Мама, я хакер: пробуем вскрыть приложение на Flutter

RFC 1983

Hacker — человек, наслаждающийся доскональным пониманием внутреннего устройства систем,  компьютеров и компьютерных сетей, в частности.

Привет! Предлагаю заглянуть под капот фреймворка и разобрать процесс компиляции и, заодно, выявить потенциальные проблемы при реверс-инжиниринге приложения Flutter на платформе Android.

3cb56174093f1a8d237461e70f2e5197.png

Мама! Ну сколько раз тебе говорить,  я не нахер,  я — ХАКЕР!

В достаточно далёком, по меркам IT-технологий, 1989 году, я стал счастливым обладателем  своего первого персонального компьютера  «Ассистент», с неплохими характеристиками для домашнего ПК на тот момент — Intel совместимым процессором 8086 на 5МГц и памятью 128Кбайт. А еще был железный (в прямом смысле слова) матричный принтер фирмы Robotron, который, зараза, неправильно печатал одну из букв в русской раскладке. В общем, волею судьбы, первым моим опытом реверс-инжиниринга стал разбор кода работы BIOS при выводе на печать и создание на ассемблере небольшого перехватчика прерывания, который подменял букву на нужную. В результате, в памяти хорошо запомнился восторг того юного начинающего хакера-пионера, и я безразмерно благодарен своим родителям, которые, отказавшись от своих «хотелок» тогда, приобрели мне не самый дешёвый компьютер по тем временам, поддержав мой интерес к вычислительной технике.  

С тех пор, периодически занимался этой темой, правда, в основном, не профессионально. Это были или лично интересные для меня задачи, например исследования вирусов, защит ПО от взлома, или нечастые смежные задачи по работе. Занимался реверс-инжинирингом приложений под DOS/RT-11/Windows/Linux, в том числе приложений для .NET Framework, серверных приложений на Java, прошивок встраиваемых систем с различными процессорными архитектурами. В настоящее время, реверсом практически не занимаюсь, но, поскольку IT-фортуна свела меня с Flutter, решил вспомнить «молодость» и попробовать «на зубок» мобильные приложения, созданные с помощью этого замечательного фреймворка.

В общем, достаточно лирики. Итак, кому интересна магия превращения исходного кода на Dart в приложение Android и насколько сложно «взломать» его, с точки зрения хакера-любителя, добро пожаловать под кат.

79ba365a9828c2273102d292c1e369a5.png

Объект исследования

В качестве объекта для эксперимента, был написан небольшой пример, задача которого банальна — проверить корректность ввода пароля. Сам пароль храниться в исходном коде как константа (конечно, никогда так не делайте). При вводе пароля производится сравнение и выводится приветствие или, в противном случае, соответствующее предупреждение. Исходники этого нано-приложения расположены здесь.

Сейчас мы отступим от кодекса честного хакера и перейдем на темную сторону взломщиков crackers (по RFC 1983). Для примера используем хорошо известный приём «взлома», это замена условного оператора сравнения на противоположный по смыслу, т. е. если изначально в условии стоит «не равно» подменяем его на «равно». Во многих системах это осуществляется просто заменой одного байта машинного кода или промежуточного кода (например, для IL .NET Framework) в скомпилированном исполняемом файле, который запускает пользователь. При этом одна из самых главных задач — найти это место в скомпилированном файле. 

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

Итак, наша цель — без модификации исходного кода, имея на руках только скомпилированный apk файл приложения Android, понять, как работает код, найти место сравнения и заменить условный оператор, после этой манипуляции подойдет любой пароль, который вводит пользователь (конечно, кроме корректного).

9bdec6ee1326ffda4ccb2a5ba1a09dfb.png

Перепонтовался

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

Не знаю хорошо это или плохо, в общем — у меня это не получилось. Поэтому, если у кого был вопрос — сложнее ли взломать «обычное» Android приложение или Flutter? — При прочих равных условиях и исходя из своего скромного опыта,  Flutter сегодня существенно труднее поддается реверс-инжинирингу. И если для обычных приложений на Android, накоплен достаточный опыт, и мы имеем различные инструменты — например, можем получить Java код из dex/jar файлов приложения, или Smali код, то для Flutter все «грустно», ввиду отсутствия инструментов и практик. Пока фреймворк не так популярен, хотя, в случае роста популярности, возможно появятся и инструменты, и более конкретные кейсы. Поэтому, простите меня профессиональные хакеры, далее речь пойдет только о процессе компиляции и замене машинного кода приложения в заранее известном месте.

44b4310260b87e74b08b9998059d5e28.jpg

Инструменты

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

  • apktool — популярный инструмент для реверс-инжиниринга приложений под платформу Android

  • keytool — утилита управления сертификатами и ключами

  • jarsigner — утилита подписи Java архивов

  • adb — стандартный инструмент для работы с подключенными к компьютеру Android устройствами

  • hopper — дизассемблер для MacOS и Linux

  • расширение для 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, эти инструкции затем переводятся непосредственно в машинный код, который понимает процессор мобильного устройства.

84b9d88e5b751160a74978792fc09076.png

По поводу различия в скорости работы JIT и AOT:  JIT хотя и использует преобразование в машинный код непосредственно на устройстве пользователя и затрачивает на это некоторое время, результирующий машинный код может быть быстрее в некоторых случаях, так как может на лету анализировать типы объектов по месту использования (да, да — тот самый полиморфизм) и генерировать оптимальный код исходя из текущего контекста исполнения, но поскольку в iOS подход JIT не разрешен, для release режима сейчас используется только AOT.

Собираем Debug

Для начала попробуем собрать наш проект в режиме debug. Выполняем в терминале команду flutter build apk --debug

так как собраный файл APK это, по сути,  zip архив, распаковываем и смотрим что там внутри

Содержимое app-debug.apk
Содержимое app-debug.apkСодержимое app-debug.apk

Из всех компонентов можно выделить файлы, предназначенные для интеграции Flutter с платформой Android:

  • classes.dex — файл классов для виртуальной машины Dalvik, в обычных приложениях как раз в этом файле находится скомпилированный Java/Kotlin байт-код приложения. В случае с Flutter там находится код связи Flutter приложения с Android API, например — главный 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 файл.

f74f13774ebdf5a6c047e45ff4cb93a7.pngСодержимое app-release.apk
Содержимое app-release.apkСодержимое app-release.apk

В сборке release находятся те же файлы classes.dex и libflutter.so, что и в сборке debug, правда они уже не такие «жирные», так как исключены многие компоненты, используемые для отладки. По сравнению с debug сборкой видим отсутствие файлов isolate_snapshot_data,  vm_snapshot_data, но, если заглянуть в libapp.so, увидим, что теперь эти компоненты разместились здесь, а kernel_blob.bin был преобразован в машинный код и размещен в секциях _kDartIsolateSnapshotInstructions и _kDartVmSnapshotInstructions библиотеки.

1177100fa6d671260cfa22fea37c3405.png

Ещё можно увидеть, что все ресурсы (assets) программы хранятся в папке flutter_assets, соответственно их также можно свободно извлечь и модифицировать при необходимости.

На этом мои изыскания затормозились, ибо декодирование файла libapp.so, что не удивительно, отображает только ассемблерный код. Инструментов декомпиляции Dart кода хотя бы в промежуточный IL, не говоря уже о AST или Dart я не нашел. С учётом процесса компиляции, конечно, можно представить и обратный процесс — это поиск точек входа, использование описания объектов из секции информации об объектах (_kDartIsolateSnapshotData), преобразование в несколько проходов из ассемблера в IL и граф переходов, затем в kernel binary и, наконец, в Dart. Но, данная работа выходит далеко за рамки объема времени, который я предполагал выделить на эту статью.

Место модификации

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

Сравнив 2 файла, было видно, что они отличаются только в одном байте, что не может радовать хакера-любителя, так как это позволяет изменить просто один байт, не затрагивая размер и композицию файла. Соответственно, чтобы изменить поведение приложения на противоположное надо заменить только один байт.

1276ea95c49919860cf4eb715c2b7a2b.png

Для исследования был взят libapp.so для архитектуры arm64, которая используется в процессоре на моем телефоне. Если посмотреть на участок кода ассемблера в котором есть изменения, то можно увидеть, что они касаются команды тестирования и перехода. Соответственно заменив tbnz на tbz мы получим необходимый нам результат.

aed3fa430efff1c78e5295a0b4eba9de.png

Само преобразование IL кода в машинный код находится в Dart sdk, в файле отвечающим за соответствующую архитектуру. Например для assembler ARM64 она находится здесь. Если проследить цепочку вызовов можно выйти и на компилятор flowgraph и IL. Здесь мы видим, что большинство кода компиляции используется как для JIT, так и для AOT режимов.

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

Пример консольного приложения main.dart
const _secret = 'secret';
void main(List 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
1df4d185b3763afef012427c99cb95b9.png

Эта команда распакует в каталоге app-release компоненты нашей релизной сборки. Поскольку сейчас интересует именно архитектура arm64 возьмем libapp.so из каталога lib/arm64-v8a и с помощью шестнадцатеричного редактора заменим байт в файле.

Замена байта по смещению 0×1FFCA7 на 0×37
2ddd3e216a0a9c9484337ef4aad4176f.png

После модификации libapp.so переcобираем apk

apktool b app-release
cb8ebeae4509438d3497f30553d8aff0.png

Поскольку мы изменили содержимое файлов архива 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

© Habrahabr.ru