Мама, я хакер: пробуем вскрыть приложение на Flutter
Hacker – человек, наслаждающийся доскональным пониманием внутреннего устройства систем, компьютеров и компьютерных сетей, в частности.
Привет! Предлагаю заглянуть под капот фреймворка и разобрать процесс компиляции и, за одно, выявить потенциальные проблемы при реверс-инжиниринге приложения Flutter на платформе Android.
Мама! Ну сколько раз тебе говорить, я не нахер, я – ХАКЕР!
В достаточно далёком, по меркам IT-технологий, 1989 году, я стал счастливым обладателем своего первого персонального компьютера «Ассистент», с неплохими характеристиками для домашнего ПК на тот момент – Intel совместимым процессором 8086 на 5МГц и памятью 128Кбайт. А еще был железный (в прямом смысле слова) матричный принтер фирмы Robotron, который, зараза, неправильно печатал одну из букв в русской раскладке. В общем, волею судьбы, первым моим опытом реверс-инжиниринга стал разбор кода работы BIOS при выводе на печать и создание на ассемблере небольшого перехватчика прерывания, который подменял букву на нужную. В результате, в памяти хорошо запомнился восторг того юного начинающего хакера-пионера, и я безразмерно благодарен своим родителям, которые, отказавшись от своих «хотелок» тогда, приобрели мне не самый дешёвый компьютер по тем временам, поддержав мой интерес к вычислительной технике.
С тех пор, периодически занимался этой темой, правда, в основном, не профессионально. Это были или лично интересные для меня задачи, например исследования вирусов, защит ПО от взлома, или нечастые смежные задачи по работе. Занимался реверс-инжинирингом приложений под DOS/RT-11/Windows/Linux, в том числе приложений для .NET Framework, серверных приложений на Java, прошивок встраиваемых систем с различными процессорными архитектурами. В настоящее время, реверсом практически не занимаюсь, но, поскольку IT-фортуна свела меня с Flutter, решил вспомнить «молодость» и попробовать «на зубок» мобильные приложения, созданные с помощью этого замечательного фреймворка.
В общем, достаточно лирики. Итак, кому интересна магия превращения исходного кода на Dart в приложение Android и насколько сложно «взломать» его, с точки зрения хакера-любителя, добро пожаловать под кат.
Объект исследования
В качестве объекта для эксперимента, был написан небольшой пример, задача которого банальна – проверить корректность ввода пароля. Сам пароль храниться в исходном коде как константа (конечно, никогда так не делайте). При вводе пароля производится сравнение и выводится приветствие или, в противном случае, соответствующее предупреждение. Исходники этого нано-приложения расположены здесь.
Сейчас мы отступим от кодекса честного хакера и перейдем на темную сторону взломщиков crackers (по RFC 1983). Для примера используем хорошо известный приём «взлома», это замена условного оператора сравнения на противоположный по смыслу, т. е. если изначально в условии стоит «не равно» подменяем его на «равно». Во многих системах это осуществляется просто заменой одного байта машинного кода или промежуточного кода (например, для IL .NET Framework) в скомпилированном исполняемом файле, который запускает пользователь. При этом одна из самых главных задач – найти это место в скомпилированном файле.
Поскольку у меня был некоторый опыт именно с исследованием Android приложений, в качестве примера возьмем эту платформу, конечно, большинство шагов преобразования исходного кода и сам принцип компиляции также применим и к iOS приложениям.
Итак, наша цель – без модификации исходного кода, имея на руках только скомпилированный apk файл приложения Android, понять, как работает код, найти место сравнения и заменить условный оператор, после этой манипуляции подойдет любой пароль, который вводит пользователь (конечно, кроме корректного).
Перепонтовался
Перед началом работы над статьей, изначально ставил цель разобраться именно с реверс-инжинирингом кода, т. е. без исходных кодов, разобраться с принципом работы исследуемого «объекта» и внести соответствующие коррекции для изменения поведения.
Не знаю хорошо это или плохо, в общем – у меня это не получилось. Поэтому если у кого был вопрос – сложнее ли взломать «обычное» Android приложение или Flutter? – При прочих равных условиях и исходя из своего скромного опыта, Flutter сегодня существенно труднее поддается реверс-инжинирингу. И если для обычных приложений на Android, накоплен достаточный опыт, и мы имеем различные инструменты – например, можем получить Java код из dex/jar файлов приложения, или Smali код, то для Flutter все «грустно», ввиду отсутствия инструментов и практик. Пока фреймворк не так популярен, хотя, в случае роста популярности, возможно появятся и инструменты, и более конкретные кейсы. Поэтому, простите меня профессиональные хакеры, далее речь пойдет только о процессе компиляции и замене машинного кода приложения в заранее известном месте.
Инструменты
Прежде чем мы приступим к исследованию файла Android приложения на Flutter, опишу инструменты, которые использовались в процессе.
-
apktool – популярный инструмент для реверс-инжиниринга приложений под платформу Android
-
keytool – утилита управления сертификатами и ключами
-
jarsigner – утилита подписи Java архивов
-
adb – стандартный инструмент для работы с подключенными к компьютеру Android устройствами
-
расширение для VSCode позволяющие редактировать файлы в шестнадцатеричном режиме
Все действия производились на MacOS.
Debug&Release
Во Flutter существует несколько режимов сборки приложения. Для целей статьи мы рассмотрим основные – это debug и release режимы.
Debug в этом режиме, кроме того, что там по умолчанию включены различные дополнения, помогающие разработчику при отладке, используется так называемая JIT (Just-In-Time) компиляция, в вольном переводе «прямо-во-время», по сути преобразование в машинные коды, которые понимает процессор мобильного устройства, происходит непосредственно во время работы самого приложения.
Release режим использует уже AOT (Ahead-Of-Time) буквально «заранее», компиляция приложения из исходных кодов в машинные коды мобильного устройства производится на машине разработчика, и на устройство пользователя устанавливается уже скомпилированное приложение, никаких преобразований во время выполнения не производится.
У этих 2-х режимов есть одно общее – это AST представление. При компиляции исходных кодов они предварительно переводятся в промежуточное представление AST (Abstract-Syntax-Tree), это представление может сохраняться в специализированном формате как Kernel Binary (как правило это файлы с расширением dill).
Именно это представление передается в мобильное устройство при JIT компиляции в режиме Debug и используется как промежуточный шаг при переводе в машинные коды в режиме AOT. Далее это представление преобразуется в граф управления и переходов (CFG), содержащий инструкции промежуточного кода IL, эти инструкции затем переводятся непосредственно в машинный код, который понимает процессор мобильного устройства.
По поводу различия в скорости работы JIT и AOT: JIT хотя и использует преобразование в машинный код непосредственно на устройстве пользователя и затрачивает на это некоторое время, результирующий машинный код может быть быстрее в некоторых случаях, так как может на лету анализировать типы объектов по месту использования (да, да – тот самый полиморфизм) и генерировать оптимальный код исходя из текущего контекста исполнения, но поскольку в iOS подход JIT не разрешен, для release режима сейчас используется только AOT.
Собираем Debug
Для начала попробуем собрать наш проект в режиме debug. Выполняем в терминале команду flutter build apk --debug
так как собраный файл APK это, по по сути, zip архив распаковываем и смотрим что там внутри
Содержимое app-debug.apk
Из всех компонентов можно выделить файлы, предназначенные для интеграции Flutter с платформой Android:
-
classes.dex – файл классов для виртуальной машины Dalvik, в обычных приложениях как раз в этом файле находится скомпилированный Java/Kotlin байт-код приложения. В случае с Flutter там находится код связи Flutter приложения с AndroidAPI, например – главный FlutterActivity, работа с объектами поверхностей рисования на виртуальном дисплее, код для связи с платформенными каналами
-
libflutter.so – менее платформозависимая библиотека движка Flutter написанная в основном на C/C++. В библиотеке находится runtime для работы Flutter, код OpenGL, SKIA и runtime виртуальной машины Dart. Более подробней c составом этих файлов можно ознакомиться в скрипте сборки GN
Что касается именно нашего кода проекта, он расположился в следующих файлах:
-
isolate_snapshot_data описывает объекты/граф объектов и их создание, это помогает быстро разворачивать, при старте приложения, структуры данных используемые в программе
-
vm_snapshot_data общие объекты виртуальной машины Dart используемые изолятами
-
kernel_blob.bin, вот здесь и храниться наш код в формате kernel binary, а также весь код фреймворка написанный на Dart. Причем это blob файл, т. е. там может храниться различная мета информация о коде кроме самого kernel binary, в случае debug сборки там также храниться весь исходный код нашего приложения включая комментарии. На самом деле модификация debug сборки не представляет интерес, так как там практически храниться все в «открытом» виде. Поэтому для целей нашей статься перейдем к исследованию сборки в режиме release
Собираем Release
Выполняем команду flutter build apk
Для получения полного лога о процессе сборки используется флаг --verbose
, он покажет гораздо больше информации, что так же может быть полезным при диагностике проблем. В логе можно увидеть что процесс компиляции нашего проекта проходит этап преобразования в AST представление, при этом генерируется файл app.dill, затем применяется инструмент gen_snapshot, он создает из app.dill скомпилированный в машинный код файл библиотеки libapp.so, который уже помещается в APK файл.
Содержимое app-release.apk
В сборке release находятся те же файлы classes.dex и libflutter.so, что и в сборке debug, правда они уже не такие «жирные», так как исключены многие компоненты, используемые для отладки. По сравнению с debug сборкой видим отсутствие файлов isolate_snapshot_data, vm_snapshot_data, но, если заглянуть в libapp.so, увидим, что теперь эти компоненты разместились здесь, а kernel_blob.bin был преобразован в машинный код и размещен в секциях _kDartIsolateSnapshotInstructions и _kDartVmSnapshotInstructions библиотеки.
Ещё можно увидеть, что все ресурсы (assets) программы хранятся в папке flutter_assets, соответственно их также можно свободно извлечь и модифицировать при необходимости.
На этом мои изыскания затормозились, ибо декодирование файла libapp.so, что не удивительно, отображает только ассемблерный код. Инструментов декомпиляции Dart кода хотя бы в промежуточный IL, не говоря уже о AST или Dart я не нашел. С учетом процесса компиляции, конечно, можно представить и обратный процесс – это поиск точек входа, использование описания объектов из секции информации об объектах (_kDartIsolateSnapshotData), преобразование в несколько проходов из ассемблера в IL и граф переходов, затем в kernel binary и, наконец, в Dart. Но, данная работа выходит далеко за рамки объема времени, который я предполагал выделить на эту статью.
Место модификации
Не разобравшись полноценно с реверс-инжинирингом приложения, решил отложить этот вопрос и просто сравнить два скомпилированных файла, чтобы понять в каком месте происходят изменения при изменении операнда условия в исходном коде.
Сравнив 2 файла, было видно, что они отличаются только в одном байте, что не может радовать хакера-любителя, так как это позволяет изменить просто один байт, не затрагивая размер и композицию файла. Соответственно, чтобы изменить поведение приложения на противоположное надо заменить только один байт.
Для исследования был взят libapp.so для архитектуры arm64, которая используется в процессоре на моем телефоне. Если посмотреть на участок кода в котором есть изменения, то можно увидеть, что они касаются команды тестирования и перехода. Соответственно заменив tbnz на tbz мы получим необходимый нам результат.
Само преобразование IL кода в машинный код находится в Dart sdk, в файле отвечающим за соответствующую архитектуру. Например для assembler ARM64 она находится здесь. Если проследить цепочку вызовов можно выйти и на компилятор flowgraph и IL. Здесь мы видим, что большинство кода компиляции используется как для JIT, так и для AOT режимов.
Для тех, кому интересна компиляции и процессы преобразования Dart SDK предоставляет такую возможность.
Пример консольного приложения main.dart
const _secret = 'secret';
void main(List<String> args) {
if (args.isNotEmpty) {
String value = args[0];
if (value == _secret) {
print("You are in!!");
} else {
print("Please, enter again");
}
}
}
Команда отображающая при запуске информацию о IL, CFG и коде ассемблераdart --print-flow-graph --print-flow-graph-filter=main --disassemble main.dart
Пример участка скомпилированного кода, выполняющий операцию сравнения
;; t0 <- LoadLocal(value @-1)
0x10ac21e87 ff75e0 push [rbp-0x20]
;; t1 <- Constant(#secret)
0x10ac21e8a 4d8b5f47 movq r11,[pp+0x47]
0x10ac21e8e 4153 push r11
;; t0 <- InstanceCall:24( ==<0>, t0, t1)
0x10ac21e90 488b542408 movq rdx,[rsp+0x8]
0x10ac21e95 498b5f4f movq rbx,[pp+0x4f]
0x10ac21e99 4d8b6757 movq r12,[pp+0x57]
0x10ac21e9d 41ff54240f call [r12+0xf]
0x10ac21ea2 59 pop rcx
0x10ac21ea3 59 pop rcx
0x10ac21ea4 50 push rax
;; t1 <- LoadLocal(:t0 @-2)
;; AssertBoolean:26(t1)
0x10ac21ea5 488b45d8 movq rax,[rbp-0x28]
0x10ac21ea9 493b86d0000000 cmpq rax,[thr+0xd0] null
0x10ac21eb0 0f8509000000 jnz 0x000000010ac21ebf
0x10ac21eb6 4d8b672f movq r12,[pp+0x2f]
0x10ac21eba 41ff542407 call [r12+0x7]
;; t1 <- Constant(#true)
0x10ac21ebf 41ffb6d8000000 push [thr+0xd8]
;; Branch if StrictCompare:28(===, t0, t1) goto (4, 5)
0x10ac21ec6 415b pop r11
0x10ac21ec8 58 pop rax
0x10ac21ec9 493b86d8000000 cmpq rax,[thr+0xd8] true
0x10ac21ed0 0f8522000000 jnz 0x000000010ac21ef8
Акт последний – модификация APK
Закончим наш эксперимент заменой соответствующего байта условного оператора в скомпилированном apk файле. Это операция состоит из нескольких этапов несколько этапов:
-
Разборка apk файла
-
Модификация libapp.so с заменой байта
-
Сборка модифицированного apk
-
Подпись apk
-
Установка на смартфон и проверка
Разбираем исходный файл используя инструмент apktool
apktool d -r -s app-release.apk
Эта команда распакует в каталоге app-release компоненты нашей релизной сборки. Поскольку сейчас интересует именно архитектура arm64 возьмем libapp.so из каталога lib/arm64-v8a и с помощью шестнадцатеричного редактора заменим байт в файле.
Замена байта по смещению 0x1FFCA7 на 0х37
После модификации libapp.so переcобираем apk
apktool b app-release
Поскольку мы изменили содержимое файлов архива APK, при установке этого файла сработает защита проверки подписи. Чтобы обойти это, нам необходимо подписать файл своей подписью. Можно использовать существующий ключ или создать новый хранилище ключей командойkeytool -genkeypair -v -keystore example.keystore -alias example -keyalg RSA -keysize 2048 -validity 10000
Подписываем файл apksigner sign --ks example.keystore --ks-key-alias example app-release.apk
Устанавливаем на устройство adb install app-release.apk
Запускаем наше приложение, и… теперь подходит любой пароль! Мы изменили поведение приложения без использования исходных файлов.
Резюме
Хотя полностью провести реверс-инжиниринг мне не удалось, по результатам работы над статьей можно сделать вывод – исследование без исходных кодов внутренней работы приложения на Flutter выполнить сложнее чем «обычного» приложения под Android. Что касается именно «взлома», конечно декомпиляция является не единственным инструментов для достижения целей взломщиков – у них есть целый арсенал как прокси-серверов, инструменты для SQL-unpinning, инъекций кода, я уже не говорю про социальную инженерию. Поэтому, изначально, можно предполагать, что при необходимости и целесообразности весь код приложения может быть просмотрен и изучен, а также проведена его соответствующая модификация в различных целях. Поэтому важна целостная картина безопасности инфраструктуры и бизнес-контекста, в котором работает мобильное приложение. Но, это совершенно отдельная тема, которая пересекается с этой статьей только в части реверс-инжиниринга.
Что касается именно Flutter, хранение чувствительной информации на устройстве желательно избегать, но в случае необходимости можно использовать пакет flutter_secure_storage или использовать обфускацию при сборке, которая может установить дополнительную преграду при изучении и осмыслении скомпилированного кода, используя имена классов и функций.
Источники информации используемые при подготовке статьи
Выражаю благодарность всем разработчикам Dart SDK за понятный и говорящий за себя код, а также отдельно Вячеславу Егорову за описание и принцип работы виртуальной машины
https://github.com/dart-lang/sdk/blob/master/runtime/docs/index.md