In this blog post, we take a deeper look at the firmware of the COROS PACE 3. This is a follow-up post for the Bluetooth Analysis post.
Overview
In order to get a better understanding of the hardware, it is often useful to take a look at the internal hardware of the device. Luckily for us, a document with internal photos for the FCC filing is freely available on the internet. Therefore, we are not forced to open the watch, which avoids the risk of damaging anything. Unfortunately, most of the labels in the document were not clearly readable, but it allowed for a rough overview of the device. What’s immediately visible is a 4G eMMC, a SoC in the center, which could be the main MCU, and a separate Bluetooth chip next to it.
Obtaining the firmware data
When performing an update, the COROS PACE 3 downloads the firmware data over HTTP without any encryption. This allows for eavesdropping the firmware files during the update (see also SYSS-2025-029 and SYSS-2025-030).
Three of the files seemed the most interesting:
16_cypress_bt_fw.bin
: The bluetooth firmware running on the separate Bluetooth chip7_COROS_W331_system_ota.bin
: The system firmware performing most of the watches tasks81_COROS_W331_bootloader_ota.bin
: A bootloader which runs before the system firmware
These files appear to be unencrypted and simply running the strings
utility on the system OTA binary reveals a lot of log messages and source paths.
This already helps to identify the more hardware components of the watch:
1
2
$ strings 7_COROS_W331_system_ota.bin | grep psoc
coros/bsp/clock/bsp_rtc_psoc6.c
The PSOC6 is a programmable system-on-chip line by Infineon, suggesting that it is most likely used as the main SoC here. The PSOC6 is available in three different product lines:
- 61, containing Arm Cortex-M4 CPU
- 62, containing both an Arm Cortex-M4 CPU and Cortex-M0+ CPU
- 63, featuring a Bluetooth LE radio
Unfortunately, at this point we could not figure out which line was used exactly, but the 63 line seemed unlikely due to the separate Bluetooth chip visible in the FCC filings.
The bootloader
Looking at the bootloader binary in a hex editor reveals a short header with the magic bytes HF at the start. All COROS firmware files contain the following file header:
1
2
3
4
0 2 3 4 5 6 7 8 12 14 16
+------------------------------------------------------------------------------------------+
| magic | 0 | file ID | model | flags | checksum | checkxor | size | body crc | header crc |
+------------------------------------------------------------------------------------------+
Some fields worth noting:
header crc
is a CRC16-CCITT over the first 0xE bytes of the header.checksum
andcheckxor
consists of all bytes following after the header summed/xor’ed together.body crc
is a CRC16-CCITT over all bytes following after the header.
After the initial 0x10 header bytes (in blue), there are several little endian addresses, suggesting a vector table (in red).
All addresess, besides the initial stack address, are in the 0x10000XXX
range, suggesting that the base address might be 0x10000000
.
Potential vector table
After stripping the initial 0x10
header bytes from the file, we loaded the bootloader into Ghidra at said address.
We were now able to further analyze the bootloader.
Surprisingly, not much seemed to be happening after the reset vector.
The only thing worth noting was an address, to what seems to be a second vector table, being written to one of the MMIO registers.
System initialization routine
Potential second vector table
Since we know that the PSOC 62 line contains a second CPU, this is most likely the vector table for the other CPU. Now that we know that the 62 line is most likely used here, the correct SVD file with register addresses can be loaded in Ghidra.
The disassembly now properly shows the register names and confirms that the second vector table is indeed used for the secondary CM4 CPU. This CPU is also what is responsible for all the bootloader tasks. The first CPU only spins up the CM4 and is then kept idling.
Register level analysis of the CM4 Startup on PSoC 62
Further reverse engineering allows gaining an overview over what the bootloader does. We were mainly interested in how the bootloader upgrades the system OTA binary, since that appears to be the main purpose of the bootloader. The updated system OTA binary is read from the flash and then parsed by the bootloader. The updated binary is expected to contain two separate images. One of these images is then written to the internal flash, while the other image is written to an external memory-mapped flash. In order to extract these OTA files, we started working on a python script, where we re-implemented the logic used by the bootloader, to write these images to standalone binary files.
While reverse engineering the bootloader, we noticed a fallback mode, which allows flashing a firmware over USB. In order to enter this fallback mode, the USB cable needs to be connected immediately after holding down both buttons on the watch. While in this mode, the watch displays the message Please connect to PC to upgrade the firmware.
System firmware
The system firmware runs from the internal flash of the PSOC6. Additionally, an external flash is also mapped into the address space using XIP (execute-in-place). Both images contain functions and data and the firmware frequently jumps between the two. After writing a small script to extract the two images from the system OTA binary, we were once again able to load them into Ghidra for analysis. Similar to the bootloader, most of the firmware is ran on the Cortex-M4, while the other CPU is mostly kept idling.
Bluetooth firmware
The bluetooth firmware is another binary blob for the separate bluetooth chip (Infineon CYW43012) seen on the board in the FCC filing. This CYW43012 contains a vendor firmware in ROM and allows loading user code into RAM, which will act as patches to the code already present in ROM. This user code is stored in the bluetooth firmware file mentioned in the beginning, and is loaded by the system firmware using HCI commands. The file contains HCI commands which are then sent to the chip to write the user code piece-by-piece into RAM.
Excerpt of the bluetooth firmware
Extraction
After all interesting firmware files and their structures are known, we developed a script for extracting them, e.g. for further reverse engineering:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import construct
import argparse
import crcmod
import struct
class CorosFileException(Exception):
"""An exception occured while parsing the file"""
class CorosFileId:
SYSTEM_OTA = 7
BT_FIRMWARE = 16
BOOTLOADER = 81
CorosFileHeader = construct.Struct(
'magic' / construct.Int16ul,
construct.Padding(1),
'fileid' / construct.Int8ul,
'model' / construct.Int8ul,
'flags' / construct.Int8ul,
'checksum' / construct.Int8ul,
'checkxor' / construct.Int8ul,
'size' / construct.Int32ul,
'filecrc' / construct.Int16ul,
'headercrc' / construct.Int16ul
)
CorosSystemOTAHeader = construct.Struct(
'magic' / construct.Int16ul,
'imagecount' / construct.Int8ul,
construct.Padding(3),
'offsets' / construct.Array(8, construct.Int32ul),
'headercrc' / construct.Int16ul
)
crc16_ccitt = crcmod.mkCrcFun(0x11021, initCrc=0xFFFF, rev=False)
def checksum(data):
return sum(data) & 0xFF
def checkxor(data):
xor = 0
for b in data:
xor = xor ^ b
return xor ^ ((len(data) // 0xFF) + (len(data) & 0xFF)) & 0xFF
def merge_consecutive_addresses(d):
for k in list(d):
# Skip keys that were already merged
if k not in d:
continue
while True:
end = k + len(d[k])
if end in d:
d[k] += d[end]
del d[end]
else:
break
if __name__ == '__main__':
parser = argparse.ArgumentParser(
prog='corosfiletool',
description='Tool for unpacking coros firmware files')
parser.add_argument('file', type=argparse.FileType('rb'))
args = parser.parse_args()
# Parse header
headerbytes = args.file.read(CorosFileHeader.sizeof())
header = CorosFileHeader.parse(headerbytes)
# Verify header
if header.magic != 0x4648 and header.magic != 0x4644:
raise CorosFileException("Invalid file header magic")
if header.headercrc != crc16_ccitt(headerbytes[:CorosFileHeader.sizeof() - 2]):
raise CorosFileException("Invalid header CRC")
data = args.file.read(header.size)
# Verify file body
if header.filecrc != crc16_ccitt(data):
raise CorosFileException("Invalid filecrc")
if header.checksum != checksum(data):
raise CorosFileException("Invalid checksum")
if header.checkxor != checkxor(data):
raise CorosFileException("Invalid checkxor")
match header.fileid:
case CorosFileId.SYSTEM_OTA:
# System OTA images have an additional header
otaheader = CorosSystemOTAHeader.parse(data)
# Verify OTA header
if otaheader.magic != 0x464D:
raise CorosFileException("Invalid OTA magic")
if otaheader.headercrc != crc16_ccitt(data[:CorosSystemOTAHeader.sizeof() - 2]):
raise CorosFileException("Invalid OTA header CRC")
# Unpack images
offsets = otaheader.offsets
imagecount = otaheader.imagecount
for i in range(imagecount):
startoffset = offsets[imagecount - i - 1]
endoffset = offsets[imagecount - i] if i > 0 else len(data)
filename = f'system_ota_image_{i}.bin'
print('Writing ' + filename + '...')
with open(filename, 'wb') as f:
f.write(data[startoffset:endoffset])
case CorosFileId.BT_FIRMWARE:
offset = 0
appldata = {}
while offset < len(data):
cmd, cmdsize = struct.unpack('<HB', data[offset:offset+3])
offset += 3
cmddata = data[offset:offset+cmdsize]
offset += cmdsize
if cmd == 0xFC4C: # write data
dataaddr, = struct.unpack('<I', cmddata[:4])
# Store data in a dict to merge later on
appldata[dataaddr] = cmddata[4:]
elif cmd == 0xFC4E: # entrypoint
entry, = struct.unpack('<I', cmddata)
print(f'Application entry point: 0x{entry:08x}')
# Merge the dict
merge_consecutive_addresses(appldata)
# Write merged chunks to files
for k, v in appldata.items():
filename = f"cypress_bt_fw_{k:08x}.bin"
print('Writing ' + filename + '...')
with open(filename, "wb") as f:
f.write(v)
case CorosFileId.BOOTLOADER:
print('Writing bootloader_ota.bin...')
with open('bootloader_ota.bin', 'wb') as f:
f.write(data)
Crash investigation
As mentioned in the previous blog post, multiple crashes were discovered during black-box fuzzing. With the firmware now loaded, we can investigate these crashes further. When encountering an exception, the watch stores the exception context to the backup registers of the SoC. These backup registers keep their contents even when the SoC are powered off, allowing for persistent storage of the exception context. On the next power cycle, the register contents are read and stored into a log file transmitted to the connected phone via BLE. This log file can then be retrieved from the phone and analyzed to figure out the cause of the crashes. A crash in the log file will look something like this:
1
2
3
4
5
6
7
8
9
10
11
HardFault0:1014b7a2 1006e150 1014d454 100631bc 1008cb62 100b8dda 0
Regs:0 1006e151 1014b7a2
Task:805b678 805b358 805b4e8 805b290 805b5b0 805b740 805b678 803c180
Heap:3a3e0000 2d2e1419
cm backtrace:
1014b7a2 1006e150 1014d454 100631bc 1008cb62 100b8dda 100f3d3e 100f415a
10077f52 100f409e 100b9098 100b92e0 10077c26 100b4592 10077c82 100f415a nested irq 0
r0=00000001 r1=0803c25d r2=00000000 r3=00000069
r4=00000200 r5=0803c257 r6=00000008 r7=0803c4e0
r8=00000000 r9=00000000 r10=0803c261 r11=000000ff
r12=00000000 lr=1006e151 pc=1014b7a2 xpsr=61000000
It contains the current register state of the CM4, including a backtrace. This allowed for quickly analyzing the crashes with the firmware now loaded in Ghidra.
NULL pointer dereference
The first crash log leads to the app_android_info_treat
function responsible for parsing notifications sent from the app.
The exact structure of the notifications format is described in the previous blog post.
As a quick recall: A notification consists of three lines, where the first line is the package name (e.g. com.whatsapp
), the second line the header and the third line the notification body.
Taking a closer look at the function reveals that it will use the end of the package name as the header (line 1), if it starts with a null byte. In that case, the name after the com.
prefix is used as the header.
Code snippet for header package name parsing
If line 1 is missing completely though, the l1_data
pointer will be NULL, causing a NULL pointer dereference resulting in a crash of the watch.
Out-of-bounds read
The second crash log leads to a checksum function in the ble_protocol_cmd_receive
function.
This function expects 16-bit packets containing a command field as the first byte:
1
2
3
4
0 8 16
+-------------+
| Command | 0 |
+-------------+
After sending command 0xB9
, an additional packet from the same connection ID can be received.
The function expects the packet to contain additional data over which an 8-bit checksum will be calculated.
The checksum is validated against the last byte of the packet:
1
2
3
4
0 8 16 n-8 n
+-------------------------+
| 0 | 0 | Data | Checksum |
+-------------------------+
When sending a second command to the same connection, the checksum is calculated over the command body, shown by the following decompiled code.
Code snippet for checksum calculation
Sending a packet that is only 2 bytes in size causes an underflow in the 32-bit data_size
variable, resulting in the value 0xFFFFFFFF
to be passed to the calculate_checksum
function as the buffer size.
As a result, calculate_checksum
reads past the end of the buffer until it reaches the end of the memory bounds, at which point a data abort is raised.
The signature check
Since no signature check could be found in the bootloader, an attempt was made to modify the firmware. For this, a string was edited and the firmware was written to the watch using the bootloader fallback mode. When attempting to modify the firmware of the watch, the updating process would always fail at 0% on the installing step, even though all checksums in the header were properly calculated.
Firmware update attempt
A later version of the bootloader revealed that they added an RSA2048 signature check to the system OTA image:
RSA signature verification
This makes it no longer possible to write a modified firmware to the watch using the updating mechanism. We assume that it might have been possible to modify the the system image in previous versions.
Conclusion
In the end, we were able to analyze and reverse engineer the bootloader, system image and bluetooth firmware. We gained valuable insight into the internal workings of the watch and were able to confirm the root cause of the DoS vulnerabilities discovered during blackbox fuzzing.