Recently, I came across relatively cheap medical devices: consumer-grade pulse oximeters. These devices clip onto your finger and shine a light through it. By analyzing the light transmitted through your finger, the device can infer your pulse and blood oxygen saturation.
In this project, I specifically looked at the Beurer PO 80. This is a German-engineered medical device (according to the box) for less than $100 at reputable resellers. It has a USB port for connecting to a PC to view pulse and SpO2 in real-time and to download previously recorded data.
PC Software
These pulse oximeters are compatible with the free “SpO2 Assistant” software. This software seems to support a variety of different pulse oximeter models. It plots pulse and SpO2 data in real time, allows for exporting recorded data, and lets you configure some basic settings of the pulse oximeter like patient name (no idea why this would be necessary) or the current date and time.
First, I unpacked the SpO2 Assistant software and loaded it into Ghidra. My initial plan was to reverse-engineer the custom USB HID protocol that the Beurer PO 80 seems to use. Quickly, I stumbled upon embedded strings and a logo that makes me question the “German engineering” claim. But to be fair, the software was technically not part of the pulse oximeter itself.
I soon realized that static decompilation is probably not the most effective way to understand the USB HID protocol. Instead, I connected the pulse oximeter and used a protocol sniffer to eavesdrop on the communication between device and PC software.
With this dynamic analysis method and some trial-and-error, I was able to partly reverse-engineer the protocol. I wrote a Python tool that can initialize and fetch pulse and SpO2 data from the Beurer PO 80.
Device Teardown
As a next step, I took the pulse oximeter apart. It disassembles nicely without any screws or glue. The build quality is definitely not outstanding, but not surprising at this price. You can find suspiciously similar devices for less than $10 on Aliexpress.
The device has a 240 x 240 color display and a user button on the front side. The main microcontroller is a GigaDevice GD32F350RBT6, a 108 MHz, Arm Cortex-M4 core with 128 kB flash and 16 kB SRAM. Additionally, there is a serial flash memory chip for recording data. An accelerometer detects the current orientation and rotates the display accordingly. There is also room for a Bluetooth module that is not populated on the USB-only PO 80.
Conveniently, there is a debug connector that exposes the SWD debug interface of the microcontroller. With this, I was hoping to dump the firmware for further analysis.
Bypassing Flash Readout Protection
After connecting a debug probe, the device was successfully detected, but I could not dump the firmware. The device had “low-level protection” mode enabled. While in this mode, SRAM and memory-mapped peripherals can be read through the debugger, but read-out of flash is prohibited; only code can access flash. Also, boot from SRAM is disabled in this mode, to prevent loading a flash dumper directly into SRAM.
I used a known hardware vulnerability of these microcontrollers to bypass the read-out protection. The die revisions used in my device were still vulnerable. Huge thanks to a1exdandy, the author of the original research, for publishing the blog post and helping me get his exploit to work.
With this exploit, I could successfully dump the firmware of the device. Due to the nature of the exploit, the chip had to be completely bricked during this process, i.e., the SWD debug port had to be completely disabled. This, however, is not a problem. I can just buy a new microcontroller, flash the original firmware that I just dumped, and now I have a fully unlocked development device.
As a side note: replacing the chip took longer than expected. I accidentally ordered a GD32F350R8T6, instead of the GD32F350RBT6 that was in the device originally. These two types differ in their flash sizes: 64 kB vs 128 kB. Don’t ask me why GigaDevice thought this naming scheme and this font was a good idea. I only realized my mistake after a few hours of debugging, where I noticed that only half of the firmware would be flashed correctly. After reordering the correct type, my pulse oximeter was working again.
Customizing Firmware
After resurrecting the device, connecting GDB and stepping through the code still did not work as smoothly as expected. This was mainly due to two things: First, the firmware would automatically enable low level protection again at run time. Hence, the microcontroller can only be debugged once. Second, the device would enter a sleep mode after a few seconds of inactivity, even if a debugger was connected. By searching for references to the option byte control register, I quickly identified the following snippet in the firmware:
By cross-referencing with the datasheet, you can see that this indeed enables low-level protection.
By changing 0xBB to 0xA5 in a hex editor, I was able to successfully patch out this protection mechanism; the microcontroller would now stay unlocked indefinitely.
Similarly, I was able to resolve the second problem by patching out
the watchdog configuration and the deep-sleep enter (wfi
instruction).
Using the Display
Next, I wanted to access the display. While I could reverse-engineer the PCB layout and rewrite the firmware from scratch, I wanted to reuse as much of the original firmware as possible.
To identify code that is responsible for accessing the display (there are no symbols or debug messages in the binary), I started the pulse oximeter and interrupted it with a debugger while it was displaying a splash screen. From there, I could backtrack and identify the relevant function.
This function essentially looks like this:
void display_draw(char* buf, int x, int y, int width, int height)
i.e., it can display an arbitrary-sized buffer at an arbitrary
location on the display. To display the Doom splash screen, I patched
the image data into an unused section of the flash memory. Instead of
manipulating the function call in the binary (which can be a bit
tricky), I used the following GDB script to dynamically patch the
buffer address on the stack at run time, right before the
display_draw function call.
target extended-remote :4242 # Connect to the target
b *0x08007f06 # Set break before display_draw
define hook-stop # Define a hook, run at break
x/10x $sp # Print the current stack
set {int}0x20001698 = 0x08002000 # Replace the buffer on the stack
continue # Resume execution
end # End of hook definition
monitor reset # Reset the target
continue # Continue until break
While this allows me to draw anything I want on the display, it is not actually running Doom yet. For this, I would probably have to start using a compiler instead of only a hex editor.
Since I can freely program, patch, and debug the microcontroller firmware, this is definitely doable. However, I think it would be more interesting to leverage this level of access for further investigation of the existing firmware, e.g., to look for exploitable memory corruption vulnerabilities. It would be really cool if, e.g., a buffer overflow in the custom USB HID protocol could be used to gain code execution without physical access to the device.
Please reach out if you would like to contribute to this challenge. I may follow-up with this project in the future, but for now, I will focus on other things. Thanks again a1exdandy for the help with bypassing the GD32 readout protection and others for their help and advice.