Psst: I’m publishing a book with No Starch Press! I wrote “From Day Zero to Zero Day” for newcomers looking to enter the rarefied world of vulnerability research. From code review to reverse engineering to fuzzing, I go through the “how”, not just the “what”, of hunting zero days - stuff that I wish I could’ve learned from the beginning. Available for early access now at all your usual channels including Amazon, out in June 2025!
Why hack one device, when you can hack all of them? By reverse-engineering and finding vulnerabilities in user-machine association flows for smart weighing machines, I was able to take over millions of internet-connected health devices. Hardware and web security are two halves of modern smart device security, and learning to hack both can yield impressive and scary results. This blogpost goes through the basics of hacking connected smart devices from end-to-end, focusing on the critical workflow of user-device association.
Internet-Connected… Weighing Machines??? ๐
While I was on holiday, I noticed a strange icon on the screen of a weighing machine in the hotel gym. It was a WiFi icon. I realised to my horror that people have now decided that connecting weighing machines to the internet is a good idea (RIP, Internet of Shit). When I checked on Amazon, I noticed a plethora of available options sporting WiFi or Bluetooth connectivity, many with suspiciously-similar mobile apps.
In fact, many were made by the same OEM. Even if they were made by different OEMs with marginally different codebases, a quick peek at the associated Android applications revealed that many of them used the same common libraries, such as com.qingniu.heightscale
, presumably because it would take way less effort to write a compatible library from scratch.
While the BLE protocol-related code was interesting and allowed me to figure out the right opcodes to communicate with these devices over Bluetooth, most of them have been reverse-engineered and documented by the openScale project. In any case, it wasn’t very interesting trying to figure out a local exploit that required close physical proximity.
We Need to Go Deeper ๐
If your goal is to hack not just one but all of the devices, a key target is the user-device association flow. For example, when you first buy a smart device and take it out of the box, you often need to login to a mobile application and scan a QR code or pair with the device over Bluetooth. Once done, your user account on the manufacturer’s web service is now associated with the physical device.
This can be a tricky process to secure. Starting from the factory, each device needs a unique device identifier/secret so you don’t accidentally pair with another device B when scanning the QR code of device A. The least-secure way of doing this is using a static string such as a UUID, MAC address, or serial number. While these might be fine as an identifier, it’s not really secure as an authentication secret. Even if they might be randomly-generated and thus hard to bruteforce, it’s going to be very difficult to revoke them in case they get leaked.
A more secure option would be to generate cryptographic keys like public/private key pairs. This still makes it a target for physical memory extraction, and if the key generation process is weak in some way, an attacker could still potentially generate arbitrary keys for any device. The conventional solution it to rely on good old public key infrastructure and certificate architecture, allowing for easy revocation of compromised certificates.
So the typical flow would go:
- User installs mobile app and logs in with their user account.
- Through the app, user connects to the hardware device.
- Hardware device’s secret is sent to the mobile app.
- Mobile app sends both the user’s secret (e.g., session token) and the device’s secret to the server.
- Server confirms the authenticity of the secrets and associates the user account to the hardware device.
- User can now control and fetch data from the hardware device remotely over internet.
Seems reasonable. What could go wrong?
SQL Injection in OEM (BT-WAF Bypass) ๐
The OEM in question stumbled right out of the gate. Without even needing to buy a physical device, I enumerated the available API endpoints on the mobile application, including an interesting api/ota/update
endpoint. I thought this would allow me to get my hands on firmware to further understand the device. Thanks to the decompiled Java code for the Android mobile app, I was able to easily reconstruct the required JSON body parameters. However, it appeared that even with the right inputs the manufacturer didn’t actually have a lot of updates to share.
Unfortunately, while exploring the API endpoints, I discovered that there were several endpoints that suffered from basic SQL injections. Interestingly, the server used a Chinese WAF called Baota Cloud WAF (BT-WAF), which was much stronger than many typical WAFs I had faced before. In particular, a /api/device/getDeviceInfo
endpoint allowed looking up serial numbers for devices, which were used as both identifiers and authentication secrets by this manufacturer. The serial number itself was used in a /api/device/bindv2
endpoint that would bind, or associate, the requesting user’s account with the device referenced by the serial number! The “serial number” itself was a randomly-generated MAC address which was stored on the devices.
Here’s the initial payload body for the vulnerable endpoint:
{
"serialnumber":"'001122334455"
}
There’s not a lot to work with here. If there was a second injection point, I might have been able to work out a more subtle spanned payload. With a lot of trial and error, I eventually managed to land on this bypass for BT-WAF:
{
"serialnumber":"'or\n@@version\nlimit 1\noffset 123#"
}
Let’s break it down a bit. If this was injecting into an SQL statement like SELECT * FROM devices WHERE serial = 'INJECTION'
, the final injected SQL would be: SELECT * FROM devices WHERE serial = 'INJECTION'or\n@@version\nlimit 1\noffset 123#'
. There are two key bypass gadgets here:
@@version
always evaluates to true and can be used instead of the more obvious1=1
.\n
newlines can break up a statement instead of spaces.
With this, I was now able to leak the device information, including the serial number used as authentication secrets, of any device! As it turned out, by incrementing the offset
, this numbered more than two hundred thousand devices.
Getting a Serial Debugging Shell on the Withings WBS06 ๐
As I turned to research other devices, I came across the Withings Body weighing machine. Similar to the others, it featured WiFi and Bluetooth connectivity and a custom mobile application. This was a much more reputable brand and interestingly appeared to be co-branded as the Nokia Body scale as well.
It was relatively easy to pull the firmware via the app’s API for futher analysis. However, unlike more complex firmware used by routers and such that typically included a full filesystem and Linux operating system, this was a baremetal ARM firmware. I wish there was more I could shed light on about the art of reversing baremetal ARM, but videos and blogs by experts with a lot more experience basically tell you the same thing: it’s extremely hard.
Nevertheless, I made a crack at it, following the Analyzing bare metal firmware binaries in Ghidra blogpost by Barun and getting a somewhat-decent approximation of what I needed to know. This involved figuring out the microcontroller model of the WBS06 via internal photos from their FCC certification documentation and setting the correct memory mappings.
What grabbed my attention was a couple stray strings that hinted at a… shell?
Connection Manager Shell Command
Usage:
wifi <wifi_sync_flags>
Attempts a Wifi sync with the given flags.
wifi_sync_flags is a combination of the following flags:
0x01 (allow update), 0x02 (store DbLib), 0x04 (send DbLib), 0x08 (send
rawdata),
0x10 (send wlog), 0x20 (send events), 0x40 (send extras)
wifi_no_update <wifi_sync_flags>
Attempts a Wifi sync, no update allowed (even if set in flags).
wifi_update <wifi_sync_flags>
Attempts a Wifi sync, allows update if available (even if not set in
flags).
bt Attempts a Bluetooth sync
do Attempts a Wifi/Cellular sync and fallback to Bluetooth if it fails.
Why would a smart weighing machine have a shell on it? With a bit more sleuthing, I came across a Reddit post by another researcher who had actually figured out the UART pins on an earlier model, the WBS05.
This seemed pretty straightforward, so I excitedly set about trying to replicate this on the WBS06. The biggest clue was that the WBS06 also had the same three holes on the bottom corresponding the the Tx, Rx, and GND UART pins, and comparing this to the internal pictures from the FCC documentation confirmed this.
However, my initial efforts failed. Despite correctly figuring out the right baud rate with a logic analyser, my serial connection kept returning gibberish. After many more hours of pain, I realised that my cheap CP2102 USB to TTL converter was responsible for the issue, and using a more reliable FT232 finally got the results I needed.
Now that I had a debugging shell, I could explore all of the stored data on the device, including the certificate, secret keys, and more! Of course, while this was exciting, it didn’t really mean much - I could “hack” a device I already owned, big deal.
Broken User-Device Association Logic ๐
To really test remote vectors, I needed to fully understand how the device authenticated itself to the API servers and performed user-machine association.
For example, the connection_manager wifi
command would attempt a connection to the API servers with verbose debug logging.
shell>connection_manager wifi
[info][CM] Connection manager request, action = 3, wifi sync flags = 0xffffffff
[VAS] t:15
[info][CM] Start with cnlib action = 3
[VAS] t:15
[CNLIB] Recovered LastCnx from DbLib
[AM] Defuse id 4
[TIME] Current time (timestamp) 0 , 8h 0min 0sec
[TIME] Waking up in 16h 90min 60sec
[TIME] Add random time 0
[AM] Set id 3 at 63060
[AM] Set id 1 at 600
[CNLIB] Try to connect via wifi (1)
[DBLIB][ERASEBANK] Bank 1
[info][DBLIB][SUBSADD] 14 0
[info][CM] Initializ[VAS] t:15
e Wifi
[WIFIM] Request
[WIFIM] init
[VAS] t:15
wifi_chip_enable
bcm43438_request
== Set dcdc_sync ==
bcm43438_request: pwron module
[WIFIMFW] current_fw == FW_2 1
version 1
size 80
[WIFIMFW] wifi_crc: 0
[WIFIMFW] Take current bank
[WIFIMFW] Firmware block 1a8000 : OK
[WIFIMFW] Wifi Offset 21a370, lenght 58d1d
[WWD] HT Clock available in 31 ms
[WWD] mac: a4:7e:fa:19:2c:f6
supported channels: 13
[WIFIM] init OK
[info][CM] Wifi initialized
[WIFIM] join_configured_ap
[VAS] t:15
[WIFIM] ssid = ...
[WIFIM] key = ...
[WIFIM] WPA key already saved
[WWD] join: ssid=<...>, sec=0x00400004, key=<...>
[WDM] wwdm_join_event_handler: state=1, wifim_err=9, stopped=0
[WDM] wwdm_join_event_handler: state=2, wifim_err=9, stopped=0
[WDM] wwdm_join_event_handler: state=2, wifim_err=0, stopped=1
[WDM] wwdm_join_event_handler: stopped
[WWD] join: wiced_res=0, wifim_res=0
[info][WIFIM] join: attempt #0, rc=0
[info][WIFIM] join: SSID <...> join rc=0 after 1 attempts
[VAS] t:15
[VAS] t:15
[info][WIFIM] join: RSSI=-64
[VAS] t:15
[WIFIM] connect: use static ip
[WIFIM] Interface UP (Status : 0xf)
[WIFIM] netif_up: use DHCP
[WIFIM] Interface UP (Status : 0xf)
[WIFIM] netif_up:
[WIFIM] IP=192.168.0.9
[WIFIM] Mask=255.255.255.0
[WIFIM] Gw=192.168.0.1
[WIFIM] DNS[0]=192.168.0.1
[WIFIM] DNS[1]=0.0.0.0
[WIFIM] connect_cfg_ap: success
[info][CM] Joined configured AP successfully
[VAS] t:15
[info][CM] Store DbLib...
[VAS] t:15
[DBLIB][ERASEBANK] Bank 2
[info][CM] Store DbLib done
[HTT[VAS] t:15
S_CLIENT] Init
[HTTPS_CLIENT] Init
[info][CM] Wslib init successful, carry on
[VAS] t:15
[WS] WsLib_StartSession
[WS] __WsLib_Once
[WS] Https_client browsing <https://wbs06-ws.withings.net/once?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] New connection or Adress/Security Changed
[HTTPS_CLIENT] Close
[HTTPS_CLIENT] Init
[HTTPS_CLIENT] Handshake started
{"status":0,"body":{"user":[{"userid":...,"screens":[{"id":66,"deactivable_status":6,"src":1,"embid":11,"rk":1}]},...]}}
>
[DBLIB][ERASEBANK] Bank 1
[WS] WSLIB_OK
[WS] Https_client browsing <https://wbs06-ws.withings.net/v2/summary?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] Socket already opened
[WS] Params <action=getforscale&sessionid=...>
{"status":0,"body":[{...}]}
>
[WS] WSLIB_OK
[USLIB] FLUSH STORED MEASURE
[USLIB] 0 measure(s) flushed
[WS] Https_client browsing <https://wbs06-ws.withings.net/v2/weather?appliver=1181&appname=WBS06&apppfm=device>
[HTTPS_CLIENT] Socket already opened
[WS] Params <action=getforecast&sessionid=...short=1&enrich=t>
...
I also attempted to replace the mTLS certificates stored on the device to make WiFi interception easier, but it worked as intended as the server rejected my own self-signed certificates.
Nevertheless, thanks to the debugging logs and reading various state data from memory, I worked out most of the authentication flow:
- After receiving WiFi credentials over Bluetooth from mobile app, device can now independently connect to API server.
- Device presents its certificate and connects to the API server using mutual TLS (mTLS).
- API server returns a nonce.
- Device signs nonce with local private key and sends it to server.
- API server confirms signature is valid and returns a device session token.
- Device can now interact with API server using device session token as authentication.
Interestingly, the user-device association workflow could be done in two ways. The first way us initiated by the user’s mobile application:
- Mobile app already has user’s session token.
- App fetches device’s session token over Bluetooth.
- App authenticates to API server with
Session-Id: USER_SESSION_TOKEN
and sends the request payloaduserid=USER_ID& sessionidtoken=DEVICE_SESSION_TOKEN
.userid
is a simple incrementing number. - API server confirms
Session-Id
as well assessionidtoken
are valid, before associatinguserid
with the device ID thatDEVICE_SESSION_TOKEN
belongs to.
The second way is initiated by the device:
- Device already has device session token.
- Device fetches user’s session token over Bluetooth from the app.
- Device authenticates to API server with
Session-Id: DEVICE_SESSION_TOKEN
and sends the request payloaddeviceid=DEVICE_ID& sessionidtoken=USER_SESSION_TOKEN
.deviceid
is a simple incrementing number. - API server confirms
Session-Id
as well assessionidtoken
are valid, before associatingdeviceid
with the user ID thatUSER_SESSION_TOKEN
belongs to.
Both methods were properly hardened and validated; attempting to change userid
in the first flow or deviceid
in the second flow would fail because they did not match the Session-Id
session token.
However, there was one fatal flaw in the business logic. Perhaps I can illustrate this with an approximation of the server-side validation logic:
if (req.session.isValid) {
if (!validateSession(req.body.sessionidtoken)) {
return error
}
const targetSession = fetchSession(req.body.sessionidtoken)
// user app-initiated flow
if (targetSession.type === 'device') {
associate(req.body.userid, targetSession.id)
// device-initiated flow
} else if (targetSession.type === 'user') {
associate(req.body.deviceid, targetSession.id)
}
}
What’s the mistake here? Well, consider a request where both Session-Id
and sessionidtoken
are the attacker’s user session token, while deviceid
is set to a device that they don’t own. The logic will still think that this is a device-initiated flow and never require the attacker to provide a session token corresponding to the target deviceid
! Take a couple seconds to parse the code with this in mind.
Instead, the code should have done an additional validation:
if (req.session.isValid) {
if (!validateSession(req.body.sessionidtoken)) {
return error
}
const targetSession = fetchSession(req.body.sessionidtoken)
// user app-initiated flow that validates that user to be associated matches the session token header
if (req.body.userid === req.session.id && targetSession.type === 'device') {
associate(req.body.userid, targetSession.id)
// device-initiated flow that validates that devuce to be associated matches the session token header
} else if (req.body.deviceid === req.session.id && targetSession.type === 'user') {
associate(req.body.deviceid, targetSession.id)
}
}
With this mistake, based on the available device ids, I estimated more than 1 million potential devices could be re-associated to an attacker user account.
The responsible disclosure was fixed rapidly even over the holiday period:
- 29 December 2024: Reported to vendor
- 3 January 2025: Report confirmed and fixed
This demonstrates their seriousness about security - vulnerabilities affect every vendor, but I know which one I’d rather buy from.
When hacking hardware, it can be hard to scale (pun intended) beyond a single device to a fully remote exploit. User-device association is one of those critical flows that can bypass many standard hardware and network hardening controls, because the vulnerabilities lie on the API server rather than on the device. It’s worth taking a look especially with consumer-grade hardware that prioritizes usability and ease of setup.