UNISOC (formerly Spreadtrum) is a rapidly growing semiconductor company that is nowadays focused on the Android entry-level smartphone market. While still a rare sight in the west, the company has nevertheless achieved impressive growth claiming 11% of the global smartphone application processor market, according to Counterpoint Research. Recently, it’s been making its way into some of the budget phones produced by name brands such as Samsung, Motorola and Nokia; and the newest 5G chipset advertises an impressive 6nm process.
Despite this rapid growth, little research has been published that validates the security of the overall UNISOC platform’s boot process; and so far prior research has been focused on the kernel drivers and the modem. With Google’s continued investments into the security of AOSP, these days often the weakest links in Android phones security are found in the semiconductor vendor or OEM additions. For example, pre-installed vendor applications, vendor kernel drivers, as well as the components of a custom secure boot chain are where many major vulnerabilities are being discovered.
Thus, for user privacy and security it is crucial that the foundation, such as bootloaders and vendor drivers, upon which Android builds up, are sufficiently secured.
As part of this research, NCC Group focused on the secure boot chain implemented by UNISOC processors used in Android phones and tablets. Several vulnerabilities in the Boot ROM were discovered which could persistently undermine secure boot. These vulnerabilities could be exploited by malicious software which previously escalated its privileges in order to insert a persistent undetectable backdoor into the boot chain, or by a local adversary with physical access to the device exploiting the recovery mode present on these devices.
The first step required prior to analyzing the BootROM is to extract its binary. While second-stage bootloaders are typically readily available from Android firmware update packages, and are commonly stored without any encryption, that is not the case for the BootROM code. Since it is baked into the processor’s silicon, there is little reason for a vendor to provide easily accessible and auditable firmware binaries, and perhaps there are incentives not to make it too easily accessible in the hopes of making potential vulnerabilities harder to discover. Regardless of the actual reason, this sort of secrecy leads to additional work on researchers’ behalf in order to initially gain access to the executable binary.
After setting our sights on several modern UNISOC chipsets, NCC Group has obtained multiple UNISOC SoC-based devices:
Among these, the Teclast devices were previously documented to reuse the default UNISOC private key for signing its bootloaders that was freely available on GitHub. Additionally, as it turned out, the secure boot fuses were not burned on the Teclast devices and an arbitrary binary could be booted utilizing the system’s recovery protocol. Thus, the BootROM binary was dumped off these two devices with little effort, and was confirmed to be dated 2018-05-28 on the T618 and 2017-05-08 on the T740 device.
The Motorola device, on the other hand, did enable secure boot with a custom vendor key, so it was impossible to dump the BootROM utilizing the same shortcut. Instead, NCC Group had to reverse engineer FDL1, which is the second-stage recovery mode bootloader, and in the process discovered a buffer overflow vulnerability which allowed for arbitrary code to be executed and dumped the T700 BootROM through these means. As it turns out, however, the T700 BootROM is exactly the same as the T618 one, down to the date code marking present within the binary.
This vulnerability in FDL1 is described below.
FDL1 is a component of the UNISOC recovery process that is normally loaded from the host by the BootROM. FDL1 initializes system memory and loads the second-stage recovery payload, FDL2, from the host over a custom USB protocol. A buffer overflow issue exists in the function responsible for retrieving the data, reproduced in pseudocode below:
long usb_get_packet(byte *dst) {
...
state = 0;
is_masked = false;
writeptr = dst;
do {
if (DAT_00014c40 == DAT_00014c10) {
DAT_00014c40 = 0;
DAT_00014c10 = 0;
do {
FUN_0000f460();
} while (DAT_00014c10 == 0);
DAT_00014c14 = DAT_00014c28;
DAT_00014c28 = DAT_00014c28 ^ 1;
}
uVar2 = DAT_00014c10;
pbVar3 = (byte *)(DAT_00014bc0 + (ulong)DAT_00014c40);
while (DAT_00014c40 < uVar2) {
DAT_00014c40 = DAT_00014c40 + 1;
if (state == 1) {
LAB_0000fc70:
bVar1 = *pbVar3;
if (bVar1 != 0x7e) {
if (bVar1 == 0x7d) {
state = 2;
is_masked = true;
} else if (is_masked) {
state = 2;
*writeptr = bVar1 ^ 0x20;
is_masked = false;
writeptr = writeptr + 1;
} else {
*writeptr = bVar1;
state = 2;
writeptr = writeptr + 1;
}
}
} else if (state == 0) {
state = *pbVar3 == 0x7e;
} else if (state == 2) {
if (*pbVar3 == 0x7e) {
return (long)writeptr - (long)dst;
}
goto LAB_0000fc70;
}
pbVar3 = pbVar3 + 1;
}
} while( true );
}
Note that the function does not enforce the maximum size of a payload that it can receive. As a result, a host can send a very large payload and cause a global buffer overflow, potentially resulting in arbitrary code being executed within FDL1.
In particular, NCC Group discovered that on a device based on the UNISOC T700 chipset, the temporary buffer is pointing into FDL1 executable memory. Therefore, exploiting this bug allows us to overwrite memory training code that is no longer needed after device initialization. If the overwrite is large enough, it is possible to overwrite the following executable code that is still being used, and execute arbitrary code within the context of FDL1.
NCC Group successfully exploited this vulnerability in order to obtain code execution within the FDL1 on the Moto E40 device and dump its BootROM.
Several common challenges arise when reverse-engineering a typical BootROM. Few, if any, debugging strings are available, and the code often makes use of undocumented hardware registers or various lower-speed peripheral interfaces. For example, instead of setting up a fast DMA transfer between eMMC flash and the main memory, code for which could typically be referenced in open-source Linux drivers, the BootROM may use a slower and simpler PIO interface, that may not be publicly documented or implemented. Nevertheless, by locating standard bootloader building blocks such as UART interfaces, USB setup packet parsing, and RSA signature validation it is possible to figure out the overall design and implementation of the BootROM.
In the case of UNISOC, the BootROM is a fairly simple binary blob that takes up just around 35 kilobytes of code. Two power-on boot modes are implemented: regular boot as well as recovery boot which is entered when either a specific key is held on power up, or the second-stage bootloader is missing or fails to validate. The recovery protocol itself is similar to what is present on the older UNISOC/Spreadtrum feature-phones, with the same algorithms used for CRC calculation and HDLC protocol wrapping.
Upon locating the code responsible for the implementation of the UNISOC BootROM recovery mode, NCC Group discovered that it lacked most of validity checks on the input data. Several vulnerabilities were quickly found that allowed for arbitrary code execution within the BootROM. All of these can be reachable by an attacker that has brief physical access to the device as booting a UNISOC phone or a tablet into recovery mode only requires holding a specific button (typically volume down) during power up. The vulnerabilities below are listed in the order of decreasing severity.
The recovery mode implemented by UNISOC exposes 5 commands which are accessible over UART and USB interfaces with the goal of loading and starting the next-stage payload, FDL1.
The data transfer initialization command, cmd_start
, was found not to perform any checks against the attacker-controlled target address of the payload:
void cmd_start(cmd_start_t *payload)
{
uint write_addr_be;
uint write_sz_be;
write_addr_be = payload->addr_be;
write_sz_be = payload->sz_be;
// NCC: big endian byte-swap
g_write_addr = (ulong)((write_addr_be ^ (write_addr_be >> 0x10 | write_addr_be << 0x10)) >> 8 &
0xff00ff ^ (write_addr_be >> 8 | write_addr_be << 0x18));
g_write_sz = (ulong)((write_sz_be ^ (write_sz_be >> 0x10 | write_sz_be << 0x10)) >> 8 & 0xff00ff ^
(write_sz_be >> 8 | write_sz_be << 0x18));
g_cur_write_ptr = g_write_addr;
send_status(0x80);
return;
}
Next, when the data transfer command, cmd_recv_data
, is repeatedly executed, it writes attacker-controlled data to the attacker-controlled g_cur_write_ptr
pointer and then advances it by the size of the data:
void cmd_recv_data(cmd_recv_data_t *payload)
{
ulong sz;
// NCC: big endian byte-swap
sz = (ulong)((uint)((ulong)payload->size_be >> 8) | (payload->size_be & 0xff) << 8);
memcpy2(g_cur_write_ptr,payload->data,sz);
g_cur_write_ptr = g_cur_write_ptr + sz;
g_num_received = g_num_received + sz;
send_status(0x80);
return;
}
As a result, these two commands provide an arbitrary write primitive into the BootROM’s memory space. This functionality could then be used by an attacker with physical access to the device to overwrite a function pointer somewhere in the BootROM data section or a return address stored on the stack and execute their own code with BootROM privileges.
The implementation of the USB command dispatcher is reproduced below in pseudocode:
void recovery_comms(void)
{
uint uVar1;
payload_t *buf;
undefined4 len;
do {
while (uVar1 = receive_and_validate_payload(&buf,&len), uVar1 == 0x8f) {
(*(code *)(&g_func_table)
[(ulong)((uint)((ulong)buf->cmd_be >> 8) | (uint)buf->cmd_be << 8) & 0xffff])
(buf,len);
}
send_status(uVar1);
memset2(&DAT_00004998,0,0x30);
} while( true );
}
Note that the global array g_func_table
is indexed with the arbitrary 16-bit argument (buf->cmd_be
) which is not validated against the size of the array. Because the array only contains 5 elements, passing a command value greater than 4 would result in data past the end of the array being treated as a function pointer and the BootROM attempting to execute code at that location.
In the worst case scenario, this could result in arbitrary attacker-controlled code being executed within the context of the BootROM. However, because this array is located in the read-only BootROM memory region, and there is no obvious path to implant an attacker-controlled value nearby, the Overall Risk of this finding is reduced to Medium.
The USB data transfer function is reproduced below in pseudocode:
void receive_payload_usb(void)
{
byte *pbVar1;
byte ch;
undefined4 local_4;
local_4 = 0;
while (g_recv_status != 3) {
ch = get_byte_from_usb(&local_4);
if (g_recv_status == 1) {
if (ch != 0x7e) {
if (ch == 0x7d) {
ch = get_byte_from_usb(&local_4);
ch = ch ^ 0x20;
}
g_recv_status = 2;
pbVar1 = g_output_ptr + 1;
*g_output_ptr = ch;
g_output_ptr = pbVar1;
g_written_len = g_written_len + 1;
}
}
else if (g_recv_status == 0) {
if (ch == 0x7e) {
g_recv_status = 1;
}
}
else if (g_recv_status == 2) {
if (ch == 0x7e) {
g_recv_status = 3;
}
else {
if (ch == 0x7d) {
ch = get_byte_from_usb(&local_4);
ch = ch ^ 0x20;
}
pbVar1 = g_output_ptr + 1;
*g_output_ptr = ch;
g_output_ptr = pbVar1;
g_written_len = g_written_len + 1;
}
}
}
return;
}
The data is read byte-by-byte from the host and unmasked using an HDLC-like algorithm. Because there is no length checking performed against the received data, a host that sends a large payload could overflow the fixed-size BootROM buffer, resulting in memory corruption within the BootROM and potentially code execution.
The same issue exists in the UART data transfer function, receive_payload_uart()
, located at address 0x104924 in the BootROM.
Note that while the global buffer is located close to the end of BootROM memory and past the stack region, and it is not possible to trivially obtain code execution by overwriting a return pointer, an adversary may instead attempt to write to a memory-mapped hardware device instead that is present on the system and induce a controllable memory corruption that way.
wLength
ValidationThe USB setup packet handler contains a vulnerability where it does not properly validate the value of wLength
for requests of type GET_STATUS
:
void handle_setup_request(void)
{
...
reqTypeBit = g_setup.bmRequestType >> 5 & 3;
...
if (g_setup.bRequest == 0) {
bVar2 = cRead_1(DAT_5fff0012);
cWrite_1(DAT_5fff0012,bVar2 | 0x40);
idx = 0;
if (CONCAT11(g_setup.wLength._1_1_,(undefined)g_setup.wLength) != 0) {
do {
cWrite_1(usb_txrx,(&DAT_00004010)[idx]);
idx = idx + 1;
} while (idx < CONCAT11(g_setup.wLength._1_1_,(undefined)g_setup.wLength));
}
bVar2 = cRead_1(DAT_5fff0012);
cWrite_1(DAT_5fff0012,bVar2 | 10);
return;
}
...
else if (reqTypeBit == 2) {
bVar2 = cRead_1(DAT_5fff0012);
cWrite_1(DAT_5fff0012,bVar2 | 0x40);
idx = 0;
if (CONCAT11(g_setup.wLength._1_1_,(undefined)g_setup.wLength) != 0) {
do {
cWrite_1(usb_txrx,(&DAT_00004010)[idx]);
idx = idx + 1;
} while (idx < CONCAT11(g_setup.wLength._1_1_,(undefined)g_setup.wLength));
}
bVar2 = cRead_1(DAT_5fff0012);
cWrite_1(DAT_5fff0012,bVar2 | 10);
}
...
}
As a result, sending a GET_STATUS
setup request with a large wLength
value would disclose memory past the end of the DAT_00004010
global variable.
The implementation of the USB command dispatch is reproduced below in pseudocode:
void recovery_comms(void)
{
uint uVar1;
payload_t *buf;
undefined4 len;
do {
while (uVar1 = receive_and_validate_payload(&buf,&len), uVar1 == 0x8f) {
(*(code *)(&g_func_table)
[(ulong)((uint)((ulong)buf->cmd_be >> 8) | (uint)buf->cmd_be << 8) & 0xffff])
(buf,len);
}
send_status(uVar1);
memset2(&DAT_00004998,0,0x30);
} while( true );
}
Note how two arguments are passed further to the implementation: the payload buffer and its size. However, as NCC Group has discovered, the implementation does not actually validate the size of the received payload:
void cmd_start_usb(cmd_start_t *payload)
{
uint write_addr_be;
uint write_sz_be;
write_addr_be = payload->addr_be;
write_sz_be = payload->sz_be;
g_write_addr = (ulong)((write_addr_be ^ (write_addr_be >> 0x10 | write_addr_be << 0x10)) >> 8 &
0xff00ff ^ (write_addr_be >> 8 | write_addr_be << 0x18));
g_write_sz = (ulong)((write_sz_be ^ (write_sz_be >> 0x10 | write_sz_be << 0x10)) >> 8 & 0xff00ff ^
(write_sz_be >> 8 | write_sz_be << 0x18));
g_cur_write_ptr = g_write_addr;
send_status(0x80);
return;
}
void cmd_recv_data_usb(cmd_recv_data_t *payload)
{
ulong sz;
sz = (ulong)((uint)((ulong)payload->size_be >> 8) | (payload->size_be & 0xff) << 8);
memcpy2(g_cur_write_ptr,payload->data,sz);
g_cur_write_ptr = g_cur_write_ptr + sz;
g_num_received = g_num_received + sz;
send_status(0x80);
return;
}
In particular, cmd_start_usb
retrieves write address and size from the payload buffer without validating that the payload buffer is at least 12 bytes (2 bytes header, 2 bytes padding, 4 bytes for addr_be
and 4 bytes for sz_be
), and cmd_recv_data_usb
copies data of sz
bytes from the payload without validating the amount of data present. As a result, uninitialized memory values may be unintentionally copied. Then, by attempting to execute the resulting image, and observing the returned error code, it may be possible for an adversary to disclose portions of the BootROM memory.
Additionally, the same issue exists in the UART recovery command handlers cmd_start_uart
and cmd_recv_data_uart
.
After discovering the issues in the recovery mode, NCC Group’s focus shifted to the regular boot process. The UNISOC BootROM implements a secure boot chain with the root key anchored within the BootROM by utilizing eFuses. Every stage in the boot process is then responsible for validating the signature of the next stage. As such, compromising an early boot stage, such as BootROM validation of the second-stage bootloader, would allow for a complete takeover of the rest of the system.
One vulnerability was discovered in the loading of second-stage executables. Since this code is used for both the regular boot and the recovery boot, exploitation of this single vulnerability allows for a persistent compromise of the system.
The second-stage bootloader loaded by the BootROM contains a certificate as a part of its image. This certificate includes a public RSA key to validate the current image, as well as hash of the next public RSA key in the boot process. This creates a secure boot chain that is ultimately anchored by the BootROM to a hash of the first public RSA key stored in eFuses. However, a vulnerability is present in the BootROM where the hash of the public RSA key is not always properly validated.
Specifically, the BootROM accepts two types of certificates: 0 (contentcert
) and 1 (keycert
). According to the UNISOC’s U-Boot source code, the keycert
embeds a hash of the next public key, creating a secure boot chain, whereas the contentcert
does not and appears to be used as the last certificate in the chain. Normally, a certificate of type 1 is embedded within the second-stage bootloader and in this case the BootROM properly validates its public RSA key against eFuses. However, in the case where the certificate of type 0 is used, no such validation is performed as can be seen from the second if
condition branch in the pseudocode snippet below:
undefined8 validate_rsa(byte *fused_key_hash,byte *calculated_payload_hash,cert_t *cert)
{
...
certtype = *(byte *)&cert->certtype;
memset(decrypted_sig,0,0x100);
pubkey_hash._0_8_ = 0;
pubkey_hash._8_8_ = 0;
pubkey_hash._16_8_ = 0;
pubkey_hash._24_8_ = 0;
if (certtype < 2) {
if (certtype == 1) {
if ((cert1->type == 1) && (g_min_required_ver <= cert1->version)) {
calculate_hash(&cert1->pubkey,((cert1->pubkey).keybit_len >> 3) + 8,pubkey_hash);
iVar1 = memcmp(calculated_payload_hash,cert1->hash_data,0x20);
if ((iVar1 == 0) && (iVar1 = memcmp(fused_key_hash,pubkey_hash,0x20), iVar1 == 0)) {
local_4 = do_rsa_powmod(&(cert1->pubkey).e, (cert1->pubkey).n,
(cert1->pubkey).keybit_len, cert1->signature,
decrypted_sig);
calculate_hash(cert1->hash_data,0x48,hashed_contents);
validate_sig_oaep(hashed_contents,0x20,decrypted_sig,0x100,0x20,0x2000004,0x2000004,
0x800,&local_4);
is_valid = 1;
if (local_4 != 0) {
set_flag(0x2000000000);
is_valid = 0;
}
} else {
set_flag(0x400000000);
is_valid = 0;
}
} else {
set_flag(0x8000000000);
is_valid = 0;
}
}
else if ((cert0->type == 1) && (g_min_required_ver <= cert0->version)) {
calculate_hash(&cert0->pubkey,((cert0->pubkey).keybit_len >> 3) + 8,pubkey_hash);
// NCC: No call to memcmp pubkey_hash
iVar1 = memcmp(calculated_payload_hash,cert0->hash_data,0x20);
if (iVar1 == 0) {
local_4 = do_rsa_powmod(&(cert0->pubkey).e, (cert0->pubkey).n,
(cert0->pubkey).keybit_len, cert0->signature,
decrypted_sig);
calculate_hash(cert0->hash_data,0x48,hashed_contents);
validate_sig_oaep(hashed_contents,0x20,decrypted_sig,0x100,0x20,0x2000004,0x2000004,
0x800,&local_4);
is_valid = 1;
if (local_4 != 0) {
set_flag(0x2000000000);
is_valid = 0;
}
} else {
set_flag(0x400000000);
is_valid = 0;
}
} else {
set_flag(0x8000000000);
is_valid = 0;
}
} else {
set_flag(0x1000000000);
is_valid = 0;
}
return is_valid;
}
As a result, an arbitrary public RSA key could be provided by an adversary with the certificate type set to 0. Several possibilities then exist for potential exploitation of this issue.
Since an adversary now controls the public RSA key, an obvious avenue to exploit this vulnerability would be to craft a legitimate signature for an arbitrary bootloader image. However, an additional issue exists in the BootROM in the following snippet:
local_4 = do_rsa_powmod(&(cert0->pubkey).e,(cert0->pubkey).n,(cert0->pubkey).keybit_len,
cert0->signature,decrypted_sig);
calculate_hash(cert0->hash_data,0x48,hashed_contents);
validate_sig_oaep(hashed_contents,0x20,decrypted_sig,0x100,0x20,0x2000004,0x2000004,0x800,
&local_4);
Consider the definition of both cert0_t
and cert1_t
structures:
struct cert0_t {
uint certtype;
struct pubkey_t pubkey;
byte hash_data[32];
uint type;
uint version;
byte signature[256];
};
struct cert1_t {
uint certtype;
struct pubkey_t pubkey;
byte hash_data[32];
byte hash_key[32];
uint type;
uint version;
byte signature[256];
};
Note that an additional 32-byte hash_key
field exists in the cert1_t
structure. The intent of passing size 0x48 to the calculate_hash
function is to capture all of hash_data
, hash_key
, type
and version
variables in the hash. However, when dealing with the certificate type 0, the hash_key
field does not exist, and so a 32-byte chunk of the signature is calculated as part of the hash that is then validated using RSA-OAEP. Due to the implementation details, NCC Group was unable to craft a valid signature that could bypass this check.
Another issue is present in the RSA validation functionality that could result in a memory corruption occurring within the BootROM. Prior to performing the RSA operation, a byte-swap is performed and the result stored in a global buffer in BootROM memory:
undefined4 do_rsa_powmod(undefined8 e,undefined8 n,undefined4 bits,undefined8 sig,undefined8 dst)
{
undefined4 uVar1;
memset(BYTE_ARRAY_00002988,0,0x100);
uVar1 = FUN_001059ec(e,n,bits,sig,BYTE_ARRAY_00002988);
memcpy2(dst,BYTE_ARRAY_00002988,0x100);
return uVar1;
}
undefined8 FUN_001059ec(undefined8 e,undefined8 n,int bits,undefined8 sig,undefined8 dst)
{
FUN_00105514(dst,sig,n,e,bits >> 3);
return 0x100;
}
void FUN_00105514(undefined8 dst,undefined8 sig,long n,long e,uint bytelen)
{
DAT_00004420 = 0;
DAT_00004428 = 0;
DAT_00004430 = 0;
DAT_00004438 = 0;
DAT_00004440 = 0;
memset(g_n,0,0x800);
if (e != 0) {
byteswap_for_rsa(g_e,e,4);
}
if (n != 0) {
byteswap_for_rsa(g_n,n,bytelen);
}
byteswap_for_rsa(g_sig,sig,bytelen);
DAT_00004420 = 0xe1000010e0c0001;
DAT_00004428 = CONCAT44(0xb0002168,(bytelen & 0xffff) << 2 | 0x8d00001);
DAT_00004430 = 0xb0082468b0042268;
DAT_00004438 = 0xb80c2368580c1080;
DAT_00004440 = CONCAT44(DAT_00004440._4_4_,0xffffffff);
FUN_001054c0(0x8000001);
FUN_001052c4();
memcpy(dst,g_decoded,(long)(int)bytelen);
do_byteswap_inplace(dst,bytelen);
return;
}
Because no size check is performed against the RSA key size, a key greater than 2048 bits would overflow the global g_n
and g_sig
buffers which are 256 bytes in size. These buffers are located at addresses 0x2168 and 0x2268. Since the stack pointer is set to 0x4000 during BootROM initialization, a large RSA key is able to corrupt the stored return address on the stack and then cause arbitrary code to be executed. Since the vulnerable RSA key parsing is reachable from both the recovery and regular boot modes, this vulnerability could be exploited for persistent code execution within the BootROM context.
Despite a fairly minimal feature set and a small size of its binary, the UNISOC BootROM was found to contain several high-impact vulnerabilities, potentially affecting millions of shipped devices. While these issues cannot be fixed due to the read-only nature of the BootROM code, users can reduce their risk by not leaving their devices unattended, and installing latest software updates to mitigate the risk of CVE-2022-38691/CVE-2022-38692 being persistently exploited through a temporary privilege escalation.