Hacking a cheap fitness tracker – Setting the time
The cast
The Mambo HR is a no-name $30 fitness tracker from Chinese manufacturer Lifesense, and I recently acquired one as a gift. Let's look this horse in the mouth, shall we?
Oof, it's not pretty. The Mambo HR has no buttons or touch functionality, and is controlled entirely through a proprietary Lifesense smartphone app. The device, however, is designed for the Chinese market, and is only supported by the Chinese version of the app.
The app is responsible for updating the device's clock, but for some reason, the time is half an hour off. (Possibly, the software does not support South Australia's unusual half-hour time zone.) There is no functionality to configure the time or time zone within the app, and indeed, there is very little to configure of any description.
It looks and works decently enough, though. Let's see what we can do with it.
Examining the Bluetooth traffic
The Lifesense app communicates with the Mambo HR over Bluetooth Low Energy (BLE). BLE devices utilise the Generic Attribute Profile (GATT) to expose its API, and using BLE Scanner, we can examine it. It's not pretty.
Aside from some generic device information strings, all services and characteristics exposed are proprietary and undocumented.
Thankfully, Android natively provides a mechanism to log the Bluetooth HCI traffic from the app and dump it to a file.
It's not pretty. Filtering by write requests, a pattern quickly emerges.
One GATT characteristic, 0xA502, is being used for all the write requests! I suppose it was too much to hope that there would be a nice API.
Digging deeper, we find a few write requests soon after connection that seem interesting:
0000 80 01 0b 01 01 5a 64 15 42 0a 02 67 e8 86 0d .....Zd.B..g...
0000 80 03 20 01 50 5a 64 15 42 ff 00 02 8a fe 00 00 .. .PZd.B.......
0010 af 01 00 00 ....
5a 64 15 42
looks like a Unix timestamp, and indeed, does correspond to the approximate time that the connection was made. There don't appear to be any other write requests with timestamps, so this looks promising.
With this, we return to BLE Scanner and replay the write requests to the device. The second write request seems to do nothing, but the first write request, lo and behold, resets the device clock to the earlier time! (having taken some time to reach this point)
So we should just be able to change the timestamp (forward half an hour, to fix the watch's time display), and resend the packet, right?
Unfortunately, this does not have the desired effect, and instead causes the Mambo HR to disconnect from the smartphone.
Indeed, any modification of the payload seems to either cause no effect, or cause a disconnection:
80 01 0b 01 01 5a 64 15 42 0a 02 67 e8 86 0d : Accepted
80 01 0b 01 01 5a 64 15 42 : No effect
80 01 0b 01 01 5a 64 15 42 0a : No effect
80 01 0b 01 01 5a 64 15 42 0a 02 67 e8 86 : No effect
80 01 0b 01 01 5a 64 15 ff 0a 02 67 e8 86 0d : Kicked
80 01 0b 01 01 5a 64 ff 42 0a 02 67 e8 86 0d : Kicked
80 01 0b 01 01 5a 64 15 42 0a 02 67 e8 86 0e : Kicked
80 01 0b 01 01 5a 64 15 42 00 00 00 00 00 00 : Kicked
80 01 0b 01 01 5a 64 15 42 0a 05 67 e8 86 0e : Kicked
80 01 0b 01 01 5a 64 42 15 0a 02 67 e8 86 0d : Kicked
Reverse engineering the checksum
This suggests that the timestamp is protected by a checksum. In order to work out what kind of checksum, we need more data. By repeatedly force-stopping and restarting the Lifesense app, we can intercept a large number of these requests:
0000 80 01 0b 01 01 5a 64 15 42 0a 02 67 e8 86 0d .....Zd.B..g...
0000 80 01 0b 01 01 5a 64 23 dc 0a 02 45 25 b4 8a .....Zd#...E%..
0000 80 01 0b 01 01 5a 64 30 fa 0a 02 3b 49 76 a9 .....Zd0...;Iv.
It looks like the four bytes at 0x05 are the timestamp, and the final four bytes are probably the checksum. A 4-byte checksum sounds suspiciously like CRC32, but what polynomial, and what is the payload for the checksum? CRC RevEng allows us to brute force the polynomial given payload–checksum pairs, but without knowing where the payload begins and ends, a few experimental trials yielded no results.
Is it even a CRC at all? It's difficult to tell, so time for more data gathering. By manipulating the system clock, and performing the same force-stop–restart trick, we can obtain many timestamps and checksums for a narrow 5-minute window:
0000 80 01 0b 01 01 5a 64 31 73 0a 02 6d 11 31 c3 .....Zd1s..m.1.
0000 80 01 0b 01 01 5a 64 31 e9 0a 02 9d 95 0c e5 .....Zd1.......
0000 80 01 0b 01 01 5a 64 32 14 0a 02 32 3c 43 88 .....Zd2...2<C.
0000 80 01 0b 01 01 5a 64 32 38 0a 02 03 6b fc 0c .....Zd28...k..
0000 80 01 0b 01 01 5a 64 32 56 0a 02 41 22 1a 26 .....Zd2V..A".&
0000 80 01 0b 01 01 5a 64 7b ab 0a 02 1a 22 ed 12 .....Zd{...."..
0000 80 01 0b 01 01 5a 64 31 40 0a 02 4b 3c 6a 0a .....Zd1@..K<j.
0000 80 01 0b 01 01 5a 64 31 6d 0a 02 7b a9 bf b9 .....Zd1m..{...
0000 80 01 0b 01 01 5a 64 31 95 0a 02 c0 7e 9d d1 .....Zd1....~..
0000 80 01 0b 01 01 5a 64 31 c2 0a 02 a9 8d a5 e4 .....Zd1.......
0000 80 01 0b 01 01 5a 64 31 f0 0a 02 8e 62 94 1a .....Zd1....b..
0000 80 01 0b 01 01 5a 64 32 12 0a 02 36 b1 3f 3a .....Zd2...6.?:
0000 80 01 0b 01 01 5a 64 32 3f 0a 02 06 24 ea 89 .....Zd2?...$..
0000 80 01 0b 01 01 5a 64 31 3a 0a 02 12 5a 87 8c .....Zd1:...Z..
0000 80 01 0b 01 01 5a 64 31 77 0a 02 6a 18 99 1f .....Zd1w..j...
0000 80 01 0b 01 01 5a 64 31 99 0a 02 c9 64 64 b5 .....Zd1....dd.
0000 80 01 0b 01 01 5a 64 31 d5 0a 02 b0 e4 10 11 .....Zd1.......
0000 80 01 0b 01 01 5a 64 31 f8 0a 02 80 71 c5 a2 .....Zd1....q..
0000 80 01 0b 01 01 5a 64 32 2a 0a 02 1c c9 8b 12 .....Zd2*......
0000 80 01 0b 01 01 5a 64 32 4d 0a 02 51 51 56 b7 .....Zd2M..QQV.
CRCs (and similar linear functions) have the property that given two pairs of messages (m1, m2)
and (m3, m4)
, if m1 XOR m2 == m3 XOR m4
, then CRC(m1) XOR CRC(m2) == CRC(m3) XOR CRC(m4)
. Note that, since the data surrounding our timestamp and checksum remains the same, this does not affect the property, and it does not matter that we do not know where the payload starts and ends.
We can write a simple script to test this property on our data:
data = []
with open('/path/to/file.txt', 'r') as f:
while True:
line = f.readline().strip()
if len(line) == 0:
break
bits = line.split()
time = int(''.join(bits[6:10]), 16)
code = int(''.join(bits[12:16]), 16)
data.append((time, code))
xors = {}
for i, item1 in enumerate(data):
for j, item2 in enumerate(data):
if i > j:
xor = item1[0] ^ item2[0]
if xor not in xors:
xors[xor] = []
xors[xor].append((i, j))
for xor, indexes in xors.items():
if len(indexes) > 1:
print(hex(xor))
for i, j in indexes:
print(hex(data[i][0]), hex(data[j][0]), hex(data[i][1]), hex(data[j][1]), hex(data[i][1] ^ data[j][1]))
print()
Running this confirms that there are many message pairs that satisfy this property, thus it appears that the checksum is indeed likely to be a CRC!
A detour through decompilation
Based on our previous difficulty with CRC RevEng, let's change tack and have a crack at decompiling the Lifesense app.
It's not pretty. By searching for some magic numbers, we can find some places where write requests like ours are generated, but the control flow is complex and obfuscated, and attempting to follow this forward to find the CRC code yields no fruit.
Thankfully, however, the code contains helpful debugging statements, and some objects even have overloaded toString
operators that provide the unobfuscated names of their variables! A simple grep
for ‘crc’ sets us on the right track, and in no time at all, we can identify the relevant code:
private static long m2709k(byte[] bArr) {
long j = 0;
long[] jArr = new long[256];
C0244c.m2679a(jArr);
for (byte b : bArr) {
j = (j >> 8) ^ jArr[(int) ((((long) b) ^ j) & 255)];
}
return j;
}
private static void m2679a(long[] jArr) {
for (int i = 0; i < 256; i++) {
long j = (long) i;
for (int i2 = 0; i2 < 8; i2++) {
j = (j & 1) == 1 ? (j >> 1) ^ 3988292384L : j >> 1;
}
jArr[i] = j;
}
}
This is a straightforward CRC32 implementation, with the standard CRC32 polynomial – although the state is initialised to zero, rather than 0xffffffff
, and there is no final XOR. Armed with this knowledge, we can easily brute-force our way through all the possibilities for the payload:
data = '80 01 0b 01 01 5a 64 15 42 0a 02'.split()
for start in range(0, 6):
for end in range(9, 12):
print('echo ' + ''.join(data[start:end]))
print('./reveng -P EDB88320 -l -c ' + ''.join(data[start:end]))
Examination of the results reveals that the payload is 01 5a 64 15 42 0a 02
, which dutifully resolves to the expected checksum.
Putting it all together
We now have all the information we need to produce a write request! (with timestamp offset by 30 minutes as required)
import binascii
import time
OFFSET = 30 * 60
tstamp = int(time.time()) # UTC time
tstamp += OFFSET # Fake the time zone
payload = bytes.fromhex('01' + '{:08x}'.format(tstamp) + '0a02')
crctable = []
for i in range(256):
k = i
for j in range(8):
if k & 1:
k >>= 1
k ^= 0xedb88320
else:
k >>= 1
crctable.append(k)
crc = 0
for k in payload:
crc = (crc >> 8) ^ crctable[(crc & 0xff) ^ k]
data = bytes.fromhex('80 01 0b 01') + payload + bytes.fromhex('{:08x}'.format(crc))
print(binascii.hexlify(data).decode('ascii'))
And voila! Using BLE Scanner to send a write request to the Mambo HR with the output of this script sets the clock to the correct time.