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

BM2 - Reversing the BLE protocol of the BM2 Battery Monitor
If you have not read it yet, start with part 1 for background into to the device and application we are exploring

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:


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]))
>>> print(''.join(['{:x}'.format(c & 255) for c in a]))

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:

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.MODE_CBC, 16 * b'\0')
dec = cipher.decrypt(plain)

Running the above python code:

$ ./

BLE Message types

The bm2 developers went to some effort to incorporate verbose logging, with log files located at


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:

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.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], 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.


On initialisation the application a HTTP GET request, sending the Bluetooth MAC address to 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:

HTTP POST to 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 -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
>>> 7936.0 * 16
$ wc -c bm2-firmware.bin
  126976 bm2-firmware.bin

In part 4 we will dump the firmware from the actual device and reprogram it