16 minute read
Still I want to share with you my Reverse Engineering style. Although I originally planned to write some more “hardening macOS” stuff, lately I am quite tired and I want to do only what it feels good to. Really - need some time off. But that’s another thing.
So, still measuring how the gravity impacts on aesthetics - or in a more understandable fashion - the weight/scale problem.
If you are too lazy to read the previous post Reverse with me - Qardio necromancytoo bad! It’s all there! But let me summarize quickly what we understood:
| Property | Value |
|---|---|
| Device name | QardioBase |
| Device UUID | DDD101E2-5809-1322-6FE7-CAD62DC14121 |
| Bluetooth version | 4.1 |
| BDA/MAC Address | 5C:D6:1F:C4:1C:A3 |
| Proprietary Service UUID | C8219E89-93E0-4169-A3DC-EA7959E866AF |
| Communication | Bidirectional |
| Notify characteristics | 0x0019, 0x001c, 0x002d, 0x0030 |
| Read characteristics | 0x000e, 0x000c, 0x0028 |
| Write characteristics | 0x002e |
| Communication media | BlueTooth, IP Connection |
Now, where were we? Right. We had the UUID, the characteristics, the MAC. We knew the device talked. We just didn’t know what it was saying.
The Frida Wall and the Change of Ways
The obvious next step was the iOS binary. QardioBase is still installed on an old iPhone — the last survivor of the app’s existence on this planet. So we reached for Frida.
frida -U -n Qardio
Failed to attach: unable to attach to the specified process
FairPlay. The app is signed, the iPhone is not jailbroken, and that’s the end of that conversation. Frida can attach to processes signed with your own developer certificate — not to App Store binaries on a non-jailbroken device. I could have jailbroken the phone, but hey, that’s too easy - it’d be like DoSsing someone. If you DoS someone you’re no hacker - you’re a kiddie. It’s a matter of class, innit?
So I changed approach.
Sniffing the Air: The GATT Sequence
The binary was locked. The device wasn’t.
Two routes:
- what I discovered in iOS logs
- NRF52840-DONGLE + BlackArch + Wireshark
But I am the changer of ways, therefore here you go: another shiny route! Use the dongle, starting from the elements I’ve seen in the first probe (iOS logs). Capture as many scale states as possible, and document them.
So, since this is going to be a long and painful exercise, but the real value is only understanding ONE operation, let’s focus on getting my weight, solely. All the other aspects and functionalities will be obtained in exactly the same way, and they’re left as an exercise for the reader! (sorry - I couldn’t help but writing this horrible sentence. If you’ve had some math, chances are you’ve seen that sentence that the teachers use to torture the students. After years of reading it, now it was my time to write it. I was officially excited!)
Back to our business - Weight. One operation, one goal.
Here’s what we’re looking for: the moment the scale transmits a measurement. From the iOS logs we already knew the cast: a proprietary service (C8219E89-93E0-4169-A3DC-EA7959E866AF), a handful of characteristics, bidirectional communication. The dongle gives us the same picture, but from the air — no iPhone required, no app required, just radio.
The sequence, as captured, goes like this:
- The scale advertises. Continuously. It’s looking for someone to talk to.
- A client connects and discovers the GATT services.
- The client subscribes to notifications on the STATE characteristic (
A78AF805...). - The scale emits STATE updates. When STATE hits
6, a measurement is ready. - The client reads the MEASUREMENT characteristic (
B24F98BE...). - The scale responds with a JSON payload.
That’s it. That’s the entire protocol for reading weight. No authentication, no encryption, no handshake beyond standard GATT. Qardio built a scale that hands you your data if you simply ask politely.
Which I did.
Talk is cheap, show me the Swift code
I wrote a simple swift project - I am not sure I will transform this one into a fully fledged iOS app (that’s more a “No” than a “Yes”, but if you wanna do it, don’t hesitate to get in touch with me - I’ll gladly share this code and all the reversing artefacts with you).
Just the AppDelegate - then adding a few Entitlements, and here we go!
//
// AppDelegate.swift
// QardioBase
//
// Created by Gabriele Biondo on 28/03/2026.
//
import Cocoa
import CoreBluetooth
let QARDIO_SERVICE = CBUUID(string: "C8219E89-93E0-4169-A3DC-EA7959E866AF")
let CHAR_STATE = CBUUID(string: "A78AF805-8F3F-4E8F-A964-318B768BC38C")
let CHAR_ENG = CBUUID(string: "9F3F4E1B-37D7-4F95-B374-CF585D808BEB")
let CHAR_MEAS = CBUUID(string: "B24F98BE-9CD4-4F82-B935-01F18F104EDE")
@main
class AppDelegate: NSObject, NSApplicationDelegate, CBCentralManagerDelegate, CBPeripheralDelegate {
var central: CBCentralManager!
var peripheral: CBPeripheral?
var engChar: CBCharacteristic?
var measChar: CBCharacteristic?
var window: NSWindow?
func applicationDidFinishLaunching(_ notification: Notification) {
central = CBCentralManager(delegate: self, queue: nil)
}
// MARK: - Central
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn else {
print("BLE non disponibile: \(central.state.rawValue)")
return
}
print("BLE pronto, scansione in corso...")
central.scanForPeripherals(withServices: [QARDIO_SERVICE])
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
print("Trovato: \(peripheral.name ?? "unknown") \(peripheral.identifier)")
self.peripheral = peripheral
central.stopScan()
central.connect(peripheral)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
print("Connesso a \(peripheral.name ?? "device")")
peripheral.delegate = self
peripheral.discoverServices([QARDIO_SERVICE])
}
// MARK: - Peripheral
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let service = peripheral.services?.first else { return }
print("Servizio trovato: \(service.uuid)")
peripheral.discoverCharacteristics([CHAR_STATE, CHAR_ENG, CHAR_MEAS], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
for char in service.characteristics ?? [] {
print("Caratteristica: \(char.uuid)")
if char.uuid == CHAR_ENG { engChar = char; peripheral.setNotifyValue(true, for: char) }
if char.uuid == CHAR_STATE { peripheral.setNotifyValue(true, for: char) }
if char.uuid == CHAR_MEAS { measChar = char }
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let data = characteristic.value else { return }
if characteristic.uuid == CHAR_STATE {
let state = data[0]
print("STATE: \(state)")
if state == 1 {
print("Configuration mode — abilito engineering...")
enableConfig()
}
if state == 6 {
print("Misura pronta — leggo...")
readMeasurement()
}
}
if characteristic.uuid == CHAR_ENG {
print("ENG raw: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))")
if data.count >= 4 && data[2] == 9 {
print("Config mode confermata")
readMeasurement()
}
}
if characteristic.uuid == CHAR_MEAS {
if let json = String(data: data, encoding: .utf8) {
print("MEASUREMENT: \(json)")
}
}
}
// MARK: - Commands
func enableConfig() {
guard let char = engChar, let p = peripheral else { return }
let cmd: [UInt8] = [0x00, 0x00, 0x01, 0x01]
p.writeValue(Data(cmd), for: char, type: .withResponse)
}
func readMeasurement() {
guard let char = measChar, let p = peripheral else { return }
p.readValue(for: char)
}
}
Again: using a project is overkill - but it was kind of faster.
Finger crossed, I run this guy and I prepare to weight myself. I obtain this output:
BLE pronto, scansione in corso...
Trovato: QardioBase F9275DED-C0DC-56F0-D972-3D2FCB657C4B
Connesso a QardioBase
Servizio trovato: C8219E89-93E0-4169-A3DC-EA7959E866AF
Caratteristica: A78AF805-8F3F-4E8F-A964-318B768BC38C
Caratteristica: B24F98BE-9CD4-4F82-B935-01F18F104EDE
Caratteristica: 9F3F4E1B-37D7-4F95-B374-CF585D808BEB
ENG raw: 00 00 0e 10 08 00
ENG raw: 00 00
ENG raw: 00 00
ENG raw: 21 70 28 10
ENG raw: 1d 00 00 00
ENG raw: c9 71 38 44 44 fc
ENG raw: c5 01 00 01 01 00
ENG raw: 49 74 38 44 44 7c
ENG raw: 45 04 00 00 00 00
ENG raw: c9 76 7f 44 44 38
ENG raw: c5 06 00 00 00 00
ENG raw: 49 79 7c 08 04
ENG raw: 45 09 00 00 00
ENG raw: 49 7b 7d
ENG raw: 45 0b 00
ENG raw: 49 7c 38 54 54 18
ENG raw: 45 0c 00 00 00 00
ENG raw: c9 7e 7f
ENG raw: c5 0e 00
ENG raw: a1 71 38 44 44 fc
ENG raw: 9d 01 00 01 01 00
ENG raw: 21 74 3c 40 40 3c
ENG raw: 1d 04 00 00 00 00
ENG raw: a1 76 38 54 54 18
ENG raw: 9d 06 00 00 00 00
ENG raw: 21 79 48 54 24
ENG raw: 00 00
ENG raw: 00 00
ENG raw: 49 70 28 10
ENG raw: 45 00 00 00
ENG raw: c9 71 38 44 44 fc
ENG raw: c5 01 00 01 01 00
ENG raw: 49 74 38 44 44 7c
ENG raw: 45 04 00 00 00 00
ENG raw: c9 76 7f 44 44 38
ENG raw: c5 06 00 00 00 00
ENG raw: 49 79 7c 08 04
ENG raw: 45 09 00 00 00
ENG raw: 49 7b 7d
ENG raw: 45 0b 00
ENG raw: 49 7c 38 54 54 18
ENG raw: 45 0c 00 00 00 00
ENG raw: c9 7e 7f
ENG raw: c5 0e 00
ENG raw: a1 71 38 44 44 fc
ENG raw: 9d 01 00 01 01 00
ENG raw: 21 74 3c 40 40 3c
ENG raw: 1d 04 00 00 00 00
ENG raw: a1 76 38 54 54 18
ENG raw: 9d 06 00 00 00 00
ENG raw: 21 79 48 54 24
ENG raw: 1d 09 00 00 00
ENG raw: 21 7b 3e 44
ENG raw: 1d 0b 00 00
ENG raw: 00 80
ENG raw: 00 00 05 06
STATE: 6
Misura pronta — leggo...
MEASUREMENT: {"id":"114537690006011751348217","weight":"76.0","bmi":"19.3","z":"2031","fat":"57","tbw":"31","bmc":"3","mt":"9","sm":"17","algorithm":"0","user":"gabriel","userid":"blah"}
ENG raw: 00 00
ENG raw: 00 00
ENG raw: c9 72 01 81 61 19 07 01
ENG raw: c5 02 00 01 00 00 00 00
ENG raw: 49 76 70 8c 0a 09 90 60
ENG raw: 45 06 00 00 01 01 00 00
ENG raw: c9 79 00
ENG raw: c5 09 01
ENG raw: c9 7a 7c 82 01 01 82 7c
ENG raw: c5 0a 00 00 01 01 00 00
ENG raw: 15 76 7f 10 28 44
ENG raw: 11 06 00 00 00 00
ENG raw: 95 78 38 44 44 fc
ENG raw: 91 08 00 01 01 00
ENG raw: 00 80
ENG raw: 00 00
ENG raw: 00 00
ENG raw: 49 73 8f 09 09 09 91 60
ENG raw: 45 03 00 01 01 01 00 00
ENG raw: c9 76 01 81 61 19 07 01
ENG raw: c5 06 00 01 00 00 00 00
ENG raw: 49 7a 06 86 60 18 86 80
ENG raw: 45 0a 00 01 00 00 01 01
ENG raw: 95 75 7e 05
ENG raw: 91 05 00 00
ENG raw: 15 77 38 44 44 7c
ENG raw: 11 07 00 00 00 00
ENG raw: 95 79 3e 44
ENG raw: 91 09 00 00
ENG raw: 00 80
ENG raw: 00 00
ENG raw: 00 00
ENG raw: b5 71 38 44 44 fc
ENG raw: b1 01 00 01 01 00
ENG raw: 35 74 38 44 44 7c
ENG raw: 31 04 00 00 00 00
ENG raw: b5 76 7f 44 44 38
ENG raw: b1 06 00 00 00 00
ENG raw: 35 79 7c 08 04
ENG raw: 31 09 00 00 00
ENG raw: 35 7b 7d
ENG raw: 31 0b 00
ENG raw: 35 7c 38 54 54 18
ENG raw: 31 0c 00 00 00 00
ENG raw: b5 7e 7f
ENG raw: b1 0e 00
ENG raw: 00 80
ENG raw: 00 00
ENG raw: 00 00
ENG raw: c3 07 01
ENG raw: 43 08 01
ENG raw: bf 07 01
ENG raw: 3f 08 01
ENG raw: 00 80
ENG raw: 00 00 ec 00
ENG raw: 00 00
ENG raw: 00 00
ENG raw: c9 73 48 54 24
ENG raw: c5 03 00 00 00
ENG raw: c9 75 38 44 44 7c
ENG raw: c5 05 00 00 00 00
ENG raw: 49 78 1c 20 40 3c
ENG raw: 45 08 00 00 00 00
ENG raw: c9 7a 38 54 54 18
ENG raw: c5 0a 00 00 00 00
ENG raw: 21 73 38 54 54 18
ENG raw: 1d 03 00 00 00 00
ENG raw: a1 75 7c 08 04
ENG raw: 9d 05 00 00 00
ENG raw: a1 77 7c 08 04
ENG raw: 9d 07 00 00 00
ENG raw: a1 79 38 44 44 38
ENG raw: 9d 09 00 00 00 00
ENG raw: 21 7c 7c 08 04
ENG raw: 1d 0c 00 00 00
ENG raw: 00 80
ENG raw: 00 00 05 00
STATE: 0
ENG raw: 00 00 ec 00
ENG raw: 00 00
ENG raw: 00 00
ENG raw: 61 73 00 80 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 80
ENG raw: 41 73 00 01 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 01
ENG raw: 21 73 0e 30 40 80 00 00 00 00 00 00 00 00 00 00 00 00 80 40
ENG raw: 01 73 00 00 00 00 01 01 02 02 02 02 02 02 02 02 01 01 00 00
ENG raw: 00 80
ENG raw: 00 00 05 0c
STATE: 12
ENG raw: 00 00 eb 00
BLE non disponibile: 4
BLE pronto, scansione in corso...
BLE non disponibile: 4
BLE pronto, scansione in corso...
...
Now, about those ENG raw packets.
The ENGINEERING characteristic is chatty. Very chatty. While you’re standing on the scale, it streams a continuous flow of binary data — sensor readings, state transitions, internal counters. It’s the scale thinking out loud.
The packets come in pairs — a data packet followed by an acknowledgement:
c9 71 38 44 44 fc ← data
c5 01 00 01 01 00 ← ack
Byte 0 varies in a pattern that looks like a bitmask — 61, 41, 21, 01 in sequence, which in binary is:
01100001
01000001
00100001
00000001
A bit shifting down. Probably indicating which sensor segment is reporting. Byte 1 is consistently 73 (115 decimal) across an entire block — likely a session or frame identifier.
The state transitions are readable:
00 00 05 03 → STATE 3 — user stepping on
00 00 05 06 → STATE 6 — measurement ready
00 00 05 00 → STATE 0 — idle
00 00 05 0c → STATE 12 — post-measurement
There’s also this, appearing just before STATE 6:
00 00 0b 00 a1 df ff
Bytes a1 df ff — interpreted as a signed 24-bit little-endian integer — give -8287. Not a weight in kilograms. Almost certainly a raw ADC reading from the load cell, relative to tare. The conversion to kilograms requires a calibration factor and offset that live in the firmware. I don’t have those, and frankly I don’t need them.
Because here’s the interesting design choice Qardio made: the ENGINEERING channel is effectively obscured by complexity — raw sensor data, binary protocol, no documentation. The MEASUREMENT channel, on the other hand, is a JSON string in plain text.
Interpreting the JSON (or: Why you shouldn’t wear socks)
They made the internals hard to read and the output trivial to read. Which means that for our purposes — resurrecting the scale — I can ignore everything above and focus entirely on what arrives on B24F98BE:
{
"id": "114537690006011751348217",
"weight": "76.0",
"bmi": "19.3",
"z": "2031",
"fat": "57",
"tbw": "31",
"bmc": "3",
"mt": "9",
"sm": "17",
"algorithm": "0",
"user": "gabriel",
"userid": "blah"
}
Dafuq?
I am not 76 kg - far from that. But it has a meaning, I didn’t really weigh myself in the right manner (I had my center of gravity outside the scale, to use the keyboard)
I am definitely not Jabba the Hut, and the other values are not mine - actually any doctor would avoid a patient with those figures…
Now, BMI is the body mass index. It’s a derived metric. Since it’s based on the weight, which is rubbish, this is rubbish as well (but you could infer my height. I don’t care, please do. You would be fascinated.)
z is the bioelectrical impedance (in Ohm). Long story short - the scale sends an imperceptible electric signal through the feet, measuring the impedance of the organic tissues. Water conducts, fat does not. Simple physics. Now 2031 Ω is total rubbish. Normally, an adult in good health, without bionic transplants nor alien eggs inside him, has a body impedance ranging between 400 and 600 Ω, depending on side conditions such as height, muscles, hydration. 2031 Ohm is 3 to 4 times the expected value. A bit of logic tells us that this is the classic scenario in which there is no contact - or contact is hindered somehow and…
… oh, ffs! The socks. I weighted myself with socks on.
This reverberates on all values:
tbwtotal body water, 31% - this is probably the result of being in a desert for weeks! A healthy adult has values that are easily twice this one.bmcbody mineral contents. 3 kg - well that can be plausible. This metric is less impacted by impedance.mt- muscle tissue. 9 kg over 76 would mean that who jumped on the scale had been on a bed for months - it’d be a severe condition. Not plausible.sm- skeletal muscle - same as above.
What happened there: high impedance implies that the signal doesn’t flow through. In turn the software interprets the data as “no water” therefore compensating with “lots of fat”. The rest? Clusterfuck.
I wanted to retroengineer a scale, I ended up doing physics - which is not exactly my passion.
Now, I have enough material to do a few more tests and draw my conclusions. In principle:
- I need to restart this mac. PitA, but sometimes, on development phases, CoreBluetooth on Mac goes cuckoo.
- I want to take a clean measurement.
- If this last operation shall confirm my weight - which it should by the way, for the values I saw in the scale’s display are the same I reported in the JSON record - I would be ready to go to write the App. If I ever decide to do so
I am already very satisfied of how the analysis progressed.
Lesson learnt: The Engineering Labyrinth vs. The Open Backdoor
There is a profound, almost comical lesson buried in this exercise.
The ENGINEERING characteristic (9F3F4E1B...) is what I’d call the Debug Stream. Loud, chatty, binary pairs flying at you constantly. Byte masks shifting down — 61, 41, 21, 01. ACKs everywhere. It looks serious. It looks like someone knew what they were doing.
And inside that stream, you’re staring at raw physics — values like -8287, which are ADC integers relative to a tare offset. To make sense of them you’d need the calibration math from the firmware. Good luck with that.
So you’d be forgiven for thinking the system is impenetrable.
Then you look at the MEASUREMENT characteristic (B24F98BE...).
Plain UTF-8 JSON. {"weight": "76.0"}. No custom parser. No bit-banging. Just a string.
Qardio built a high-tech moat around the sensors and left the master key under the doormat — because it was easier for the app developers to consume a JSON object than to deal with raw hex blobs. The scale does all the heavy lifting at the edge, calculates everything, and then hands you the result on a silver platter. In plain text.
IoT security is rarely a solid wall. It’s usually a series of random obstacles. If you obsess over the engineering noise, you’ll think the system is impenetrable. Follow the data to where the app actually consumes it, and the emperor is naked.
The ENGINEERING channel is smoke and mirrors — intimidating, close to the silicon, deeply unreadable. A few handles away, the MEASUREMENT channel screams the truth in plain text.
Over-engineering on the inside. Poverty of design on the outside.
For a reverser, it’s the Promised Land.
Catcha next time - til then, stay paranoid. And fit, possibly…
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.