BM2 - Reversing the BLE protocol of the BM2 Battery Monitor
Part 3 of the battery monitor series -Analysing the BLE protocol in a car battery monitor to set the foundations to replace the application which tracks user’s location
In this post we will explore the Bluetooth Low Energy implementation of the BM2
application.
Jump to part 2 if you want to see how the AMap SDK is collecting GPS, Wifi and Cell data.
Looking into the BLE
Our first stop is to use Objection to hook into the method that triggers the HTTP request which includes the GPS co-ordinates to dump a backtrace:
android hooking watch class_method com.dc.battery.monitor2.ble.BleService.uploadMacInfo --dump-args --dump-return --dump-backtrace`
Working through each method, we observe
onCharacteristicChanged
overrides bluetooth.BluetoothGattCallback
. This callback is then invoked every time there is a Bluetooth remote characteristic notification from the battery device.
The application initiates BLE scanning to detect BLE devices within range by calling BuetoothAdapter.LeScanCallback The advertised device name is is checked to see if it matches to three different strings:
public static final String TARGET_DEVICE_NAME1 = "Battery Monitor";
public static final String TARGET_DEVICE_NAME2 = "ZX-1689";
public static final String TARGET_DEVICE_NAME3 = "Li Battery Monitor";
If any of these three strings match then a connection. OnConnectionStateChanged
callback stops the BLE scanning and an “anti-piracy” check is peformed to detect counterfit hardware. This sends the devices MAC address over a HTTP request to the Internet. Internet traffic is also generated on characteristic change events (onCharacteristicsChanged
callback).
In the following diagram, grey are classes, orange are mehtods and red marked methods are ones that invoke outbound Internet access:
Encryption
Each characteristics message that is received is first decrypted. The decryption key is hard-coded, represented as an array of signed integers. The initialisation vector is 16 bytes of zeros:
To represent the key as a string, each element needs to be converted to an unsigned integer first with a logical bitwise & 255
on each element. They key is leagendÿþ1882466
. I suspect the invalid unicode is an issue with the developer cutting and pasting the key into their text editor.
>>> a = [108,101,97,103,101,110,100,-1,-2,49,56,56,50,52,54,54]
>>> print(''.join([chr(c & 255) for c in a]))
leagendÿþ1882466
>>>
>>> print(''.join(['{:x}'.format(c & 255) for c in a]))
6c656167656e64fffe31383832343636
Let’s verify the decryption as this will be useful later when re-implementing our own interface to the physical device rather then using this invasive application.
The quickest way obtain the encrypted BLE messages and corresponding decrypted is to generate template hooking script for the decrypt()
method is to right mouse click on JADX and select copy as frida statement
The Frida javascript is generated which can either be pasted in realtime after invoking Frida, or wrapping it in Java.perform(function() { }
code block and invoking at the command line.
Java.perform(function() {
let BleService = Java.use("com.dc.battery.monitor2.ble.BleService");
BleService["decrypt"].implementation = function (bArr) {
console.log(`BleService.decrypt is called: bArr=${bArr}`);
let result = this["decrypt"](bArr);
console.log(`BleService.decrypt result=${result}`);
return result;
};
)};
Hooking the decrypt()
method while running the application yields the cyphertext and plain text for each BLE message:
Re implementing the decryption using the hardcoded key and first encrypted BLE message received, we can verify that our decrypted value is the same as what the hooked decrypt()
function in Frida returned:
#!/bin/python3
import binascii
from Crypto.Cipher import AES
plain = bytearray([(b&255) for b in [97,-71,48,-107,45,87,-59,111,-29,10,-35,76,106,-47,-27,-22]])
key = bytearray([(b&255) for b in [108,101,97,103,101,110,100,-1,-2,49,56,56,50,52,54,54]])
cipher = AES.new(key, AES.MODE_CBC, 16 * b'\0')
dec = cipher.decrypt(plain)
print(binascii.hexlify(dec).decode())
Running the above python code:
$ ./bledecrypt.py
f54f514c09cf00000000000000000000
BLE Message types
The bm2
developers went to some effort to incorporate verbose logging, with log files located at
/storage/emulated/0/Android/data/com.dc.battery.monitor2/files/Documents/Log
Following through the logs is quite helpful to understand the flow.
The BleService
class implements a basic state machine. In the default state (powered on) messages prefixed with 0xF5
call dealRealData
which extracts and calculates the voltage and charge:
Once again we can verify. Selecting an arbitrary BLE message emitted from the decrypt()
hook earlier and parsing the right bytes gives us the correct voltage and charge level:
>>> bleMessage = 'f54f414c0b0f00000000000000000000'
>>> voltage = int(bleMessage[2:5],16) / 100
>>> power = int(bleMessage[6:8],16)
>>> print(voltage,power)
12.68 76
That’s 12.68
voltes with 76%
charge.
Now we can decrypt the BLE messages from the BM2
device, it’s time to figure out how to interface directly over BLE without any phone application.
Bettercap’s BLE module offers a quick way to scan and enumerate BLE devices:
ble.enum <mac>
does some basic enumeration on the service characteristics, although this does not reveal anything too useful. All it really tells us is the BM2 device is sending generic responses for the standard characteristics. Rather then provide the model number it responds with the string Model Number
. One has to wonder if there was some cut-and-paste efforts here.
Next stop is gattool
.
$ sudo gattool -L
The handle 002e
which has the property NOTIFY
(reported by bettercap) is consistently sending what appears random bytes. This is indicative that it’s encrypted data - and we know the AES key. What we are missing now is the characteristics value for the handle with the NOTIFY
attributes.
Using --characteristics
switch will do discovery. Here we can see value handle 0x002E
with a uuid
value of 0000fff4-0000-1000-8000-00805f9b34fb
.
Let’s use the BLE GATT python library
Bleak to receive the voltage/charge data from the BM2 device and put together the previous findings to decrypt the characteristics values and decode the voltage and charge/power value:
#!/usr/bin/python3
import asyncio
import sys
from bleak import BleakClient
import binascii
from Crypto.Cipher import AES
from datetime import datetime
key = bytearray([(b&255) for b in [108,101,97,103,101,110,100,-1,-2,49,56,56,50,52,54,54]])
char = "0000fff4-0000-1000-8000-00805f9b34fb"
async def main(address, char):
async with BleakClient(address, char) as client:
print("[+] connected")
await client.start_notify(char, callback_handler)
await asyncio.sleep(100000.0)
await client.stop_notify(char)
async def callback_handler(_, data):
cipher = AES.new(key, AES.MODE_CBC, 16 * b'\0')
ble_msg = cipher.decrypt(data)
raw = binascii.hexlify(ble_msg).decode()
voltage = int(raw[2:5],16) / 100.0
power = int(raw[6:8],16)
now = datetime.utcnow().strftime('%F %T.%f')[:-3]
print("[%s] voltage: %.2f, power: %d" % (now, voltage, power))
if __name__ == "__main__":
if len(sys.argv) != 2 :
print("Usage: %s <mac address>\n" % sys.argv[0])
else :
address = sys.argv[1]
asyncio.run(main(address, char))
Running it with the address of the BM2 device:
Now we could dig further into how the over the air updates are done, as well as the other features but I feel that we have enough now if we wanted to implement our own full application to interface with the BM2 to avoid using the phone application that is the subject of the majority of this blog post.
One last thing on the BLE front - A closer look at the “anti-piracy” feature.
Anti-Piracy
On initialisation the application a HTTP GET
request, sending the Bluetooth MAC address to http://api.quicklynks.com:8080/v1/deviceInterface
with the method parameter value of checkMac
. It appears that the back-end database checks that this is a real provisioned device. Changing a single byte value in the MAC address from a valid one to something else returns a failure. It appears that MAC addresses are random (with the exception of the vendor ID)
This check is only done once; the MAC address of the bm2 device is stored in the shared preferences under the key name genuine_list
:
This check is done every time the application establishes a new connection to the bm2 device via a callback listener event onConnectionChange
. The HTTP request is only invoked one time as the genuine_list
shared preference value is referenced in subsequent checks.
The application will then send the bytes 0xE2 0x01
to the device over BLE if the MAC address is not genuine and 0xE2 0x02
if it is genuine.
Part 4 which will be released in the future will investigate what happens here on the device. At least on the application side, a piracy notice is displayed.
Grabbing the firmware
The bm2 cloud APIs will happily provide us latest firmware from their service if we provide an outdated firmware version. In the meantime I have ordered a CC Debugger to test this interface and see if we can pull the firmware off directly. For those intereted in obtaining the OTA firmware:
A HTTP POST
to api.quicklynks.com:8080/v1/versionInterface
with the parameter vm
with value of 7
or less will return the full endpoint to obtain the latest firmware in the url
field.
curl http://api.quicklynks.com:8080/v1/versionInterface? -d 'method=queryFirmUpgrade&vm=8&ptype=batterymonitor2'
A subsequent request to the value in url
:
FileParseUtil.parseHexFile
in the decompilation offers insights into the firmware format.
com.dc.battery.monitor2.ble.upgradehex
in the decompilation The 8th and 9th byte in the firmware image specifies the total size:
dd if=bm2-firmware.bin bs=1 count=8 2>/dev/null | xxd
00000000: 4e35 ffff 1000 007c N5.....|
>>> 0x7c00
31744
>>> 7936.0 * 16
126976.0
$ wc -c bm2-firmware.bin
126976 bm2-firmware.bin
In part 4 we will dump the firmware from the actual device and reprogram it