
Реверс-инжиниринг — это трудоемкая и интересная задача, которая поддается не всем. Любой может «скормить» программу декомпилятору, но не у всех хватит выдержки разобраться в хитросплетениях машинных команд. Процесс становится сложнее, если исследование проводится над программой для другого устройства, например телефона с ОС Android.
Звучит сложно. Долгое время и мне так казалось, особенно при создании модов для приложений. Байт-код smali неплох, но писать на нем сложную логику вручную — неблагодарное занятие. Но недавно мне попался на глаза решение для динамического реверс-инжиниринга — Frida.
Frida — это инструмент, который позволяет вживлять небольшой кусок JavaScript-кода прямо в запущенное приложение и менять его поведение. Под катом я расскажу, как работать с Frida, исследовать приложения на телефоне без root-доступа и создавать свои моды.
Дисклеймер. Данный текст предоставляется исключительно в развлекательных целях. Автор не несет ответственности за любые возможные действия, вдохновленные прочитанным текстом.
Помимо этого, многие разработчики приложений в правилах использования (ToS) или в лицензии прямо запрещают реверс-инжиниринг, декомпиляцию и прочие изыски над своими приложениями. В редких случаях, как, например, с серверной частью Minecraft, исследования и модификации разрешены, но исключительно для личного пользования.
Чтобы не нарушить никаких правил, в качестве «подопытного» я выбрал приложение с открытым исходным кодом под интересным названием «KGB Messenger». Это приложение специально создано для практики в играх формата CTF (Capture The Flag) и состоит из нескольких простых экранов со своими загадками. Мы не будем спойлерить настоящее решение и флаги, а просто модифицируем приложение, чтобы обойти одну «сюжетную» проверку, и добавим своего «пользователя» в это приложение.

Бесплатный курс по мобильному тестированию
Станьте экспертом в Mobile QA. Научитесь проверять приложения разных платформ.
Подготовка окружения
Профессионалы своего дела и опытные участники CTF могут собирать себе «полноценное» окружение, которое состоит из Android Studio, эмулятора с root-правами и нескольких декомпиляторов на все случаи жизни.
В рамках статьи я спроектирую ситуацию, когда исследователь не хочет тянуть все зависимости Android Studio и проводит эксперименты непосредственно на своем телефоне без root-доступа. Сперва просто поставим приложение и посмотрим, что оно из себя представляет.

При попытке запустить приложение сразу же появляется ошибка, что его можно запустить только на русских устройствах. Значит, пора доставать инструменты. Нам потребуется следующее.
Python 3.x — у меня 3.13.9.
Node.js и npm — я использовал 22.12.0 и 10.9.0.
Java Runtime Environment (JRE).
Android Debug Bridge (adb) — его можно установить через Android Studio, а можно скачать отдельно в виде SDK Platform Tools.
APKTool — инструмент декомпиляции APK-файлов.
zipalign — инструмент выравнивания файлов по четырем байтам, это важно для новых версий ОС Android.
apksigner — утилита для подписи APK-файла.
Большинство утилит существуют как под Windows, так и под Linux. Я запускаю практически все программы на Windows, кроме zipalign и apksigner. Их я выполняю в WSL, потому что эти программы есть в репозиториях ОС Ubuntu.
«Сердцем» нашего приключения является Frida — динамический инструмент для разработчиков, реверс-инженеров и исследователей безопасности. Frida работает как отладчик с интерактивной консолью и поддержкой скриптов на языке JavaScript (движок V8). Frida взаимодействует с программами, написанными на C, Go, .NET, Swift, Java, и может следить за вызовами функций и переопределять логику без доступа к исходному коду.
Устанавливаем набор Frida на компьютер:
pip install frida-tools pip install frida npm install frida
Для отладки на удаленных устройствах есть Frida-server, которая выполняет всю работу на устройстве и связывается с «клиентом» на компьютере. Проблема в том, что без root-доступа нельзя запустить приложение с возможностью отладки. К счастью, это проблема решается использованием frida-gadget.
frida-gadget — это динамическая библиотека, которая загружается при запуске приложения и запускает Frida-server, ограниченный процессом приложения. Это позволяет получить полный контроль над одним приложением без необходимости «рутования» телефона.
Внедрение библиотеки в приложение происходит в несколько команд:
# Внедряем библиотеку frida-gadget --apktool-path "java -jar apktool_2.10.0.jar" kgb-messenger.apk # Выравниваем файлы в архиве zipalign -f -p -v 4 kgb-messenger/dist/kgb-messenger.apk kgb-messenger.patched.apk # Создаем ключ для подписи (нужно сделать только один раз!) keytool -genkey -v -keystore my.keystore -alias alias_name -keyalg RSA -keysize 4096 -validity 10000 # Подписываем APK-файл apksigner sign --ks-key-alias app --ks my.keystore kgb-messenger.patched.apk
Особенности безопасности на Android не позволяют поставить приложение, подписанное другим сертификатом «поверх», поэтому удаляем и ставим заново.
Теперь, если запустить приложение, то приложение откроется, но сообщения об ошибке не будет. Это потому, что frida-gadget перехватила управление и ждет команды со стороны компьютера. Это сделано специально, чтобы исследователь получил доступ к приложению до того, как оно начнет полезную работу.
По умолчанию frida-gadget слушает подключения по адресу 127.0.0.1 на порту 27042. Этот адрес телефона недостижим для компьютера, поэтому нужно пробросить порт с телефона на компьютер:
adb forward tcp:27042 tcp:27042
Обратите внимание, что
frida-gadget— это известный инструмент, и разработчики приложений могут делать эмпирические проверки на наличие Frida на телефоне. Одна из таких проверок — открытый порт 27042. Так, например, во время написания статьи у меня перестала открываться одна из онлайн-игр на телефоне. Стоит остановить исследуемое приложение с Frida, и игра снова запускается. Чудеса!
Теперь запускаем приложение и подключаемся. Указываем localhost, а вместо имени процесса — Gadget.
E:\frida>frida -H 127.0.0.1 Gadget ____ / _ | Frida 17.2.15 - A world-class dynamic instrumentation toolkit | (_| | > _ | Commands: /_/ |_| help -> Displays the help system . . . . object? -> Display information about 'object' . . . . exit/quit -> Exit . . . . . . . . More info at https://frida.re/docs/home/ . . . . . . . . Connected to 127.0.0.1 (id=socket@127.0.0.1) [Remote::Gadget ]->
Теперь у нас есть интерактивная консоль, которая умеет совершать действия в памяти JVM-процесса приложения.
Исследование приложения
Писать в консоли довольно неудобно, поэтому сразу создаем файл hello.js в текущем каталоге и вписываем в него следующий код.
// Объявляем глобальную переменную, которая будет доступна в консоли var activity; // Выполняем в контексте Java, это асинхронная функция Java.perform(() => { // Перебираем все загруженные в память объекты-наследники Activity Java.choose('android.app.Activity', { // Для каждого подходящего объекта вызывается эта функция onMatch: function(a) { console.log("Found activity: " + a.getClass().getSimpleName() ); activity = a; }, // В конце перебора будет выполнена эта функция onComplete: function() { console.log("Activity search completed"); } }); })
Затем загружаем скрипт в консоли. Скрипт выполняется и мы можем посмотреть в объект.
[Remote::Gadget ]-> %load hello.js Are you sure you want to load a new script and discard all current state? [y/N] y Found activity: MainActivity Activity search completed [Remote::Gadget ]-> activity "<instance: android.app.Activity, $className: com.tlamb96.kgbmessenger.MainActivity>" [Remote::Gadget ]->
Теперь можно использовать автодополнение в консоли, чтобы изучить доступные методы в Activity. Дальше остается исследовательская деятельность. Но даже с нулевыми познаниями в байт-коде виртуальной машины мы можем посмотреть на декомпилированный код, который остался от выполнения команды frida-gadget.
В текущем каталоге находится каталог с именем APK-файла, а внутри нас ждут различные артефакты, в том числе smali-код приложения. Быстро проходимся по каталогам и по пути kgb-messenger/smali/com/tlamb96/kgbmessenger находим три интересных класса: MainActivity, LoginActivity и MessengerActivity.
Smali, как и любой машинный код, читать не просто. Но если вам однажды придется это делать, то рекомендую шпаргалку в переводе @LionZXY.
Изначальная задача в этого приложения заставить вас разобраться что именно проверяет приложение и какие данные оно ждет на вход, ведь эти данные — флаг, то есть ответ на задачу. В нашем случае флаг не представляет ценности, гораздо важнее «рабочее» приложение. Делаем смелое предложение, что можно проигнорировать ошибку и перейти в LoginActivity.
Дополняем функцию onComplete:
var activity; Java.perform(() => { Java.choose('android.app.Activity', { onMatch: function(a) { console.log(a) console.log("Found activity: " + a.getClass().getSimpleName() ); activity = a; }, onComplete: function() { console.log("Activity search completed"); // Загружаем классы var Intent = Java.use("android.content.Intent"); var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); // Создаем объект var intent = Intent.$new(activity, LoginActivity.class); // Запрашиваем смену Activity activity.startActivity(intent); } }); })

Затем в консоли выполняем команду %reload и наблюдаем успех на телефоне. Появляется вопрос: «После каждого изменения скрипта нужно вводить %reload в консоли? И можно ли это как-то автоматизировать?»
Ответ — да. При запуске можно указать скрипт, который нужно загрузить и Frida будет отслеживать его изменения и тут же применяет.
frida -H 127.0.0.1 Gadget -l hello.js
Однако вскоре вы заметите, что при каждой перезагрузкой скрипта у вас запускается новая LoginActivity. Исправим это:
var activity; var login; Java.perform(() => { Java.choose('android.app.Activity', { onMatch: function(a) { console.log("Found activity: " + a.getClass().getSimpleName() + " isResumed: " + a.isResumed() ); if(a.getClass().getSimpleName() == "MainActivity") { if(a.isResumed()) { // Если MainActivity активна, то сменяем на LoginActivity var Intent = Java.use("android.content.Intent"); var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); var intent = Intent.$new(a, LoginActivity.class); a.startActivity(intent); } } if(a.getClass().getSimpleName() == "LoginActivity") { // Сохраняем приведенную Activity login = Java.cast(a, Java.use("com.tlamb96.kgbmessenger.LoginActivity")) }else { // Сохраняем Acvitity для исследования activity = a; } }, onComplete: function() { console.log("Activity search completed"); } }); })
Теперь при первом запуске MainActivity будет сменяться на LoginActivity, которую мы можем исследовать. Воспользуемся функциями Frida для получения функций и полей класса, объявленных именно в LoginActivity.
Java.perform(() => { var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); console.log("====== Declared Methods ======") for(var m of LoginActivity.class.getDeclaredMethods()) { console.log(m) } console.log("====== Declared Fields ======") for(var m of LoginActivity.class.getDeclaredFields()) { console.log(m) } })
Сохраняем скрипт и сразу же видим результат:
====== Declared Methods ====== private void com.tlamb96.kgbmessenger.LoginActivity.i() private boolean com.tlamb96.kgbmessenger.LoginActivity.j() public void com.tlamb96.kgbmessenger.LoginActivity.onBackPressed() protected void com.tlamb96.kgbmessenger.LoginActivity.onCreate(android.os.Bundle) public void com.tlamb96.kgbmessenger.LoginActivity.onLogin(android.view.View) ====== Declared Fields ====== private java.security.MessageDigest com.tlamb96.kgbmessenger.LoginActivity.m private java.lang.String com.tlamb96.kgbmessenger.LoginActivity.n private java.lang.String com.tlamb96.kgbmessenger.LoginActivity.o
Наше внимание привлекают две приватные функции и три приватных поля. Кажется, что n и o — это строки, в которые сохраняются значения из формы. Вводим «admin» в поле логина и «12345» в поле пароля, нажимаем кнопку входа, а затем «заглядываем» в приватные поля.
[Remote::Gadget ]-> login.n.value "admin" [Remote::Gadget ]-> login.o.value "12345" [Remote::Gadget ]-> login.j() false [Remote::Gadget ]-> login.i() Error: java.lang.StringIndexOutOfBoundsException: length=5; index=7 at <anonymous> (/frida/bridges/java.js:1) at value (/frida/bridges/java.js:8) at e (/frida/bridges/java.js:8) at apply (native) at value (/frida/bridges/java.js:8) at e (/frida/bridges/java.js:8) at <eval> (<input>:1)
Обратите внимание, что для доступа к значению нужно обратиться к полю value, иначе вы получите описание поля класса.
Метод
i()возвращает булево значение и, вероятно, проверяет корректность пароля.Метод
j()явно ожидает, что в полях будет правильный логин и пароль.
Обновим значения и попробуем еще раз.
[Remote::Gadget ]-> login.n.value = "adminlong" "adminlong" [Remote::Gadget ]-> login.o.value = "1234567890" "1234567890" [Remote::Gadget ]-> login.i() Error: java.lang.NullPointerException: Can't toast on a thread that has not called Looper.prepare() at <anonymous> (/frida/bridges/java.js:1) at value (/frida/bridges/java.js:8) at e (/frida/bridges/java.js:8) at apply (native) at value (/frida/bridges/java.js:8) at e (/frida/bridges/java.js:8) at <eval> (<input>:1)
Эврика! Метод i() действительно связан с логином и паролем и пытается показать нам Toast — всплывающее окно. Все действия с графическим интерфейсом должны выполняться в главном потоке. Даем команду на выполнение в главном потоке и видим всплывающее окно.
[Remote::Gadget ]-> Java.scheduleOnMainThread(() => {login.i();})

Корректный флаг появится только при правильной паре «логин-пароль». Но опять же: поиск флага выходит за рамки нашей задачи. Поэтому модифицируем методы LoginActivity, чтобы можно было войти в приложение по своим данным, а также отключим демонстрацию флага.
Java.perform(() => { var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); LoginActivity.i.implementation = function () { // Переопределяем функцию i, которая показывает Toast // Оставляем пустое тело } LoginActivity.j.implementation = function () { // Переопределяем функцию j, которая проверяет пароль // и возвращает статус проверки if(this.o.value == "admin") { // Если пароль равен admin, то возвращаем успех return true; } // В остальных ситуациях выполняем оригинальную функцию return this.j(); } })
Запускаем приложение и обнаруживаем, что приложение проверяет не пару «логин-пароль», а сперва проверяет логин, затем — пароль. Логин придется узнать как-то без Frida.
Спойлер к загадке CTF
Логин можно найти среди ресурсов приложения в файле strings.xml. Там же можно найти флаг для первой загадки и хэш настоящего пароля для этого экрана.

Если все сделано правильно, то теперь у нас есть приложение, в котором игнорируется проверка устройства и добавлен «бэкдор» — возможность входа по паролю «admin».
Основная проблема этой модификации — абсолютная неработоспособность без «привязки» к компьютеру. Добавим модификации немного автономности.
Сохранение изменений
У Frida-gagdet есть формат взаимодействия script, в котором выполняется скрипт вместо запуска сервера для интерактивного взаимодействия. Казалось бы, добавляем скрипт в APK-файл, переключаемся на режим взаимодействия script — и готово. Но нет. Сперва подготовим скрипт: уберем лишние отладочные строки и переменные.
// Импортируем функции для взаимодействия с Java import Java from "frida-java-bridge"; Java.perform(() => { // Переопределение методов в LoginActivity var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); LoginActivity.i.implementation = function () {} LoginActivity.j.implementation = function () { console.log("override") if(this.o.value == "admin") { return true; } return this.j(); } }) setTimeout(() => { Java.perform(() => { Java.choose('android.app.Activity', { onMatch: function(a) { if(a.getClass().getSimpleName() == "MainActivity") { if(a.isResumed()) { // Если MainActivity активна, то сменяем на LoginActivty var Intent = Java.use("android.content.Intent"); var LoginActivity = Java.use("com.tlamb96.kgbmessenger.LoginActivity"); var intent = Intent.$new(a, LoginActivity.class); a.startActivity(intent); } } }, onComplete: function() {} }); }); }, 200);
Главное отличие скрипта для неинтерактивного способа — наличие явного импорта функций для взаимодействия с Java. Если этого не сделать, то скрипт просто не исполнится и Frida не скажет почему.
Второе отличие — необходимость откладывать действия поиска на неопределенное время, чтобы все что нужно загрузилось в память. В идеале нужно переопределить функцию onCreate в MainActivity, но именно в ней происходит инициализация Frida, из-за чего уже нельзя изменить поведение этой функции.
Теперь собираем скрипт в формат для интеграции в APK-файл и собираем новый APK.
npm install frida-java-bridge frida-compile -c -o hello-prod.js hello.js frida-gadget --js hello-prod.js --apktool-path "java -jar apktool_2.10.0.jar" kgb-messenger.apk # Далее выравниваем и подписываем, как описывалось ранее
Вот теперь у нас есть автономная модификация, которая просто работает. В теории все хорошо, но есть множество нюансов, которые узнаются только в процессе взаимодействия с Frida.
Подписывайтесь на мой Telegram-канал, там можно увидеть заметки по темам статей, над которыми я работаю, и небольшие познавательные посты, а по пятницам всегда время мемов.
Бонус
Я решил сохранить некоторые из моментов, с которыми столкнулся в процессе работы с Frida. Я не во всех случаях понимаю, почему что-то работает или не работает, но нашел обходные пути и добился работоспособности.
Регистрация новых классов
Это очевидный момент, он находится довольно быстро: строки в JavaScript не могут быть аргументами в полях, которые принимают Java-строку.
var JString = Java.use("java.lang.String"); var arg = JString.$new("foobar");
Инициализация связи с Java
Хотя в статье я оборачивал весь код в лямбда-функцию, которая передавалась в Java.perform, консольные команды выполняются в глобальном контексте. Но чтобы в глобальном контексте работали команды вроде Java.use, вам необходимо инициализировать связь с Java и хотя бы один раз вызвать Java.perform.
Строковый тип в Java
Frida позволяет регистрировать классы во времени исполнения. Например, если вам необходимо определить какой-то интерфейс для обратного вызова (callback).
var OnSyncCallbackImp; Java.perform(() => { OnSyncCallback = Java.use("com.example.app.OnCallback"); OnSyncCallbackImp = Java.registerClass({ // Имя может быть любое name: 'com.frida.LogSyncCallback', // Указываем какие интерфейсы реализуются implements: [OnSyncCallback], // Поля класса fields: { context: 'android.content.Context', path: 'java.lang.String' }, // Методы класса methods: { // Конструктор. Может быть несколько перегрузок у каждого метода $init: [{ // Аргументы argumentTypes: ["android.content.Context", "java.lang.String"], // Возвращаемый тип returnType: "void", implementation: function (arg1, arg2) { // Все поля имеют тип Field, // для использования значения нужно поле value this.context.value = arg1; this.path.value = arg2 } }], onError: [{ returnType: 'void', argumentTypes: ['java.lang.String', 'int', 'int'], implementation: function (a, b, c) { // реализация } }], onSuccess: [{ returnType: 'void', argumentTypes: ['java.lang.String', 'int', 'int'], implementation: function (a, b, c) { // Реализация } }], } }); })
В некоторых случаях Frida отказывалась регистрировать класс. Помогало только вынесение Java.registerClass в отдельный Java.perform и все чудесным образом начинало работать.
Несколько попыток поиска
В статье предлагается отсрочить поиск MainActivity на 200 мс. Хорошей идеей будет сделать механизм повторения в случае неудачного поиска, например, до пяти раз с периодом в 500 мс.
Курс по мобильному тестированию
Кажется, что мы разобрали простой CTF. Но на деле подобные задачи часто основываются на реальных случаях с уязвимостями. Поэтому тестировать мобильное приложение перед релизом — особенно важная задача.
Коллеги подготовили бесплатный курс по мобильному тестированию. Присоединяйтесь, если хотите узнать, на что важно обратить внимание перед продакшеном.
Заключение
Frida — это мощный инструмент, который позволяет исследовать и модифицировать приложения на Android без долгих перекомпиляций и чтения байт-кода. Помимо этого, адаптировать Frida-скрипт к новым версиям приложения гораздо быстрее и удобнее, чем разбираться в байт-коде. Тем не менее, Frida — это лишь один из инструментов и он не обладает всемогуществом.
