Frida: реверс-инжиниринг приложений на Android - Академия Selectel

Frida: реверс-инжиниринг приложений на Android

Владимир Туров
Владимир Туров Разработчик
5 декабря 2025

Рассказываем, как работать с Frida, исследовать приложения на телефоне без root-доступа и создавать свои моды.

Изображение записи

Вы когда-нибудь задумывались, что на самом деле происходит внутри Android-приложения, которым пользуетесь каждый день? Реверс-инжиниринг — это трудоемкая и интересная задача, которая поддается не всем. Любой может «скормить» программу декомпилятору, но не у всех хватит выдержки разобраться в хитросплетениях машинных команд. Процесс становится сложнее, если исследование проводится над программой для другого устройства, например телефона с ОС Android.

Звучит сложно. Долгое время и мне так казалось, особенно создание модов для приложений. Байт-код smali неплох, но писать на нем сложную логику вручную — неблагодарное занятие. Но недавно мне попался на глаза решение для динамического реверс-инжиниринга — Frida.
В статье я расскажу, как работать с Frida, исследовать приложения на телефоне без root-доступа и создавать свои моды.

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

Помимо этого, многие разработчики приложений в правилах использования (ToS) или в лицензии прямо запрещают реверс-инжиниринг, декомпиляцию и прочие изыски над своими приложениями. В редких случаях, как, например, с серверной частью Minecraft, исследования и модификации разрешены, но исключительно для личного пользования. 
Чтобы не нарушить никаких правил, в качестве «подопытного» я выбрал приложение с открытым исходным кодом под интересным названием «KGB Messenger». Это приложение специально создано для практики в играх формата CTF (Capture The Flag) и состоит из нескольких простых экранов со своими загадками. Мы не будем спойлерить настоящее решение и флаги, а просто модифицируем приложение, чтобы обойти одну «сюжетную» проверку, и добавим своего «пользователя» в это приложение.

Подготовка окружения

Профессионалы своего дела и опытные участники 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-процесса приложения.


      // Объявляем глобальную переменную, которая будет доступна в консоли
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.

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

Логин можно найти среди ресурсов приложения в файле `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.

Бонус

Я решил сохранить некоторые из моментов, с которыми столкнулся в процессе работы с Frida. Я не во всех случаях понимаю, почему что-то работает или не работает, но нашел обходные пути и добился работоспособности.

Строковый тип в Java

Это очевидный момент, он находится довольно быстро: строки в JavaScript не могут быть аргументами в полях, которые принимают Java-строку.


      var JString = Java.use(“java.lang.String”);
var arg = JString.$new(“foobar”);

Инициализация связи с Java

Хотя в статье я оборачивал весь код в лямбда-функцию, которая передавалась в Java.perform, консольные команды выполняются в глобальном контексте. Но чтобы в глобальном контексте работали команды вроде Java.use, вам необходимо инициализировать связь с Java и хотя бы один раз вызвать Java.perform.

Регистрация новых классов

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 мс.

Заключение

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