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

Комментарии (1)


  1. vdudouyt
    05.12.2025 08:31

    Отличный инструмент для динамической инструментации, позволяющий изучать и в определенной мере изменять поведение ПО на уровне более низком, чем наблюдение за внешними проявлениями I/O, но более высоком, чем вгрызание в ассемблерный код в дебаггере перед более детальной разборкой. К тому же десктопные вендоры, в отличие от мобильных, особо им не пуганы, что позволяет сэкономить некоторое время.