rb9

Light jailbreaking: exploiting Tuya IoT devices for fun and profit

Published on . Last updated on

Tuya Smart is one of the biggest global manufacturers of affordable whitelabel IoT devices for various brands. Wanting to use their WiFi-connected LED lights locally without connecting to the cloud, I went on a hunt together with Tom Clement to run custom firmware on these lights.

In this writeup, we describe the out-of-bounds memory write bug we found, along with an exploit chain that allows overwriting all security keys needed to control most Tuya devices and flash custom firmware over-the-air given physical access to the device.

We started coordinated disclosure with Tuya on Feb 12th 2022 to report the bug we used, which has since been patched on newly-produced stock. They were friendly and very cooperative, and we want to thank them for our pleasant experience.

N.B. jailbreak/exploit tooling can be found on the tuya-cloudcutter GitHub repository.

Summary

We identified a vulnerability in the AP configuration process in the BK7231T and BK7231N SDKs. This vulnerability seems to have existed in these SDKs since their debut on GitHub. It also existed in more or less the same form on all of the devices’ firmware that we have dissected. The vulnerability allows hijacking control flow on the device; but in a rather limited fashion. However, when coupled with a few interesting subroutines that we found in the SDKs (as well as deployed firmware), we managed to build an exploit chain leading to control over factory-burned secrets on the device. More specifically, the exploit chain allows overwriting these device parameters with controlled data:

  • UUID: the device unique identifier.
  • Authentication key, AKA authkey or auzkey: this parameter along with the UUID uniquely identify a device and allow it to further encrypt and authenticate its TLS-wrapped communication with Tuya’s servers.
  • Pre-Shared Key, AKA psk or pskkey: used as a shared secret to establish a TLS communication channel between the device and Tuya’s servers.

Armed with these parameters, it is possible to locally configure (i.e. set the local and sec keys used for local device control) and control the stock firmware without the need to communicate with Tuya’s servers in addition to remotely initiating Over-The-Air (OTA) firmware updates.

N.B. this vulnerability is practically unexploitable for attackers without physical access to the affected device, because the vulnerable code path requires that the device is set in configuration mode which requires physical access. Therefore, we would like to make it clear that there’s likely little to no impact for the average user. However, it is interesting from a “jailbreaking” point of view for the more advanced users who would want to exercise more control over the software on their smart home devices.

The rest of this writeup elaborates on the details of the vulnerability as well as the exploit chain. Enjoy ;)

Vulnerability description

To start off, let’s get a bit of a background to get the context set up. Most of the Tuya devices we’ve tested have a so-called ‘AP configuration mode.’ In this mode, which is usually reachable by turning the device off then on a few times, the device would set up and advertise a WiFi AP. From there, the user can configure the device to connect to the user’s own network by connecting to its AP and using any of the “Smart Life” style applications.

Taking a bit of a deeper look, the following recipe is more or less what happens:

  1. Device sets up a WiFi AP with DHCP and hands out leases for 192.168.175.0/24. It assigns itself the IP address 192.168.175.1.
  2. It listens on port 6669 over UDP for configuration messages. These messages are sent by Smart Life apps to onboard the device on the user’s WiFi AP.
  3. The user is asked to enter their AP SSID and optionally a password, after which their smartphone spams the the configuration message over the network to the UDP service listening on the device on port 6669. One important point to note here is that the entire UDP message payload (that is the message contents, not including the UDP headers) cannot exceed 256 bytes.
  4. Once one of these configuration messages make it to the device, it will parse the message and if sane, shut down its AP and reconfigure itself as a station. It will then attempt to connect to the user’s designated AP and immediately starts a cloud-based configuration request-response sequence with Tuya’s servers.

There are more steps than that until the device is considered ‘functionally configured.’ However, for the purposes of this write-up we will keep ourselves to just the steps above.

Onto the implementation of the process outlined above; the messages are encapsulated in a binary format which is essentially some TLV (Type-Length-Value) format with CRC to check for validity. We won’t look at the encapsulation since it’s not terribly important; what’s important to note is the payload which is the configurtion message. It’s a JSON-formatted payload which is optionally encrypted, and has the following structure:

{"ssid": "<SSID>", "passwd": "<PASSWORD>", "token": "<TOKEN>"}

ssid and passwd are self-explanatory. As for the token, it’s usually an 8-character ASCII string which is generated by Tuya’s servers as a result of a so-called ‘registration request’ that the apps invoke. This code is a short-lived one-time use token which the device has to get right in order to be ‘activated’ on the server side.

Keeping this structure in mind, let’s move onto the implementation on the device’s side of the equation. In the libtuya_iot.a binary blob which is part of the BK7231T SDK, there is an object file named ap_netcfg.c.o. This file, as the name indicates, contains the code which implements a sizeable part of the AP configuration process; the most interesting bits of which can be found in the __udp_ap_v3 function.

Zooming into it, the annotated psuedocode for the process is as follows:

// 1. Receive UDP message from socket and parse the binary container
// 2. Look for the wrapped JSON payload
// 3. Parse the JSON payload
// ...
// 4. Processing:
// lan_ap_nw_cfg is a pointer to a global struct containing info
// about the AP network configuration
ap_cfg_token_data = lan_ap_nw_cfg->ap_cfg_token;
// ap_cfg_token_data is where the token from the JSON payload gets copied
memset(ap_cfg_token_data,0,0x40); // Zero out the field
// token_string is the string representation of the
// token object in the blob
token_string_len = strlen(token_string);
memcpy(ap_cfg_token_data,token_string,token_string_len); // [1]
// ...
// 5. Once the data has been parsed, signal the received AP config
// parameters by invoking the finish_cb function pointer, which is
// configured during device initialization.
pcVar2 = (char *)(*lan_ap_network_config_struct->finish_cb)((PTR_SSID_PASSWORD_TOKEN)&lan_ap_network_config_struct->spt,0x10002); // [2]
// 6. Process the results of the finish_cb invocation and loop back to
// the beginning if invalid. Otherwise, terminate this process and its
// asscoiated FreeRTOS task.

And the structure of the lan_ap_nw_cfg struct, as found in ap_netcfg.c.o is:

struct SSID_PASSWORD_TOKEN {
    uint8_t ssid[33];
    uint8_t s_len;
    uint8_t passwd[65];
    uint8_t p_len;
    uint8_t token[17];
    uint8_t t_len;
};

struct LAN_AP_NW_CFG_S {
    THRD_HANDLE thread;
    BYTE_T recv_buf[256];
    CHAR_T ap_cfg_token[64];
    INT_T fd;
    TIMER_ID log_ack_timer;
    MSG_ID send_log_mid;
    int (* finish_cb)(PTR_SSID_PASSWORD_TOKEN, int);
    struct SSID_PASSWORD_TOKEN spt;
    undefined field_0x1c6;
    undefined field_0x1c7;
    UINT_T log_len;
    CHAR_T * log_buf;
    UNW_IP_ADDR_T client_addr;
    UCHAR_T ap_cfg_finished;
    UCHAR_T err_log_finished;
    UCHAR_T log_serial_finished;
    UCHAR_T has_log_serial;
    UCHAR_T ap_disconnected;
    undefined field_0x1d9;
    undefined field_0x1da;
    undefined field_0x1db;
    int netcfg_type;
};

The little code section above highlights the vulnerability in its purest form; the token field in the JSON structure has no length restriction and is used (along with its length calculated by strlen) as an argument to memcpy at [1], which immediately allows us to pull off an out-of-bounds (OOB) write to the ap_cfg_token field. By using this OOB write, we can clobber all the subsequent fields in lan_ap_nw_cfg all the way to the finish_cb field which gets used at [2], meaning that control flow redirection is possible.

Exploitation

The vulnerability provides us with a great primitive to be working from. However, when it comes to useful exploitation, there are some caveats that we have to be aware of:

  1. As mentioned before, the message length cannot exceed 256 bytes. Since the wrapping binary format is necessary, that also puts a size limit on the JSON payload length which can not exceed 232 bytes.
  2. There can be no null bytes in the token field; that is because the library used to parse JSON, which is a modified version of cJSON called ’ty_cJSON’, also uses C-style null-terminated strings. Additionally, should it have not been a problem for the parser, it would have still not worked because number of bytes copied is decided by a strlen invocation. Luckily, the BK7231 chips are little-endian ARM processors; making this more of a minor inconvenience than an issue.
  3. As far as we’re aware, it isn’t possible to execute code directly from flash, stack nor heap memory regions on BK7231 processors. This may turn out to be incorrect due to the general lack of information about the platform. Of course, there are ways around this limitation (e.g. ROP/JOP), but it makes generalized exploitation harder to pull off and we haven’t been able to build a firmware version agnostic chain yet.

With these limitations in mind, we can start building an exploit chain by analyzing the stack and registers’ content around the invocation site of finish_cb. Additionally, we have to find some ROP gadgets which could allow us to build control flow redirection chains given that we initially have no control over the stack’s contents at the invocation site, but some control over whatever data that gets parsed from the received UDP message.

After a bit of reversing and some trial-and-error runs on an E27 smart light device, we mapped out the registers’ contents at the invocation site:

r0 = pointer to ssid
r1 = 0x10002
r2 = 0x80000000
r3 = finish_cb function pointer
r4 = (pointer to lan_ap_nw_cfg + 0xfc) ; [1]
r5 = pointer to passwd
r6 = pointer to ssid
r7 = pointer to the parsed ty_cJSON object associated with the configuration request

[1] - this is about 8 bytes away from the end of the UDP receive buffer used by the task, so we have some control over that as well and it nicely has no constraints on the value of the bytes that can be there.

We also found a rather handy instruction pattern littered all over the firmware which allows building ROP chains that enact side-effects on register values:

ldr rX, [rY, #Z]
adds rD, rS, #0
blx rX

where rX was found to be usually r3.

Using these two pieces of information, it’s possible to build ROP chains which are somewhat useful. For example, to perform the operation r0 = r7 then pass control to a specific code section, we could search for gadgets that implement adds r0, r7, #0 as the middle instruction. By choosing rY, #Z to be some displacement from a register such as r0 or r5 which point to the controlled ssid and passwd respectively, it is also possible to choose where to pass control flow next. On the same E27 device, one such gadget which is semantically equivalent but of a different form and consisting of THUMB instructions is

adds r0, r7, #0
ldr r1, [sp, #8]
ldr r3, [r5, #0x20]
blx r3

In order to utilize that, we modify the finish_cb pointer to that gadget’s address (0x000b94b8) by setting the token field to 72 bytes of padding followed by \xb9\x94\x0b. Additionally, we also set the passwd field to 32 bytes of padding followed by the address of the next instruction that we would like to continue execution at after setting r0 = r7. Of course, other side effects are possible and the firmware is large enough to have a bit of a variety within executable regions, at least for most register value copies.

From control flow redirection to remote OTA updates

So it’s possible to redirect control flow, and there are ways to pull off some useful side effects. Nonetheless, so far this is a far cry from remote OTA updates. Is that possible within these constraints?

Turns out that it is! We noticed that there’s a piece of test code for post-production testing, in the function named __mf_cmd_process for the BK7231T SDK or mf_basic_test for the BK7231N SDK. Both function versions seem to be used for multiple factory configuration routines, including perhaps changing necessary flash-persisted parameters through serial. Both of these functions can usually be found in some code object which has the name mf_test.c.o or basic_test.c.o.

In pseudocode, the functionality of that piece of code is something similar to the following procedure set:

1. Read data from serial
2. Perform some checks on it
...
3. If all the checks pass, then

c = parsed ty_JSON object retrieved from serial
// [1]
gateway_base_conf = malloc(sizeof(GW_BASE_S))

// The following parameters are checked for presence in the object
// and subsequently used. All of them are necessary for the function
// to successfully complete. Otherwise, early bail-out occurs.
gateway_base_conf.auth_key = c["auzkey"] // string, must not be empty
gateway_base_conf.psk_key = c["pskKey"] // string, can be empty string
gateway_base_conf.uuid = c["uuid"] // string, must not be empty
gateway_base_conf.ap_ssid = c["ap_ssid"] // string, should not be empty
gateway_base_conf.prod_test = c["prod_test"] // boolean: must be false

persist_to_encrypted_flash(gateway_base_conf)

4. Print some debug info and return to caller

This function provides all the necessary ingredients to overwrite the device secret parameters: uuid, auzkey and pskKey with ones that we control. Before we can use it, though, we must have a JSON payload which has all the necessary parameters. Doing so correctly is a bit tricky due to the size limitation in our payload and the limited number of usable gadget side effects. It won’t be possible to try and smuggle another JSON payload within any of the fields of the configuration message.

However, we can simply fill the configuration message JSON payload with the necessary fields without any side effects on the vulnerability trigger. All we would have to do then is find an intermediate gadget which sets the return value register, namely r0, to the register holding the parsed ty_cJSON object which represents the configuration message. Thereby, once we trigger the vulnerability, it sets c at [1] above to our configuration message and continues execution all the way to persisting our chosen parameters in flash. Looking at the disassembly of the function on the test E27 device firmware, the exact target location [1] can be clearly identified at address 0x000ad514:

; ...
ldr        r3,[sp,#0x38] ; 0x000ad508
add        r3,#0x6 ; 0x000ad50a
add        r1,3,#0x0 ; 0x000ad50c
str        r3,[sp,#0x3c] ; 0x000ad50e
bl         ty_cJSON_Parse ; 0x000ad510
ldr        r7,[LAB_000ad79c] ; 0x000ad514 - [1]
sub        r6,r0,#0x0 ; 0x000ad516
bne        LAB_000ad52c ; 0x000ad518
; ...

Putting it all together

To wrap up, using the E27 smart device we’ve mentioned as an example so far:

Set the device in AP configuration mode and connect to the AP. Afterwards, send a configuration message to the configuration service listening on UDP port 6669 which includes the following JSON payload structure:

{
    "auzkey":"<desired auth key>",
    "uuid":"<desired uuid>",
    "pskKey":"",
    "prod_test":false,
    "ap_ssid":"A",
    "ssid":"<ssid value>",
    "token":"<72 bytes of padding><least significant 3 bytes of intermediate gadget>",
    "passwd":"<passwd value>"
}

And for that specific E27 light bulb, this is the final payload which we used as a Proof-of-Concept:

{
    "auzkey":"AAAAAAAAAAAAAAAA",
    "uuid":"abcd",
    "pskKey":"",
    "prod_test":false,
    "ap_ssid":"A",
    "ssid":"A",
    "token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xb9\x94\x0b",
    "passwd":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x15\xd5\x0a"
}

Where the intermediate gadget at 0x000b94b8 is:

adds r0, r7, #0
ldr r1, [sp, #8]
ldr r3, [r5, #0x20]
blx r3

And the final target in the middle of the test function is at address 0x000ad514. Once the device gets the message, it persists our chosen parameters to flash and hangs. After a reboot, it starts up again with our parameters, allowing us to properly MITM its communication and subsequently get as far as OTA firmware updates.

Acknowledgements

We would like to thank Jilles Groenendijk for his massive help with disassembling, dissecting and dumping the firmware for many smart device models throughout the process of validating exploitability.

Timeline

Timeline image