
Реверс-инжиниринг — это трудоемкая и интересная задача, которая поддается не всем. Любой может «скормить» программу декомпилятору, но не у всех хватит выдержки разобраться в хитросплетениях машинных команд. Процесс становится сложнее, если исследование проводится над программой для другого устройства, например телефона с ОС 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 — это лишь один из инструментов и он не обладает всемогуществом.
vdudouyt
Отличный инструмент для динамической инструментации, позволяющий изучать и в определенной мере изменять поведение ПО на уровне более низком, чем наблюдение за внешними проявлениями I/O, но более высоком, чем вгрызание в ассемблерный код в дебаггере перед более детальной разборкой. К тому же десктопные вендоры, в отличие от мобильных, особо им не пуганы, что позволяет сэкономить некоторое время.