Реверс-інжиніринг, Bluetooth та електроживлення: пишемо Android-застосунок для акумулятора
Вітаю! Мене звати Олег, я розробляю Android-застосунки в Uptech Product Studio. Цієї зими всі ми зіштовхнулися з потребою забезпечити себе електроживленням на час планових відключень. Чекати новий Ecoflow місяцями або переплачувати спекулянтам не хотілося, тому я вирішив зібрати його самостійно. Забрав у одному з інтернет-магазинів останній LiFePO4 акумулятор від польського виробника VoltPolska, прикупив до нього зарядку та інвертор.
Коли компоненти приїхали, на моє здивування, на акумуляторі було нанесено Bluetooth-імʼя. Зрадівши, я почав читати інструкцію в пошуках посилання на застосунок. І воно там справді було, однак після переходу я отримав... помилку 404. Виробник чомусь видалив свій застосунок. Після додаткового пошуку, я таки знайшов .apk чи-то офіційної, чи ні, апки десь у глухих закутках інтернету. Поставив на старий телефон, перевірив — працює.
Як Android-розробник і як простий користувач, я знаю, що ставити рандомні .apk з закапелків інтернету на свій телефон — це погана ідея. Мені хотілося стежити за акумулятором зі свого основного девайсу, і при цьому не наражати себе на небезпеку. Тому рішення (чи проблема) намалювалося самостійно: треба зареверсінжинірити застосунок, зрозуміти, як працює Bluetooth-модуль акумулятора і написати власний аналог.
Що з цього вийшло, читайте далі.
Disclaimer: усі дії у статті проводилися на основі відкритих ресурсів з метою дослідження, розваги та покращення власного комфорту. Повторювати на свій страх і ризик.
Bluetooth Low Energy
Головний герой історії
Для початку потрібно зрозуміти, з чим я взагалі маю справу. Зазвичай IoT-пристрої використовують особливу специфікацію Bluetooth, що називається Bluetooth Low Energy (BLE). Як можна здогадатися, вона дозволяє заощадити заряд пристрою, що особливо актуально для таких штук як акумулятори. Робиться це за рахунок обмежень пропускної здатності, радіусу та швидкості взаємодії.
BLE працює за стандартною клієнт-серверною моделлю, але має кілька особливостей. Для передачі даних використовується Attribute Protocol (ATT), що визначає так звані атрибути. Кожен атрибут має своє унікальне ім’я-UUID і двійкове значення, яке може бути зчитане клієнтом.
У свою чергу, на ньому будується протокол GATT (Generic Attribute Profile), що використовує ATT-атрибути для визначення таких абстракцій, як профілі, сервіси та характеристики.
Працює це так: у профілі може бути декілька сервісів, у кожному з яких може бути декілька характеристик.
На практиці це нагадує стандартне HTTP API, де профіль — це саме API, сервіси — це згруповані за певною фічею чи сутністю роути, а характеристики — окремі ендпоінти.
У сервісів і характеристик можуть бути права доступу: читання, запис, встановлення сповіщень (девайс самостійно відправлятиме вам повідомлення, коли значення характеристики змінюється) тощо.
Також можна встановлювати людиночитабельні назви та описи, одиниці вимірювання, проміжки можливих значень та інші метадані, що передаються за допомогою дескрипторів.
Знаючи це все, можна спробувати аналізувати, які саме сервіси і характеристики підтримує мій акумулятор.
Реверс-інжиніринг
Спочатку я спробував просканувати девайс за допомогою застосунку nRF Connect. Це класна штука, що показує, які атрибути доступні в девайсу. Вона навіть може відправляти запити і зчитувати відповіді. Однак, вивід застосунку був не дуже красномовним:
Вочевидь, розробник вирішив не додавати людиночитабельні дескриптори з міркувань чи-то безпеки, чи-то нестачі ресурсів. На додачу, при зчитуванні всі характеристики повертали нулі.
Загалом це глухий кут, але тут є кілька підказок, що стануть очевидними пізніше.
Мій наступний крок був просканувати трафік між оригінальним застосунком і акумулятором. На щастя, Android має функцію логування Bluetooth-пакетів у меню розробника. Я увімкнув її і почав гратися. Скинув отриманий btsnoop_hci.log файл на комп’ютер і відкрив його у Wireshark:
Усе ще нічого не ясно, але дуже цікаво. Видно write-команди, за якими слідують сповіщення про зміну якоїсь характеристики. Вочевидь, застосунок відправляє запит надати йому дані у write-характеристику і отримує відповідь через notify-характеристику. Ці дві характеристики також видно у nRF Connect на скриншоті вище.
Також я спробував проаналізувати значення, які відправляються та повертаються. Виглядають вони якось так:
Приклад write-запиту
Приклад notify-відповіді
Звичайно, це зашифровані в двійковий код і склеєні докупи числа, рядки і флаги. Просто так, не знаючи принципу формування, їх не розшифрувати. Але є щось дивне. Абсолютно всі вони починаються із байту 7b і закінчуються байтом 7d. Навмання звертаємося до ASCII-таблиці:
Бінго! Це фігурні дужки і, схоже, це не збіг. Якийсь JSON чи щось таке? Навряд, бо далі б мали йти лапки, а це не так. Але це вже старт, щоб поритися в оригінальній .apk. Відкриваємо її у Android Studio, знаходимо файл app-service.js. Вочевидь, він відповідає за обмін даними, тоді як app-view.js — за UI. Код мініфікований і обфускований, але не повністю (і це стане фатальним фактором).
Виконуємо пошук за 7b, і тут справді є багато роботи з бінарними значеннями (чомусь вони подаються як рядки, а не гексові числа). Зрештою, знаходиться ще один ключ до розгадки. Мініфікований код викликає купу якихось функцій, що називаються за принципом new7b**ToInfo.
Шукаємо декларацію.
Знову бінго. Назви функцій не мініфіковані, тому що з якоїсь причини розробники склали їх у JS-масив у вигляді об’єктів:
[{ key: “new7b**ToInfo”, value: function(...) { … }]
Потім вони присвоїли їх як поля для іншого об’єкта, який, вочевидь, діє як репозиторій даних чи щось таке. Тепер у нас є код для дешифрування двійкових результатів. Функція вище відкушує по кілька байтів від рядка, що представляє відповідь акумулятора (у шістнадцятковому записі), і парсить їх у int.
І заодно останній пазл головоломки: 7b та 7d — це справді фігурні дужки для розрізнення коректних значень, а наступний байт — це код команди. Наприклад, команда 01 змушує акумулятор повідомити загальну інформацію про свій стан, 02 повертає кількість окремих батарейних блоків усередині та дані кожного з них. І так далі.
Загальний принцип роботи виглядає так: надсилаємо у write-характеристику запит, наприклад, 7b01007d, і отримуємо відповідь по типу 7b0103010007d. Як бачимо, перші байти запиту і відповіді співпадають. Таким чином можна зрозуміти, на яку саме команду прийшла відповідь, і розпарсити її відповідно. Далі йдуть власне дані. Для чисел, де дробові значення (сила струму, напруга), вони діляться на 100 перед виводом на екран. От і все.
Android-застосунок
Тепер можна писати власний застосунок. Я накидав орієнтовний дизайн у Figma, а для розробки обрав Jetpack Compose. У деталі вдаватися не буду, оскільки на Android Developers робота з Bluetooth описана доволі добре. Згадаю лише незвичайні моменти.
В останніх версіях Android Bluetooth SDK отримало чимало покращень, серед яких і нові permissions. Я вирішив поставити нижню планку на 27 SDK (Android 8.1 Oreo). Тому мені потрібно підтримувати і новий, і старий підходи. Пермішни для старих SDK виглядали так:
<uses-permission android:name="android.permission.BLUETOOTH"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
Дозволи на локацію потрібні, тому що деякі блютуз-девайси можуть викрити місцезнаходження користувача під час під’єднання (наприклад, поштомат «Нової Пошти», скоріше за все, знає свою локацію, і підключення означатиме, що ви перебуваєте в радіусі ~10 метрів від нього). Тому Google вимагає від розробників явно повідомляти юзерів про таку можливість. Експериментально я встановив, що без дозволу на FINE_LOCATION сканування взагалі не запуститься.
Утім, починаючи зі SDK 30 (Android 11 R), старі блютуз-пермішни (з локацією все так само) задепрекейтили і тепер потрібно використовувати більш спеціалізовані:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
Роботу з пермішнами я роблю в Compose-коді за допомогою гуглівської бібліотеки Accompanist. Також я дописав на рівні актівіті логіку, що стежить за тим, щоб Bluetooth і геолокація були увімкнені:
private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent == null) return when (intent.action) { BluetoothAdapter.ACTION_STATE_CHANGED -> { when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) { BluetoothAdapter.STATE_OFF -> bluetoothEnabled = false BluetoothAdapter.STATE_ON -> bluetoothEnabled = true } } LocationManager.PROVIDERS_CHANGED_ACTION -> { val locationManager = getSystemService<LocationManager>() val isGpsEnabled = locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER) ?: false val isNetworkEnabled = locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER) ?: false locationEnabled = isGpsEnabled || isNetworkEnabled } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) registerReceiver(broadcastReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) registerReceiver(broadcastReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) … }
Пошук девайсів відбувається прозаїчно, згідно з гайдами з Android Developers. У кожного знайденого девайсу я перевіряю наявність сервісу та характеристик з потрібними UUID, щоб підтвердити, що це VoltPolska.
Після встановлення повноцінного підключення кожну секунду відправляю запит на оновлення характеристики. Наразі я працюю лише з командою 01, оскільки там є всі дані, що мене цікавлять — ємність, заряд, напруга тощо.
Знайти вихідний код і збілдити застосунок, якщо у вас теж акумулятор від VoltPolska, можна на GitHub. Також ось скриншоти того, що вийшло в кінці:
Реалізація всіх інших команд уже прозаїчна. Якщо розвивати застосунок далі, то треба було б винести з’єднання із в’юмоделі в окремий сервіс і прив’язати його до sticky-нотифікації. Тоді з’єднання не перериватиметься, коли застосунок йде у фон.
Післямова
Чого можна навчитися з цієї історії? Маючи достатньо бажання, можна обійти будь-яку систему безпеки. У моєму випадку спрацював ефект швейцарського сиру. Якби хтось не опублікував у мережі робочий застосунок чи якби розробники мініфікували код повністю, реверс-інжинірити було б у рази важче, але все ще можливо (аналізуючи вхідні/вихідні значення побайтово).
Із цього напрошується висновок про незахищеність і вразливість блютуз-девайсів. У них часто немає ніякого контролю над тим, хто і як підключається чи хоча б над тим, увімкнений блютуз чи ні. Умовний сусід теоретично міг би провести той же експеримент і підключитися до мого девайсу. З акумулятором це не страшно, але те саме працює з розумними вагами, датчиками, лампами, камерами тощо. Особисто мене це трохи лякає.
Це був цікавий експеримент, який захопив мене на кілька вечорів. Акумулятор і свій застосунок я потім використовував за призначенням, і це відчувалося справді класно — як щось, зроблене своїми руками.
44 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів