BLE:Bit for Reverse Development – Case Study: Helium Hotspot


In this article, we will try to reverse engineer the application to acquire the required characteristics needed to set up a rogue BLE peripheral. The goal is to find how to simulate the real device without having the device itself, explore the communication protocol and the kind of exchanged data. Then, we will be able to find vulnerabilities and report them without having to buy a real device. The reason I have selected this device is that the Helium is using LoRa protocol to communicate with remote sensors (ie 50km far away) and to provide them with internet connection. The LoRa keys are transferred by either Bluetooth connection (indication from the documentation) or wifi connection – in either case, the connection password needs to be transferred somehow to the device. Since the device is being setup by using BLE, and since I have a strong experience in that field, I just wanted to give it a go (ps: Helium is registered in Hackerone).

This article discusses the whole procedure of the reverse engineering without skipping any steps.

Reverse engineering of Helium Hotspot Application

The goal is to trick the mobile application Helium to connect to a rogue Helium hotspot. In order for the application to connect, it must first scan for available devices and then based on the findings and information gathered, decide if any found device matches the Helium “signature”.

I have started the procedure by decompiling the APK by using the Jadx decompiler.

First, I have to find the kind of filtering that is applied by the application. The flitering can be applied on any kind of information transmitted on the air (services, device name, MACI OUI etc). I have noticed that this is implemented via the BLE library polidea sdk.

com.polidea.rxandroidble.scan.ScanFilter

The class ScanFilter is used for filtering. By issuing a scan by using the Helium Application, i am forcing the application to create an instance of the class in memory. Then, i find the instance in memroy and print it’s content by using dynamic instrumentation as shown below:

    Java.choose("com.polidea.rxandroidble.scan.ScanFilter" , {
    onMatch : function(instance){
      console.log(instance.toString());
    },
    onComplete:function(){console.log("finished");}
  });

Frida Output:

[Nexus 5X::com.helium.wallet]-> started
BluetoothLeScanFilter [mDeviceName=null, mDeviceAddress=null, mUuid=null, mUuidMask=null, mServiceDataUuid=null, mServiceData=null, mServiceDataMask=null, mManufacturerId=-1, mManufacturerData=null, mManufacturerDataMask=null]
BluetoothLeScanFilter [mDeviceName=null, mDeviceAddress=null, mUuid=0fda92b2-44a2-4af2-84f5-fa682baa2b8d, mUuidMask=null, mServiceDataUuid=null, mServiceData=null, mServiceDataMask=null, mManufacturerId=-1, mManufacturerData=null, mManufacturerDataMask=null]
finished

We now know, that in order for the appliction to connect to a device, only the service id is used for advertisement.

That is a 128-bit Service UUID. I searched for it in source code: 0fda92b2-44a2-4af2-84f5-fa682baa2b8d, but I couldn’t find it.

So let’s find where the filter is being set and traceback from there:

        var Log = Java.use("android.util.Log");
var Exception = Java.use("java.lang.Exception");


var scanfilter = Java.use("com.polidea.rxandroidble.scan.ScanFilter$Builder");
scanfilter.setServiceUuid.overload('android.os.ParcelUuid').implementation = function(uuid){
console.log("uuid: " + uuid.toString());
console.log(Log.getStackTraceString(Exception.$new()));
return scanfilter.setServiceUuid.overload('android.os.ParcelUuid').call(this, uuid);
}
uuid: 0fda92b2-44a2-4af2-84f5-fa682baa2b8d
java.lang.Exception
at com.polidea.rxandroidble.scan.ScanFilter$Builder.setServiceUuid(Native Method)
at com.polidea.multiplatformbleadapter.BleModule.safeStartDeviceScan(BleModule.java:1231)
at com.polidea.multiplatformbleadapter.BleModule.startDeviceScan(BleModule.java:192)
at com.polidea.reactnativeble.BleClientManager.startDeviceScan(BleClientManager.java:182)
at java.lang.reflect.Method.invoke(Native Method)
at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:151)
at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)
at android.os.Handler.handleCallback(Handler.java:790)
at android.os.Handler.dispatchMessage(Handler.java:99)
at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:27)
at android.os.Looper.loop(Looper.java:164)
at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:226)
at java.lang.Thread.run(Thread.java:764)

It seems that the UUID is set by several methods of BleModule class.

I have to better understand the core classes that exist in the polidea library.

The BleModule and BleClientManager consist of very good candidates as they support most of BLE functions that a reverse engineer is expected to see. Functions such as connect, filtering write or read.

The functions discoverAllServicesAndCharacteristicsForDevice and characteristicsForDevice as well some other functions were found to be a good place to start. This is because such functions are usually called by the code that processes the found services and characteristics and acts upon them.

Logs are also important because they contain unique strings which could be used later-on to indicate their location in decompiled code. Unfortunately, nothing exists in logs.

The outcome of the initial decompilation, was that it didn’t seem to be some sort of react app. But at this stage, i have figured out that it is. I just want to stop!

npx react-native-decompiler -i ./index.android.bundle -o ./output
thio@re:~/Downloads/Reversing/helium/helium/assets$ npx react-native-decompiler -i ./index.android.bundle -o ./output
npx: installed 178 in 7.646s
Unexpected token {

All i care about is to find where the BLE functions have been called from. But, I don’t have any more information. I have beautified the code and searched for the functions (ie discoverAllServicesAndCharacteristicsForDevice, characteristicsForDevice, …). The functions have been found to be in there.

discoverAllServicesAndCharacteristicsForDevice: EA:BC:CC:11:33:13,5
java.lang.Exception
at com.polidea.multiplatformbleadapter.BleModule.discoverAllServicesAndCharacteristicsForDevice(Native Method)
at com.polidea.reactnativeble.BleClientManager.discoverAllServicesAndCharacteristicsForDevice(BleClientManager.java:390)
at java.lang.reflect.Method.invoke(Native Method)
at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:151)
at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)
at android.os.Handler.handleCallback(Handler.java:790)
at android.os.Handler.dispatchMessage(Handler.java:99)
at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:27)
at android.os.Looper.loop(Looper.java:164)
at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:226)
at java.lang.Thread.run(Thread.java:764)

getCharacteristicsForDevice: EA:BC:CC:11:33:13,0fda92b2-44a2-4af2-84f5-fa682baa2b8d
java.lang.Exception
at com.polidea.multiplatformbleadapter.BleModule.getCharacteristicsForDevice(Native Method)
at com.polidea.reactnativeble.BleClientManager.characteristicsForDevice(BleClientManager.java:426)
at java.lang.reflect.Method.invoke(Native Method)
at com.facebook.react.bridge.JavaMethodWrapper.invoke(JavaMethodWrapper.java:372)
at com.facebook.react.bridge.JavaModuleWrapper.invoke(JavaModuleWrapper.java:151)
at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)
at android.os.Handler.handleCallback(Handler.java:790)
at android.os.Handler.dispatchMessage(Handler.java:99)
at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:27)
at android.os.Looper.loop(Looper.java:164)
at com.facebook.react.bridge.queue.MessageQueueThreadImpl$4.run(MessageQueueThreadImpl.java:226)
at java.lang.Thread.run(Thread.java:764)
                key: "discoverAllServicesAndCharacteristicsForDevice",
              value: function(t, n) {
                  var c;
                  return s.default.async(function(u) {
                      for (;;) switch (u.prev = u.next) {
                          case 0:
                              return n || (n = this._nextUniqueID()), u.next = 3, s.default.awrap(this._callPromise(p.BleModule.discoverAllServicesAndCharacteristicsForDevice(t, n)));
                          case 3:
                              return c = u.sent, u.abrupt("return", new o.Device(c, this));
                          case 5:
                          case "end":
                              return u.stop()
                      }
                  }, null, this, null, Promise)
              }

I use react as a blackbox only for the reason to find strings that are needed. So we have the Android API as the lower layer. The higher layer is the react javascript and we are in the middle of those two, trying to understand what the react is needed in order to help us continue to the next step (ie register the device or communicate to send encryption keys etc..).

Searching to find the UUID needed in react js:

The good news is that i have found the logic i have expected to find – the code is revealed:

                }, t.getDeviceInfo = function() {
                  var n, u, o, c, l, f, p, w, h, v, b;
                  return s.default.async(function(x) {
                      for (;;) switch (x.prev = x.next) {
                          case 0:
                              return n = t.state.connectedHotspot, x.next = 3, s.default.awrap(n.characteristicsForService(D));
                          case 3:
                              return u = x.sent, o = u.find(function(t) {
                                  return t.uuid === A
                              }), x.next = 7, s.default.awrap(o.read());
                          case 7:
                              return c = x.sent, x.next = 10, s.default.awrap(n.characteristicsForService(F));
                          case 10:
                              return l = x.sent, f = l.find(function(t) {
                                  return t.uuid === R
                              }), x.next = 14, s.default.awrap(f.read());
                          case 14:
                              return p = x.sent, k.default.logBreadcrumb("wifi read " + p), w = l.find(function(t) {
                                  return t.uuid === j
                              }), x.next = 19, s.default.awrap(w.read());
                          case 19:
                              return h = x.sent, v = l.find(function(t) {
                                  return t.uuid === V
                              }), k.default.logBreadcrumb("wifiConfiguredCharacteristic " + v), b = {}, x.prev = 23, x.next = 26, s.default.awrap(v.read());
                          case 26:
                              b = x.sent, x.next = 32;
                              break;
                          case 29:
                              x.prev = 29, x.t0 = x.catch(23), k.default.sendError(x.t0);
                          case 32:
                              return x.abrupt("return", {
                                  wifiSSID: (0, y.fromBs64)(p.value),
                                  wifiConfigured: b,
                                  ethernetOnline: (0, y.fromBs64)(h.value),
                                  firmwareVersion: (0, y.fromBs64)(c.value)
                              });
                          case 33:
                          case "end":
                              return x.stop()
                      }
                  }, null, null, [

The bad news is that the code is minified and stripped. No, that is still good news. The bad news is that javascript sucks (sorry guys its true).

In the react code, i have found a very interesting code:

               }, t.setWifiCredentials = function(n, u) {
                  var o, c, l, f;
                  return s.default.async(function(p) {
                      for (;;) switch (p.prev = p.next) {
                          case 0:
                              return k.default.logBreadcrumb('Bluetooth::setWifiCredentials'), p.next = 3, s.default.awrap((0, x.setWifiServices)(n, u));
                          case 3:
                              return o = p.sent, c = t.state.connectedHotspot, p.next = 7, s.default.awrap(c.characteristicsForService(F));
                          case 7:
                              return l = p.sent, p.next = 10, s.default.awrap(l.find(function(t) {
                                  return t.uuid === W
                              }));
                          case 10:
                              return f = p.sent, p.next = 13, s.default.awrap(f.writeWithResponse(o));
                          case 13:
                              return p.abrupt("return", f);
                          case 14:
                          case "end":
                              return p.stop()
                      }

Looks like the wifi connection is being setup via Bluetooth. So I wonder how strong the bluetooth security is.

The LoRa keys and wifi keys are transfered via bluetooth so if the bluetooth fails, everything else fails too.

I was scrolling all around the bluetooth code and i figured out some UUIDs:

    var F = '0fda92b2-44a2-4af2-84f5-fa682baa2b8d',
      D = '0000180a-0000-1000-8000-00805f9b34fb',
      A = '00002a26-0000-1000-8000-00805f9b34fb',
      B = 'd7515033-7e7b-45be-803f-c8737b171a29',
      W = '398168aa-0111-4ec0-b1fa-171671270608',
      R = '7731de63-bc6a-4100-8ab1-89b2356b038b',
      N = 'df3b16ca-c985-4da2-a6d2-9b9b9abdb858',
      G = 'd435f5de-01a4-4e7d-84ba-dfd347f60275',
      I = '0a852c59-50d3-4492-bfd3-22fe58a24f01',
      L = 'd083b2bd-be16-4600-b397-61512ca2f5ad',
      U = 'b833d34f-d871-422c-bf9e-8e6ec117d57e',
      j = 'e5866bd6-0288-4476-98ca-ef7da6b4d289',
      V = 'e125bda4-6fb8-11ea-bc55-0242ac130003',
      M = '8cc6e0b3-98c5-40cc-b1d8-692940e6994b',

Brilliant. But if those were used for bluetooth, were those UUID services or UUID characteristics?

Some of them like 0000180a-0000-1000-8000-00805f9b34fb and 00002a26-0000-1000-8000-00805f9b34fb were recognized right away. It’s a standard service for Bluetooth. So are all of those characteristics though? the other UUIDs are 128-bit UUID which means custom UUIDs (generated by the vendor).

  • 00002a26-0000-1000-8000-00805f9b34fb = Firmware Revision String
  • 0000180a-0000-1000-8000-00805f9b34fb = Device Information

So by searching the UUID 398168aa-0111-4ec0-b1fa-171671270608 on the internet i was able to find a git repository for the helium miner!

https://github.com/NebraLtd/helium-miner-config/issues/16
Characteristic WiFiConnect 398168aa-0111-4ec0-b1fa-171671270608

After some investigation i figured out that this was a deprecated implementation of the miner in python. And it includes references to bluez (the linux bluetooth stack) which means it communicates via bluetooth. The Bluetooth presence can be two things, 1) BLE is needed to control helium, or 2) BLE is used by the python let helium mobile app to connect and control the Helium python program. It seems the 2nd assumption is true. You may run a miner in python and have it controlled by an app via BLE.

So all UUIDs for all services and characteristics along with their descriptions should be there. Awesome. bye bye javascript!

The following py file contains all the UUIDs along with their descriptions!:

https://github.com/NebraLtd/helium-miner-config/blob/559d73c3bcd1f4bbbfaf52b3f5570374fd9a22d1/uuids.py

# Firmware UUID
FIRMWARE_VERSION_CHARACTERISTIC_UUID = "00002a26-0000-1000-8000-00805f9b34fb"

# Onboarding Key
ONBOARDING_KEY_CHARACTERISTIC_UUID = "d083b2bd-be16-4600-b397-61512ca2f5ad"
ONBOARDING_KEY_VALUE = "Onboarding Key"

# Public Key
PUBLIC_KEY_CHARACTERISTIC_UUID = "0a852c59-50d3-4492-bfd3-22fe58a24f01"
PUBLIC_KEY_VALUE = "Public Key"

# WiFiServices
WIFI_SERVICES_CHARACTERISTIC_UUID = "d7515033-7e7b-45be-803f-c8737b171a29"
WIFI_SERVICES_VALUE = "WiFi Services"

# WiFiConfiguredServices
WIFI_CONFIGURED_SERVICES_CHARACTERISTIC_UUID = "e125bda4-6fb8-11ea-bc55-0242ac130003"
WIFI_CONFIGURED_SERVICES_VALUE = "WiFi Configured Services"

# WiFiRemove
WIFI_REMOVE_CHARACTERISTIC_UUID = "8cc6e0b3-98c5-40cc-b1d8-692940e6994b"
WIFI_REMOVE_VALUE = "WiFi Remove"

# Diagnostics
DIAGNOSTICS_CHARACTERISTIC_UUID = "b833d34f-d871-422c-bf9e-8e6ec117d57e"
DIAGNOSTICS_VALUE = "Diagnostics"

# Mac address
MAC_ADDRESS_CHARACTERISTIC_UUID = "9c4314f2-8a0c-45fd-a58d-d4a7e64c3a57"
MAC_ADDRESS_VALUE = "Mac Address"

# Lights
LIGHTS_CHARACTERISTIC_UUID = "180efdef-7579-4b4a-b2df-72733b7fa2fe"
LIGHTS_VALUE = "Lights"


# WiFiSSID
WIFI_SSID_CHARACTERISTIC_UUID = "7731de63-bc6a-4100-8ab1-89b2356b038b"
WIFI_SSID_VALUE = "WiFi SSID"


# AssertLocation
ASSERT_LOCATION_CHARACTERISTIC_UUID = "d435f5de-01a4-4e7d-84ba-dfd347f60275"
ASSERT_LOCATION_VALUE = "Assert Location"

# Add Gateway
ADD_GATEWAY_CHARACTERISTIC_UUID = "df3b16ca-c985-4da2-a6d2-9b9b9abdb858"
ADD_GATEWAY_KEY_VALUE = "Add Gateway"

# WiFiConnect
WIFI_CONNECT_CHARACTERISTIC_UUID = "398168aa-0111-4ec0-b1fa-171671270608"
WIFI_CONNECT_KEY_VALUE = "WiFi Connect"


# Ethernet Online
ETHERNET_ONLINE_CHARACTERISTIC_UUID = "e5866bd6-0288-4476-98ca-ef7da6b4d289"
ETHERNET_ONLINE_VALUE = "Ethernet Online"

This also contains very important strings that we must impersonate:

FIRMWARE_VERSION = "2021.01.31.1"
DEVINFO_SVC_UUID = "180A"
FIRMWARE_SVC_UUID = "0000180a-0000-1000-8000-00805f9b34fb"
MANUFACTURE_NAME_CHARACTERISTIC_UUID = "2A29"
FIRMWARE_REVISION_CHARACTERISTIC_UUID = "2A26"
SERIAL_NUMBER_CHARACTERISTIC_UUID = "2A25"
USER_DESC_DESCRIPTOR_UUID = "2901"
PRESENTATION_FORMAT_DESCRIPTOR_UUID = "2904"

In their repository i have found the gateway-config repo

https://github.com/helium/gateway-config
"The Helium configuration application. Manages the configuration of the Helium Hotspot over Bluetooth, and ties together various services over dbus."
"Exposes a GATT BLE tree for configuring the hotspot over Bluetooth.
Signals gateway configuration (public key and Wi-Fi credentials) over dbus.
Listens for GPS location on SPI and signals the current Position of the hotspot over dbus."

So everything is open and no further reversing is needed. 🙁

The new goal

Let’s setup a python miner and connect to it via mobile app

Then, act as a MiTM in BLE Connection and analyze the communication and then setup our rogue Helium Hotspot.

The Flip

So helium allowed to create a DIY Hotspot to my helium tokens but that is discontinued.

So the android application exists in their repo and it seems to be open source.

https://github.com/helium/hotspot-app/tree/main/android

Yeah, now i may start having some feelings of javascript.

https://github.com/helium/hotspot-app/blob/main/src/utils/bluetooth/bluetoothDataParser.ts
https://github.com/helium/hotspot-app/blob/main/src/utils/bluetooth/bluetoothTypes.ts
https://github.com/helium/hotspot-app/blob/main/src/utils/bluetooth/useBluetooth.tsx
https://github.com/helium/hotspot-app/blob/b6b95d8eb0e7b431949980db999ed2c46ab005b2/src/providers/BluetoothProvider.tsx
export enum Service {
FIRMWARESERVICE_UUID = '0000180a-0000-1000-8000-00805f9b34fb',
MAIN_UUID = '0fda92b2-44a2-4af2-84f5-fa682baa2b8d',
}

export enum FirmwareCharacteristic {
FIRMWAREVERSION_UUID = '00002a26-0000-1000-8000-00805f9b34fb',
}
export enum HotspotCharacteristic {
WIFI_SSID_UUID = '7731de63-bc6a-4100-8ab1-89b2356b038b',
PUBKEY_UUID = '0a852c59-50d3-4492-bfd3-22fe58a24f01',
ONBOARDING_KEY_UUID = 'd083b2bd-be16-4600-b397-61512ca2f5ad',
AVAILABLE_SSIDS_UUID = 'd7515033-7e7b-45be-803f-c8737b171a29',
WIFI_CONFIGURED_SERVICES = 'e125bda4-6fb8-11ea-bc55-0242ac130003',
WIFI_REMOVE = '8cc6e0b3-98c5-40cc-b1d8-692940e6994b',
WIFI_CONNECT_UUID = '398168aa-0111-4ec0-b1fa-171671270608',
ADD_GATEWAY_UUID = 'df3b16ca-c985-4da2-a6d2-9b9b9abdb858',
ASSERT_LOC_UUID = 'd435f5de-01a4-4e7d-84ba-dfd347f60275',
DIAGNOSTIC_UUID = 'b833d34f-d871-422c-bf9e-8e6ec117d57e',
ETHERNET_ONLINE_UUID = 'e5866bd6-0288-4476-98ca-ef7da6b4d289',
}

By inspecting the source code of the app, I have concluded that there are two services, the one is obviously the Device Information service which includes the Firmware Revision String. The other service is the custom service which includes all the custom characteristics.

The following code shows the initialization procedure:

const initialState = {
getState: async () => State.Unknown,
enable: async () => {},
scan: async () => {},
connect: async () => undefined,
discoverAllServicesAndCharacteristics: async () => undefined,
findAndReadCharacteristic: () =>
  new Promise<undefined>((resolve) => resolve()),
findAndWriteCharacteristic: () =>
  new Promise<undefined>((resolve) => resolve()),
readCharacteristic: () => new Promise<Characteristic>((resolve) => resolve()),
writeCharacteristic: () =>
  new Promise<Characteristic>((resolve) => resolve()),
findCharacteristic: async () => undefined,
}

The bluetooth function is used by:

https://github.com/helium/hotspot-app/blob/b6b95d8eb0e7b431949980db999ed2c46ab005b2/src/utils/useHotspot.ts

So first i configured all the characteristics and services and then i connected to BLE:Bit:

Read[WiFi_SSID]: 00000000000000000000
Read[PUBKey]: 00000000000000000000
Read[OnBoarding_Key]: 00000000000000000000

I haven’t placed any value to the characteristics as i have not idea about the value they should have.

This is how the hotspot is being configured:

const connectAndConfigHotspot on useHotspot.ts.

const connectAndConfigHotspot = async (hotspotDevice: Device) => {
    let connectedDevice = hotspotDevice
    const connected = await hotspotDevice.isConnected()
    if (!connected) {
      const device = await connect(hotspotDevice)
      if (!device) return
      connectedDevice = device
    }
​
    const deviceWithServices = await discoverAllServicesAndCharacteristics(
      connectedDevice,
    )
    if (!deviceWithServices) return
​
    connectedHotspot.current = deviceWithServices
​
    const wifi = await getDecodedStringVal(HotspotCharacteristic.WIFI_SSID_UUID)
    const ethernetOnline = await getDecodedBoolVal(
      HotspotCharacteristic.ETHERNET_ONLINE_UUID,
    )
    const address = await getDecodedStringVal(HotspotCharacteristic.PUBKEY_UUID)
    const onboardingAddress = await getDecodedStringVal(
      HotspotCharacteristic.ONBOARDING_KEY_UUID,
    )
​
    const type = getHotspotType(onboardingAddress || '')
    const name = getHotspotName(type)
    const mac = hotspotDevice.localName?.slice(15)
​
    if (!onboardingAddress || onboardingAddress.length < 20) {
      Logger.error(
        new Error(`Invalid onboarding address: ${onboardingAddress}`),
      )
    }
​
    const details = {
      address,
      mac,
      type,
      name,
      wifi,
      ethernetOnline,
      onboardingAddress,
    }
​
    const response = await dispatch(fetchHotspotDetails(details))
    return !!response.payload
  }

In the following referenced code, i have found the needed hotspot name for the device, in order to be selected by the application and initiate a connection:

https://github.com/helium/hotspot-app/blob/61e4f0af20c1f63a1e4188e782b8487b2aaca8db/src/store/connectedHotspot/connectedHotspotSlice.ts
export type HotspotName = 'RAK Hotspot Miner' | 'Helium Hotspot'

I have discovered that the application treats some characteristics in a different way than the others. In the initialization stage, i haven’t found any code that considers the cantained values of the characteristics, as long as those values are decoded from base64:

import { decode } from 'base-64'
...
const parseWifi = (value: string): string[] => {
const buffer = util.newBuffer(util.base64.length(value))
util.base64.decode(value, buffer, 0)
return WifiServicesV1.decode(buffer).services
}

const parseDiagnostics = (value: string) => {
const buffer = util.newBuffer(util.base64.length(value))
util.base64.decode(value, buffer, 0)
return DiagnosticsV1.decode(buffer).diagnostics
}
...
export function parseChar(
characteristicValue: string,
uuid:
  | typeof HotspotCharacteristic.WIFI_SSID_UUID
  | typeof HotspotCharacteristic.PUBKEY_UUID
  | typeof HotspotCharacteristic.ONBOARDING_KEY_UUID
  | typeof HotspotCharacteristic.WIFI_REMOVE
  | typeof HotspotCharacteristic.WIFI_CONNECT_UUID
  | typeof HotspotCharacteristic.ADD_GATEWAY_UUID
  | typeof FirmwareCharacteristic.FIRMWAREVERSION_UUID,
): string
...
export function parseChar(
characteristicValue: string,
uuid: typeof HotspotCharacteristic.DIAGNOSTIC_UUID,
): DiagnosticInfo
...
export function parseChar(characteristicValue: string, uuid: string) {
switch (uuid) {
  case HotspotCharacteristic.DIAGNOSTIC_UUID:
    return parseDiagnostics(characteristicValue) as DiagnosticInfo
  case HotspotCharacteristic.WIFI_CONFIGURED_SERVICES:
  case HotspotCharacteristic.AVAILABLE_SSIDS_UUID:
    return parseWifi(characteristicValue)
  case HotspotCharacteristic.ETHERNET_ONLINE_UUID: {
    const decodedValue = decode(characteristicValue)
    return decodedValue === 'true'
  }
}

From the code, we can see that the diagnostics, assert_loc, add_gateway, wifi_connect, wifi_remove and wifi_services are decoded and then decoded again by using protobuf: from '@helium/proto-ble'

Setting up the Rogue BLE

As it looks like, the public key, the onboarding and the wifi ssid, are all strings (shown in the code above). Additionally, i have seen a lot of code in the repo which checks for 0 length strings. So let’s populate some random base64 encoded strings, setup a rogue station by using the found device name, characteristic UUIDs and service UUIDs. Let the application connect to our rogue device (used the BLE:Bit tool for the setup):

Setting SSID...
Read[WiFi_SSID]: 53..MYAPNAME..30
Setting PubKey...
Read[PUBKey]: 56326868644756325a58
Setting OnBoarding Key...
Read[OnBoarding_Key]: 56326868644756325a58
Read[Avail_SSIDs]: 00000000000000000000
Read[WiFi_Configured]: 00000000000000000000

So that worked! The application identified the device as a Helium hotspot. So the WiFi SSID, public key and OnBorading key were used.

Now, obviously the Avail. SSID and Wifi Configuration Characteristics must be encoded with protobuf, as the code indicates, but we need the protobuf definitions. The definitions have been found here:

https://github.com/helium/helium-js/tree/master/packages/proto-ble

(otherwise we could reverse engineer the protobuf payload, but that procedure is not always successful).

Now that we have the proto definitions we need to understand the application’s logic and how it handles such code. When we have that, we can place the correct values into the characteristics and fake a rogue hotspot.

Available SSIDs & Wifi Configured Services

Below you may find the code that is responsible for parsing the SSID and Wifi Configured Services:

case HotspotCharacteristic.WIFI_CONFIGURED_SERVICES:
case HotspotCharacteristic.AVAILABLE_SSIDS_UUID:
return parseWifi(characteristicValue)
const parseWifi = (value: string): string[] => {
const buffer = util.newBuffer(util.base64.length(value))
util.base64.decode(value, buffer, 0)
return WifiServicesV1.decode(buffer).services
}

Wifi Services Proto:

syntax = "proto3";

message wifi_services_v1 {
  repeated string services = 1;
}

The characteristic available SSIDs is a string array encoded in protobuf which then must be encoded in base64! The same applies for wifi configured services too.

The handling mechanism seems pretty interesting to me at this moment.

Handling BLE Values

This is the high-level logic behind all of the previous procedures we have seen:

useEffect(() => {
  const unsubscribe = navigation.addListener('focus', async () => {
    // connect to hotspot
    const success = await connectAndConfigHotspot(hotspot)

    // check for valid onboarding record
    if (!success) {
      // TODO actual screen for this
      Alert.alert('Error', 'Invalid onboarding record')
      navigation.goBack()
      return
    }

    // check firmware
    const hasCurrentFirmware = await checkFirmwareCurrent()
    if (!hasCurrentFirmware) {
      navigation.navigate('FirmwareUpdateNeededScreen')
      return
    }

    // scan for wifi networks
    const networks = uniq((await scanForWifiNetworks()) || [])
    const connectedNetworks = uniq((await scanForWifiNetworks(true)) || [])

    // navigate to next screen
    navigation.replace('HotspotSetupPickWifiScreen', {
      networks,
      connectedNetworks,
    })
  })

  return unsubscribe
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

It connects and receives the current configuration of the hotspot and then the app is tring to check-out certain things like firmware version. When all good, it seems that it scans for wifi networks and replaces the screen related with the wifi network. The screen shall display the networks that have been found during wifi scan.

Checking firmware:

located in useHotspot.ts

  const checkFirmwareCurrent = async (): Promise<boolean> => {
  if (!connectedHotspot.current) return false

  const characteristic = FirmwareCharacteristic.FIRMWAREVERSION_UUID
  const charVal = await findAndReadCharacteristic(
    characteristic,
    connectedHotspot.current,
    Service.FIRMWARESERVICE_UUID,
  )
  if (!charVal) return false

  const deviceFirmwareVersion = parseChar(charVal, characteristic)

  const firmware: { version: string } = await getStaking('firmware')
  const { version: minVersion } = firmware

  dispatch(
    connectedHotspotSlice.actions.setConnectedHotspotFirmware({
      version: deviceFirmwareVersion,
      minVersion,
    }),
  )
  return compareVersions.compare(deviceFirmwareVersion, minVersion, '>=')
}

It seems there is a comparison of the firmware version at the end of the function. That shouldn’t be hard to bypass, as it is a numerical comparison. The firmware version must be greater or equal to the minVersion, whatever that is.

The minVersion is retrieved when getStaking(‘firmware’) is invoked:

export const getStaking = async (url: string) => makeRequest(url)

We can see clearly that this is a web request. So by monitoring the network traffic I observed an interesting request. However I can’t say the server response returned a ‘successful operation’ message.

GET /app/hotspots/V2hhdGV2ZX HTTP/1.1
authorization: Basic ...
Host: onboarding.dewi.org
Connection: close
Accept-Encoding: gzip, deflate
User-Agent: okhttp/3.14.4

Response:

HTTP/1.1 500 Internal Server Error
Server: Cowboy
Connection: close
X-Powered-By: Express
Strict-Transport-Security: max-age=31557600
Content-Type: application/json; charset=utf-8
Content-Length: 106
Etag: W/"6a-fHVDtXb9Hwi2pUWYpbWkUithBb4"
Vary: Accept-Encoding
Date: Fri, 12 Feb 2021 17:35:52 GMT
Via: 1.1 vegur

{"code":500,"errorMessage":"Cannot read property 'Maker' of null","errors":[],"data":null,"success":false}

Decoding the hostspot name sent in request:

echo -n "V2hhdGV2ZX==" | base64 -d
Whateve

That’s part of the value we have placed in one of the three previous values in our rogue BLE station.

Let’s create some valid SSIDs. I believe the SSIDs are used to be compared by the application. So those have to be valid SSIDs that are available both by the Helium device (our roge device) and the mobile device:

Code used for setting up BLE:Bit as rogue station:

// Avail SSIDs
if (characteristic.getUUID().toString().equals("d7515033-7e7b-45be-803f-c8737b171a29"))
{
    System.out.println("Setting Avail SSIDs...");
    try {
        // Build Protobuf
        WifiServices.wifi_services_v1.Builder builder = WifiServices.wifi_services_v1.newBuilder();
        List<String> ssids = new ArrayList<>();
        ssids.add("MyWifiAPName:)");
        builder.addAllServices(ssids);
        // Encode to protobuf and then base64
        byte[] encoded = Base64.getEncoder().encode(builder.build().toByteArray());
        // prepare data for transmission
        authorized_Data.setAuthorizedData(encoded, encoded.length);
​
    }catch(IOException ioex) {
    System.err.println(ioex.getMessage());
    }
}
Device Connected - Client Address: 6b:30:56:c6:70:e9 PRIVATE
Connected
Setting SSID...
Read[WiFi_SSID]: 53..MYAPNAME..30
Setting PubKey...
Read[PUBKey]: 563268686444493d
Setting OnBoarding Key...
Read[OnBoarding_Key]: 5632686864444d3d
Setting Avail SSIDs...
Read[Avail_SSIDs]: 4367..AVAIL_WIFIS..13d

Based on the source code, we expect the onboardkey to be retrieved by the application. However, no such read request has been observed via the BLE:Bit.

Application Source code regarding OnBoardKey:


// helium hotspot uses b58 onboarding address and RAK is uuid v4
const getHotspotType = (onboardingAddress: string): HotspotType =>
  validator.isUUID(addUuidDashes(onboardingAddress)) ? 'RAK' : 'Helium'

const addUuidDashes = (s = '') =>
  `${s.substr(0, 8)}-${s.substr(8, 4)}-${s.substr(12, 4)}-${s.substr(
    16,
    4,
  )}-${s.substr(20)}`

const getHotspotName = (type: HotspotType): HotspotName => {
  switch (type) {
    case 'RAK':
      return 'RAK Hotspot Miner'
    default:
    case 'Helium':
      return 'Helium Hotspot'
  }
}
const type = getHotspotType(onboardingAddress || '')

The code indicates that the value must be a 128bit UUID in raw bytes. But even then, it does not go through the next step. It shows that no wifi networks have been found.

That is odd. Trying to disable WiFi in the mobile device and repeating the process:

Connected
Setting SSID...
Read[WiFi_SSID]: 53..MYAPNAME..30
Setting PubKey...
Read[PUBKey]: 563268686444493d
Setting OnBoarding Key...
Read[OnBoarding_Key]: 5a4463314d5455774d7a4d335a5464694e4456695a513d3d
Setting OnBoarding Key...
Read[OnBoarding_Key]: 5a4463314d5455774d7a4d335a5464694e4456695a513d3d
Setting Avail SSIDs...
Read[Avail_SSIDs]: 4367..AVAIL_WIFIS..13d

As suspected, the app is looking for an internet connection. So the application is waiting for the server to respond when the onBoardkingKey is used! Then, it continues with BLE operations.

Conclusions

This is where we stop because that article is long enough. We have successfully reverse-engineered the process of connecting to a BLE device without actually having any device in our hands. We have constructed the services and their characteristics. Next, we have found the appropriate values and set them in the right place to increase the code coverage and proceed with the Helium’s device connection procedure.

There are times that luck is not on our side, ie when react native is in place and no open source is provided. That does not mean the reverse engineer is impossible but it definitely means it will be harder to be achieved.

“Do not follow where the path may lead, go instead where there is no path and leave a trail.”

Ralph Waldo Emerson

Below you may find the code used to explore the Helium System (BLE:Bit SDK 1.6, BLE:Bit Tool 1.0):

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Scanner;
import java.util.UUID;

import com.fazecast.jSerialComm.SerialPort;

import cybervelia.sdk.controller.BLECharacteristic;
import cybervelia.sdk.controller.BLEService;
import cybervelia.sdk.controller.ce.CEBLEDeviceCallbackHandler;
import cybervelia.sdk.controller.ce.CEController;
import cybervelia.sdk.controller.pe.AdvertisementData;
import cybervelia.sdk.controller.pe.AuthorizedData;
import cybervelia.sdk.controller.pe.NotificationValidation;
import cybervelia.sdk.controller.pe.PEBLEDeviceCallbackHandler;
import cybervelia.sdk.controller.pe.PEConnectionParameters;
import cybervelia.sdk.controller.pe.PEController;
import cybervelia.sdk.controller.pe.callbacks.BondingCallback;
import cybervelia.sdk.controller.pe.callbacks.PEConnectionCallback;
import cybervelia.sdk.controller.pe.callbacks.PENotificationDataCallback;
import cybervelia.sdk.controller.pe.callbacks.PEReadCallback;
import cybervelia.sdk.controller.pe.callbacks.PEWriteEventCallback;
import cybervelia.sdk.types.BLEAttributePermission;
import cybervelia.sdk.types.ConnectionTypesCommon;
import cybervelia.sdk.types.ConnectionTypesCommon.AddressType;
import cybervelia.sdk.types.ConnectionTypesPE;


public class HeliumExplore {
	static boolean debugging = true;
	static PEBLEDeviceCallbackHandler mypecallbackHandler = null;
	static PEController pe;
	static HashMap<String, String> mapUuidDescription = new HashMap<>();
	
	public static void main(String ...args) {
		connectToDevice();
		handleDevice();
	}
	
	
	
	
	private static String startComm(String device)
	{
		SerialPort[] sp = SerialPort.getCommPorts();
		String com_port = null;
		for(SerialPort s : sp)
		{
			System.out.println(s.getDescriptivePortName());
			 if (s.getDescriptivePortName().indexOf(device) >= 0)
			 {
				 com_port = s.getSystemPortName();
			 }
		}
		
		System.out.println("PE Opening: " + com_port);
		
		if (com_port == null)
		{
			System.err.println("COM Port does not exist");
			System.exit(1);
		}
		
		return com_port;
	}
	
	/* Identify BLE:Bit devices */
	private static String[] findPorts() {
		ArrayList<String> portsFound = new ArrayList<String>();
		
		SerialPort[] sp = SerialPort.getCommPorts();
		for(SerialPort s : sp)
		{
			if (!debugging && (s.getDescriptivePortName().toLowerCase().contains("cp210x") && System.getProperty("os.name").startsWith("Windows")))
				portsFound.add(s.getSystemPortName());
			else if (!debugging && (s.getDescriptivePortName().toLowerCase().contains("cp210x") && System.getProperty("os.name").startsWith("Linux")))
				portsFound.add(s.getSystemPortName());
			else if (debugging  && System.getProperty("os.name").startsWith("Windows") && (s.getDescriptivePortName().contains("Prolific") || s.getDescriptivePortName().contains("USB Serial Port")))
				portsFound.add(s.getSystemPortName());
			else if (debugging  && System.getProperty("os.name").startsWith("Linux") && (s.getDescriptivePortName().contains("pl2303") || s.getDescriptivePortName().contains("ftdi_sio")))
				portsFound.add(s.getSystemPortName());
		}
		
		String[] ports = new String[portsFound.size()];
		for(int i = 0; i<portsFound.size(); ++i)
		{
			ports[i] = portsFound.get(i);
			System.out.println("ADDED: " + ports[i]);
		}
		
		return ports;
	}
	
	public static void connectToDevice() {
		// Print Available Communication channels
		SerialPort[] sp = SerialPort.getCommPorts();
		for(SerialPort s : sp)
			System.out.println(s.getSystemPortName() + " - " + s.getDescriptivePortName());
		System.out.println(" --------------- ");
		
		// Find and print available serial ports
		String[] fports = findPorts();
		
		System.err.println("Devices Found: " + fports.length);
		
		
		// Initialise CE & PC
		try {
			mypecallbackHandler = new PEBLEDeviceCallbackHandler();
			pe = new PEController(fports[0], mypecallbackHandler);
			
			if (!pe.isInitializedCorrectly())
			{
				System.err.println("Not a BLEBit PE");
				System.exit(1);
			}
			
		}catch(IOException ioex) {
			System.err.println("Failed to initialize pe and ce: " + ioex.getMessage());
			System.exit(1);
		}
		
		System.out.println("SDK Version: " + ConnectionTypesCommon.getSDKVersion());
		System.out.println("PE FW Version: " + pe.getFirmwareVersion());
		
		
	}
	
	
	private static void handleDevice()
	{
		try {
			
			mypecallbackHandler.installConnectionCallback(new PEConnectionCallback() {

				@Override
				public void disconnected(int reason) {
					System.out.println("Disconnected");
				}
				
				@Override
				public void connected(AddressType address_type, String address) {
					System.out.println("Connected");
				}
				
			});
			
			mypecallbackHandler.installWriteCallback(new PEWriteEventCallback() {

				@Override
				public void writeEvent(BLECharacteristic characteristic, byte[] data, int data_size, boolean is_cmd,
						short handle) {
					if (characteristic != null)
					{
						System.out.print("Write["+mapUuidDescription.get(characteristic.getUUID().toString())+"]: ");
						for(int i=0;i<data_size;++i)
							System.out.print(String.format("%02x", data[i]));
						System.out.println();
					}else
						System.out.println("Characteristic object not found");
				}
				
			});
			
			mypecallbackHandler.installReadCallback(new PEReadCallback() {

				@Override
				public boolean authorizeRead(BLECharacteristic characteristic, byte[] data, int data_len,
						AuthorizedData authorized_Data) {
					
					if (characteristic != null)
					{
						// Wifi SSID
						if (characteristic.getUUID().toString().equals("7731de63-bc6a-4100-8ab1-89b2356b038b"))
						{
							System.out.println("Setting SSID...");
							try {
								String ssid = "MyAPsSSID";
								byte encoded[] = Base64.getEncoder().encode(ssid.getBytes());
								authorized_Data.setAuthorizedData(encoded, encoded.length);
							}catch(IOException ioex) {
								System.err.println(ioex.getMessage());
							}
						}
						
						// Public Key
						if (characteristic.getUUID().toString().equals("0a852c59-50d3-4492-bfd3-22fe58a24f01"))
						{
							System.out.println("Setting PubKey...");
							try {
								String value = "What2";
								byte[] encoded = Base64.getEncoder().encode(value.getBytes());
								authorized_Data.setAuthorizedData(encoded, encoded.length);
							}catch(IOException ioex) {
								System.err.println(ioex.getMessage());
							}
						}
						
						// OnBoarding Key
						if (characteristic.getUUID().toString().equals("d083b2bd-be16-4600-b397-61512ca2f5ad"))
						{
							System.out.println("Setting OnBoarding Key...");
							try {
								String value = "d75150337e7b45be";
								byte encoded[] = Base64.getEncoder().encode(value.getBytes());
								authorized_Data.setAuthorizedData(encoded, encoded.length);
							}catch(IOException ioex) {
								System.err.println(ioex.getMessage());
							}
						}
						
						// Avail SSIDs
						if (characteristic.getUUID().toString().equals("d7515033-7e7b-45be-803f-c8737b171a29"))
						{
							System.out.println("Setting Avail SSIDs...");
							try {
								WifiServices.wifi_services_v1.Builder builder = WifiServices.wifi_services_v1.newBuilder();
								List<String> ssids = new ArrayList<>();
								ssids.add("MyAPsSSID:)-Put yours here");
								builder.addAllServices(ssids);
								
								byte[] encoded = Base64.getEncoder().encode(builder.build().toByteArray());
								authorized_Data.setAuthorizedData(encoded, encoded.length);
							}catch(IOException ioex) {
								System.err.println(ioex.getMessage());
							}
						}
						
						// Print data
						System.out.print("Read["+mapUuidDescription.get(characteristic.getUUID().toString())+"]: ");
						if (authorized_Data.getAuthorizedDataLength() == 0)
						{
							for(int i=0;i<data_len;++i)
								System.out.print(String.format("%02x", data[i]));
							System.out.println();
						}
						else
						{
							for(int i=0;i<authorized_Data.getAuthorizedDataLength();++i)
								System.out.print(String.format("%02x", authorized_Data.getAuthorizedData()[i]));
							System.out.println();
						}
					}else
						System.out.println("Characteristic object not found");
					
					// Send data
					return true;
				}
				
				@Override
				public void readEvent(BLECharacteristic characteristic, byte[] data, int data_len) {
					// ignored
				}
				
			});
			
			mypecallbackHandler.installNotificationDataCallback(new PENotificationDataCallback() {

				@Override
				public void notification_event(BLECharacteristic char_used, NotificationValidation validation) {
					if (validation == NotificationValidation.NOTIFICATION_ENABLED || validation == NotificationValidation.INDICATION_ENABLED)
						System.out.println(mapUuidDescription.get(char_used.getUUID().toString()) + " NOTIFY enabled");
				}
				
			});
			
			pe.sendConnectionParameters(new PEConnectionParameters());
			pe.sendDeviceName("RAK Hotspot Miner");
			pe.configurePairing(ConnectionTypesCommon.PairingMethods.NO_IO, null);
			pe.sendBluetoothDeviceAddress("ea:bc:cc:11:33:13", ConnectionTypesCommon.BITAddressType.STATIC_PRIVATE);			
			pe.disableAdvertisingChannels(ConnectionTypesPE.ADV_CH_38 | ConnectionTypesPE.ADV_CH_39);
			pe.sendAdvIntervalTU(100);
			pe.setFirmwareRevision("10");
			
			System.out.println("FW VER: " + pe.getFirmwareVersion());
			
			// Add BLE Services
			
			BLEService main_uuid = new BLEService(UUID.fromString("0fda92b2-44a2-4af2-84f5-fa682baa2b8d").toString());
			
			
			// Create Advertisement Data
			
			AdvertisementData adv_data = new AdvertisementData();
			
			adv_data.setFlags(AdvertisementData.FLAG_LE_GENERAL_DISCOVERABLE_MODE | AdvertisementData.FLAG_ER_BDR_NOT_SUPPORTED);
			adv_data.addServiceUUIDComplete(main_uuid);
			
			AdvertisementData scan_data = new AdvertisementData();
			scan_data.includeDeviceName();
			
			
			
			// Add BLE Characteristic 
			
			addBLEChar("7731de63-bc6a-4100-8ab1-89b2356b038b", main_uuid, "WiFi_SSID"); 
			addBLEChar("0a852c59-50d3-4492-bfd3-22fe58a24f01", main_uuid, "PUBKey"); 
			addBLEChar("d083b2bd-be16-4600-b397-61512ca2f5ad", main_uuid, "OnBoarding_Key"); 
			addBLEChar("d7515033-7e7b-45be-803f-c8737b171a29", main_uuid, "Avail_SSIDs"); 
			addBLEChar("e125bda4-6fb8-11ea-bc55-0242ac130003", main_uuid, "WiFi_Configured"); 
			addBLEChar("8cc6e0b3-98c5-40cc-b1d8-692940e6994b", main_uuid, "WiFi_Remove"); 
			addBLEChar("398168aa-0111-4ec0-b1fa-171671270608", main_uuid, "WiFi_Connect");
			addBLEChar("df3b16ca-c985-4da2-a6d2-9b9b9abdb858", main_uuid, "Add_Gateway");
			addBLEChar("d435f5de-01a4-4e7d-84ba-dfd347f60275", main_uuid, "Assert_LOC"); 
			addBLEChar("b833d34f-d871-422c-bf9e-8e6ec117d57e", main_uuid, "Diagnostics");
			addBLEChar("e5866bd6-0288-4476-98ca-ef7da6b4d289", main_uuid, "Ethernet_Online");
			
			
			// Send settings
			
			pe.sendBLEService(main_uuid);
			pe.sendAdvertisementData(adv_data);
			pe.sendScanData(scan_data);
			pe.eraseBonds();
			pe.finishSetup();
			
			
			
			System.out.println("Ready for reset - PRESS ENTER");
			Scanner scanner = new Scanner(System.in);
			scanner.nextLine();
			
			pe.terminate();
			
			
		}catch(IOException e) {
			System.err.println(e.getMessage());
		}
	}	
	
	public static void addBLEChar(String uuid, BLEService service, String description) throws IOException {
		byte[] value = new byte[10];
		for(int i = 0; i<10; ++i) value[i] = 0;
		
		String uuid_char = UUID.fromString(uuid).toString();
		BLECharacteristic bCharacteristic = new BLECharacteristic(uuid_char, value);
		bCharacteristic.enableRead();
		bCharacteristic.enableWriteCMD();
		bCharacteristic.enableWrite();
		bCharacteristic.setMaxValueLength(31);
		bCharacteristic.setValueLengthVariable(true);
		bCharacteristic.enableNotification();
		bCharacteristic.enableHookOnRead();
		bCharacteristic.setAttributePermissions(BLEAttributePermission.OPEN, BLEAttributePermission.OPEN);
		service.addCharacteristic(bCharacteristic);
		mapUuidDescription.put(uuid, description);
	}
	
	
}