Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Intelligence View
⚡ tsecurity.de Intelligence

Reverse Engineering a BLE Body Scale on Android: GATT Queues, Handshakes, and BIA Packets

This article describes behavior observed on one real IF_B8B-based scale. Confirmed findings are separated from assumptions that may vary between firmware revisions. TL;DR I reverse engineered the proprietary IF_B8B BLE…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

This article describes behavior observed on one real IF_B8B-based scale. Confirmed findings are separated from assumptions that may vary between firmware revisions.







TL;DR



I reverse engineered the proprietary IF_B8B BLE protocol used by the GARLYN Bodyscan Master and built an Android application that can connect to the scale, perform its initialization handshake, receive live weight and reconstruct fragmented body-composition packets.



Source code: Pasynkov BodyScan






Contents




  • BLE topology

  • Serializing GATT operations

  • Notify vs Indicate

  • Initialization handshake

  • Weight packet parsing

  • BIA packet reassembly

  • Android architecture

  • Testing






Confidence summary




































Finding Status
Roles of fff1, fff2, and fff3
Confirmed on the tested IF_B8B
68-byte BIA packet layout Confirmed

byte[15] = 0x02 switches the scale display to pounds
Confirmed
72-byte BIA packet layout Hypothesis
Replay protection based on sequence numbers Hypothesis
Semantic meaning of every BIA field Partially inferred


Smart body scales look simple from the outside: connect over Bluetooth, step on the platform, and receive a weight measurement.



A multi-electrode body-composition scale is much harder to integrate.



While building Pasynkov BodyScan, an Android application for the GARLYN Bodyscan Master, I reverse engineered a proprietary BLE protocol used by an OEM module advertised as IF_B8B.



The hardest parts were not scanning or connecting. They were:




  • serializing Android GATT operations correctly;

  • reproducing the initialization handshake;

  • subscribing to both Notify and Indicate characteristics;

  • reconstructing fragmented body-composition packets;

  • distinguishing two possible packet layouts;

  • avoiding one profile byte that unexpectedly switches the scale from kilograms to pounds.



This article documents the process and the most useful engineering lessons.









Hardware and BLE topology



The tested scale has eight electrodes:




  • four on the platform for the feet;

  • four on a retractable handle for the hands.



The full body-composition measurement requires contact with both the platform and the handle.



The device advertises itself as:




IF_B8B






Some related OEM variants may use names matching IF_B*.



The main GATT service is:




0000fff0-0000-1000-8000-00805f9b34fb































Characteristic Direction Purpose
fff1 Scale → phone, Notify Live weight and BIA progress
fff2 Phone → scale, Write Initialization and user-profile commands
fff3 Scale → phone, Indicate Final body-composition packet


Full UUIDs:




0000fff1-0000-1000-8000-00805f9b34fb
0000fff2-0000-1000-8000-00805f9b34fb
0000fff3-0000-1000-8000-00805f9b34fb












Lesson 1: the scale goes to sleep quickly



The scale remains discoverable for only a short time after activation.



A manual flow such as opening the app, tapping Scan, waiting, and selecting the device can be too slow.



A more reliable state machine is:




scan
↓ device found
stop scan

connect immediately
↓ disconnected
restart scan






A simplified scanner:




fun startScaleScan() {
scanner.startScan(
listOf(
ScanFilter.Builder()
.setDeviceName("IF_B8B")
.build()
),
ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build(),
scanCallback
)
}






In production, do not rely only on the exact device name. Matching a known service UUID or a constrained name prefix may be safer across OEM variants.









Lesson 2: Android GATT operations must be serialized



This was the most important discovery.



On the tested configuration, issuing another GATT operation before the previous one completed caused the connection to freeze and eventually disconnect with errors such as:




GATT status 8
GATT status 133






A typical failed session looked like this:




Sync Date write succeeded
Function Set write started
onCharacteristicWrite callback never arrived
GATT status 8
Reconnect
GATT status 133






After all descriptor and characteristic operations were serialized through one queue, this failure pattern disappeared on the tested setup.



This applies to characteristic writes, descriptor writes, notification setup, MTU requests, and reads.



Calling writeCharacteristic() only starts an asynchronous operation. It is not complete until Android invokes:




onCharacteristicWrite(...)






The same rule applies to descriptors through onDescriptorWrite(...).






A coroutine-based GATT queue






private val gattMutex = Mutex()
private var activeWrite: CompletableDeferred<Int>? = null

suspend fun writeCharacteristic(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
payload: ByteArray
): Boolean = gattMutex.withLock {
check(activeWrite == null)

val deferred = CompletableDeferred<Int>()
activeWrite = deferred

try {
characteristic.writeType =
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = payload

if (!gatt.writeCharacteristic(characteristic)) {
return@withLock false
}

val status = withTimeoutOrNull(5_000L) {
deferred.await()
} ?: return@withLock false

status == BluetoothGatt.GATT_SUCCESS
} finally {
if (activeWrite === deferred) {
activeWrite = null
}
}
}







The snippet above uses the legacy characteristic value API for readability. On Android 13 and newer, production code should prefer writeCharacteristic(characteristic, value, writeType).




Callback:




override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
activeWrite?.complete(status)
}






A production implementation should also:




  • match callbacks by characteristic UUID;

  • reject late callbacks from old operations;

  • handle coroutine cancellation;

  • clean up deferred objects safely;

  • cancel the entire handshake after a timeout;

  • call disconnect() and close() before reconnecting.






Never continue after a timeout



If a write or descriptor operation times out, the GATT session is in an unknown state.



Use this recovery path:




operation timeout

stop handshake

disconnect

close

create a new GATT session












Lesson 3: do not request a larger MTU unless necessary



The outbound commands are no longer than 17 bytes. They fit inside the default BLE ATT payload:




MTU 23
20 bytes available for application data






The final BIA packet is larger, but the scale fragments it into 20-byte chunks.



On the tested setup, manually calling requestMtu(128) correlated with unstable connections. Removing the request simplified the protocol and removed an unnecessary variable.









Lesson 4: Notify and Indicate are not interchangeable



The scale uses two inbound channels.






fff1: Notify



Provides live weight, stable weight, and BIA progress.



Enable it with:




BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE









fff3: Indicate



Provides the final body-composition packet on the tested firmware.



Enable it with:




BluetoothGattDescriptor.ENABLE_INDICATION_VALUE






A safe subscription order:




enable fff1 Notify
↓ wait for onDescriptorWrite
delay 300 ms

enable fff3 Indicate
↓ wait for onDescriptorWrite
delay 300 ms

start handshake






On the tested IF_B8B firmware, subscribing only to fff1 was not enough to receive the final BIA result.









Packet framing and checksum



Most packets begin with:




33 CC






The basic checksum is:




(sum of every byte except the checksum byte + 1) mod 256









fun calculateChecksum(packet: ByteArray): Byte {
var sum = 0

for (index in 0 until packet.lastIndex) {
sum += packet[index].toInt() and 0xFF
}

return ((sum + 1) and 0xFF).toByte()
}












Reproducing the initialization handshake



The tested command sequence written to fff2 is:




Sync Date       sequence 2
Function Set sequence 4
Get Function sequence 6
Function State sequence 8
User Profile sequence 9






Each command waits for onCharacteristicWrite() before the next command begins.



The fixed sequence was copied from captured traffic of the original app and worked reliably. Strict replay protection is still a hypothesis rather than a fully isolated finding.









Command 1: Sync Date



Captured packet:




33 CC 0F 00 02 00 10 01 00 B4 6A 5A F7 CA 5B















































Bytes Meaning
0–1 Header
2 Packet length
3–4 Sequence, big-endian
5–6 Command 00 10
7 User flag
8–9 Time-zone offset in minutes, uint16 BE
10–13 Unix timestamp, uint32 BE
14 Checksum


For UTC+3:




180 minutes = 0x00B4









fun buildSyncDateCommand(
sequence: Int,
nowMillis: Long = System.currentTimeMillis(),
timeZone: TimeZone = TimeZone.getDefault()
): ByteArray {
val timestampSeconds = nowMillis / 1000L
val offsetMinutes = timeZone.getOffset(nowMillis) / 60_000

val packet = ByteArray(15)
packet[0] = 0x33
packet[1] = 0xCC.toByte()
packet[2] = 0x0F
packet[3] = ((sequence ushr 8) and 0xFF).toByte()
packet[4] = (sequence and 0xFF).toByte()
packet[5] = 0x00
packet[6] = 0x10
packet[7] = 0x01
packet[8] = ((offsetMinutes ushr 8) and 0xFF).toByte()
packet[9] = (offsetMinutes and 0xFF).toByte()
packet[10] = ((timestampSeconds ushr 24) and 0xFF).toByte()
packet[11] = ((timestampSeconds ushr 16) and 0xFF).toByte()
packet[12] = ((timestampSeconds ushr 8) and 0xFF).toByte()
packet[13] = (timestampSeconds and 0xFF).toByte()
packet[14] = calculateChecksum(packet)
return packet
}












Remaining handshake commands






Function Set






33 CC 0B 00 04 00 13 01 02 00 [checksum]









Get Function






33 CC 09 00 06 00 05 01 [checksum]









Function State






33 CC 09 00 08 00 12 01 [checksum]









User Profile



Captured example:




33 CC 11 00 09 00 01 01 00 40 24 01 B6 17 00 01 50



































































Byte Meaning
0–1 Header
2 Length
3–4 Sequence
5–6 User sync command
7 Gender
8 Profile slot
9–10 Scale PIN in BCD
11 Unit
12 Height in cm
13 Age
14 Reserved
15 Firmware-specific field
16 Checksum








The byte that switches kilograms to pounds



On the tested firmware, byte 15 of the User Profile command affects the physical display unit:




0x01 → kilograms
0x02 → pounds






The original semantic meaning of the field is unknown.



For this device, the safe value is:




packet[15] = 0x01






The lesson is simple: never assign semantic meaning to an unknown byte only because the values look plausible.









Parsing live-weight packets



A live-weight packet is 17 bytes:




33 CC 11
[uid_hi] [uid_lo]
03 80 00
[weight_hi] [weight_lo]
[lbs_hi] [lbs_lo]
02 01
[status]
00
[checksum]






Important fields:




























Bytes Meaning
8–9 Kilograms, uint16 BE / 10
10–11 Pounds, uint16 BE / 10
14 Stability status
16 Checksum


Observed statuses:




0x02 → unstable
0x03 → stable









data class WeightReading(
val kilograms: Double,
val stable: Boolean
)

fun parseWeightPacket(packet: ByteArray): WeightReading? {
if (packet.size != 17) return null
if (packet[0] != 0x33.toByte()) return null
if (packet[1] != 0xCC.toByte()) return null
if (packet[2] != 0x11.toByte()) return null
if (calculateChecksum(packet) != packet[16]) return null

val rawWeight =
((packet[8].toInt() and 0xFF) shl 8) or
(packet[9].toInt() and 0xFF)

return WeightReading(
kilograms = rawWeight / 10.0,
stable = (packet[14].toInt() and 0xFF) == 0x03
)
}






Persist a measurement only after receiving the stable status.









Parsing BIA progress



Progress packets are 10 bytes:




33 CC 0A
[seq_hi] [seq_lo]
03 82 00
[progress]
[checksum]






The progress byte is at index 8.



A value of 0x40, or 64, was observed when foot measurement had completed and the scale was waiting for hand contact.









Reassembling fragmented BIA packets



The final packet arrives through fff3 in multiple BLE fragments.



The confirmed packet is 68 bytes, while another possible firmware layout is 72 bytes. The third byte may still report 0x44, or 68, in both cases.



Robust strategy:




  1. append fragments to a buffer;

  2. search for the 33 CC header;

  3. discard bytes before the header;

  4. wait for at least 68 bytes;

  5. validate the 68-byte checksum;

  6. if valid, consume exactly 68 bytes;

  7. otherwise wait for 72 bytes;

  8. validate the 72-byte variant;

  9. if valid, consume exactly 72 bytes;

  10. if both fail, find the next header and resynchronize.



Do not clear the entire buffer after a valid frame. Remaining bytes may already belong to the next packet.









Confirmed 68-byte BIA layout



Real captured frame:




33CC4400060381014024217049B602010200000000002A04A00045488000454A00004379D0004523B000452270004531400045330000433ED000450E6000450D03030037












































































Bytes Format Meaning
10–11 uint16 BE / 100 Weight
26–29 Float32 BE / 100 Skeletal muscle percentage
30–33 Float32 BE / 10 Left-arm impedance
34–37 Float32 BE / 10 Body-fat percentage
38–41 Float32 BE / 10 Right-arm impedance
42–45 Float32 BE / 10 Left-leg impedance
46–49 Float32 BE / 10 Right-leg impedance
50–53 Float32 BE / 10 Trunk impedance
54–57 Float32 BE × 10 BMR
58–61 Float32 BE / 100 BMI
64 byte Stability
67 byte Checksum


The field meanings were identified by comparing raw values with the physical display and the original application.




fun readFloatBigEndian(
bytes: ByteArray,
offset: Int
): Float = ByteBuffer
.wrap(bytes, offset, 4)
.order(ByteOrder.BIG_ENDIAN)
.float












Possible 72-byte firmware layout



A possible second layout contains four duplicated bytes:




bytes 30–33 duplicate bytes 26–29






Fields after the duplicate are shifted by four bytes.



The special checksum skips the duplicated range:




fun isValid72(packet: ByteArray): Boolean {
if (packet.size != 72) return false

var sum = 0

for (index in 0..70) {
if (index in 30..33) continue
sum += packet[index].toInt() and 0xFF
}

val expected = ((sum + 1) and 0xFF).toByte()
return expected == packet[71]
}






Treat this layout as firmware-specific until verified on an actual 72-byte device.









Why BLE values may differ from the scale display



In one real measurement, the BLE packet and display showed different values.




























Metric BLE packet Display
Body fat 24.98% 20.3%
Skeletal muscle 32.08% Not displayed
“Total muscle” Not directly present 74.2%


Possible reasons:




  • the display applies an additional correction algorithm;

  • the BLE field represents skeletal rather than total muscle;

  • different profile parameters are used internally;

  • contact quality affects impedance;

  • socks add resistance.



A decoded number is not automatically a clinically meaningful or correctly labeled metric.



Distinguish between:




  • values received directly from the scale;

  • values calculated locally;

  • values inferred through comparison;

  • unknown fields.









Suggested Android architecture



Keep transport and protocol logic separate:




BLE scanner

GATT connection and operation queue

raw characteristic events

IF_B8B frame accumulator

protocol parser

domain measurements

repository and database

UI









interface ScaleTransport {
val incomingPackets: Flow<RawBlePacket>
val state: StateFlow<ConnectionState>

suspend fun connect(device: BluetoothDevice)
suspend fun disconnect()
suspend fun subscribe()
suspend fun write(payload: ByteArray): Boolean
}

interface ScaleProtocol {
fun buildHandshake(profile: UserProfile): List<ByteArray>

fun consume(
characteristicUuid: UUID,
payload: ByteArray
): List<ScaleEvent>
}






This makes it possible to replay captures, unit-test parsing without hardware, support other models, and isolate Android BLE bugs from protocol bugs.









Testing strategy



Every captured packet should become a fixture.



Test:




  • checksum validation;

  • big-endian weight parsing;

  • stable and unstable states;

  • incomplete frames;

  • unknown packet types;

  • BIA fragmentation using different chunk sizes;

  • garbage before a valid frame;

  • damaged frame followed by a valid frame;

  • descriptor timeout;

  • characteristic-write timeout;

  • disconnect during handshake;

  • duplicate or late callbacks;

  • reconnect after failure.



A captured packet can also be used as a complete regression test:




@Test
fun `parses captured 68 byte BIA packet`() {
val packet = hexToBytes(
"33CC4400060381014024217049B602010200000000002A04A000" +
"45488000454A00004379D0004523B0004522700045314000" +
"45330000433ED000450E6000450D03030037"
)

val result = parser.parseBodyCompositionPacket(packet)

assertEquals(85.60, result.weightKg, 0.01)
assertEquals(24.98, result.bodyFatPercent, 0.01)
assertEquals(22.78, result.bmi, 0.01)
}






The exact test API will depend on your parser model, but the captured frame should remain unchanged as a protocol fixture.









Full protocol notes



The complete reverse-engineering notes, including raw packets and implementation references, are available in the repository:



IF_B8B reverse-engineering notes









Responsible reverse engineering




  • Analyze only hardware you own or are authorized to test.

  • Do not publish credentials or identifiable health data.

  • Do not claim medical accuracy for reverse-engineered measurements.

  • Keep confirmed findings clearly separated from hypotheses.









Final handshake summary






Scan for IF_B8B

Connect without requestMtu()

Discover services

Enable fff1 Notify

Enable fff3 Indicate

Sync Date, seq 2

Function Set, seq 4

Get Function, seq 6

Function State, seq 8

User Profile, seq 9

Receive live weight on fff1

Receive BIA progress on fff1

Receive fragmented BIA packet on fff3

Validate, parse, and store












Key takeaways




  1. Serialize every GATT operation.

  2. Notify and Indicate require different CCCD values.

  3. Do not request a larger MTU without a real need.

  4. Treat the handshake as a state machine.

  5. Build a resilient byte accumulator.

  6. Validate multiple possible packet layouts.

  7. Never guess the meaning of unknown fields.

  8. Separate raw, calculated, and inferred metrics.



Reverse engineering this scale was less about discovering a few UUIDs and more about learning how fragile BLE state, proprietary framing, and firmware quirks interact.



That is exactly what made it interesting.

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Reverse Engineering a BLE Body Scale on Android: GATT Queues, Handshakes, and BIA Packets

Thematisch verwandte Begriffe: Reverse, Engineering, Body, Scale · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-96258 | A vulnerability has been found in onSite internet GmbH Auktion NG Auktio…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick