Reverse Engineering Android Apps: An Introduction to Frida

META

Activist
SUPREME
MEMBER
Joined
Mar 1, 2026
Messages
118
Reaction score
378
Deposit
0$
Reverse engineering is a labor-intensive and challenging task, but not everyone is up to the task. Anyone can feed a program to a decompiler, but not everyone has the patience to decipher the intricacies of machine instructions. The process becomes more challenging if the research is being conducted on a program for another device, such as an Android phone.

It sounds complicated. For a long time, I thought so too, especially when creating app mods. Smali's bytecode is fine, but writing complex logic in it by hand is a thankless task. But recently, I came across a dynamic reverse engineering solution called Frida.

Frida is a tool that allows you to inject a small piece of JavaScript code directly into a running app and change its behavior. Below, I'll explain how to use Frida, explore apps on a non-rooted phone, and create your own mods.

Disclaimer: This text is provided for entertainment purposes only. The author is not responsible for any actions that may be taken based on the text.
Furthermore, many app developers explicitly prohibit reverse engineering, decompilation, and other modifications to their apps in their terms of service (ToS) or licenses. In rare cases, such as with Minecraft's server side, research and modification are permitted, but only for personal use.

To avoid breaking any rules, I chose an open-source app with the interesting name " KGB Messenger " as my "test subject." This app is specifically designed for practicing CTF (Capture the Flag) games and consists of a few simple screens with their own puzzles. We won't spoil the actual solution or flags; we'll simply modify the app to bypass one "plot" check and add our own "user" to the app.

79828d2325890e0b3500e49d3b7f1d24.png

Free mobile testing course
Become an expert in mobile QA. Learn to test apps across multiple platforms.
Explore →

Preparing the environment​

Professionals and experienced CTF participants can assemble a "full-fledged" environment for themselves, consisting of Android Studio, an emulator with root access, and several decompilers for all occasions.

In this article, I'll simulate a scenario where a researcher doesn't want to pull in all of Android Studio's dependencies and instead conducts experiments directly on their phone without root access. First, we'll simply install the app and see what it's like.

Error starting application.

Error starting application.
When I try to launch the app, I immediately get an error saying it only works on Russian devices. So, it's time to get out the tools. We'll need the following.

  • Python 3.x - I have 3.13.9.
  • Node.js and npm - I used 22.12.0 and 10.9.0.
  • Java Runtime Environment (JRE).
  • Android Debug Bridge (adb) - can be installed through Android Studio, or downloaded separately as SDK Platform Tools .
  • APKTool is an APK file decompilation tool.
  • zipalign is a tool for aligning files to four bytes, which is important for newer versions of Android OS.
  • apksigner is a utility for signing APK files.
Most utilities exist for both Windows and Linux. I run almost all of them on Windows, except for zipalign and apksigner. I run them in WSL because they are available in the Ubuntu repositories.

The heart of our adventure is Frida , a dynamic tool for developers, reverse engineers, and security researchers. Frida functions as a debugger with an interactive console and JavaScript scripting support (powered by the V8 engine). Frida works with programs written in C, Go, .NET, Swift, and Java, and can trace function calls and redefine logic without accessing the source code.

Install the Frida suite on your computer:

pip install frida-tools
pip install frida
npm install frida


For debugging on remote devices, there's Frida-server, which performs all the work on the device and communicates with the "client" on the computer. The problem is that without root access, you can't run the application with debugging capabilities. Fortunately, this problem can be solved by using frida-gadget.

frida-gadget— is a dynamic library that loads when the app starts and launches the Frida server, which is scoped to the app process. This allows for full control over a single app without rooting the phone.


Embedding the library into an application occurs in several commands:

# Внедряем библиотеку
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 security features prevent you from installing an app signed with another certificate on top of it, so we'll delete it and reinstall it.

Now, if you launch the application, it will open, but there will be no error message. This is because frida-gadgetit has seized control and is waiting for a command from the computer. This is done intentionally so that the researcher gains access to the application before it begins useful work.

By default, frida-gadgetit listens for connections at 127.0.0.1 on port 27042. This phone address is unreachable by the computer, so you need to forward the port from the phone to the computer:

adb forward tcp:27042 tcp:27042


Please note that frida-gadgetthis is a well-known tool, and app developers can conduct empirical tests for Frida on a phone. One such test is for open port 27042. For example, while writing this article, one of the online games on my phone stopped opening. As soon as I stopped the Frida-enabled app, the game started working again. Miracles!
Now launch the application and connect. Specify localhost and replace the process name with 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=[email protected])

[Remote::Gadget ]->


Now we have an interactive console that can perform actions in the memory of the application's JVM process.

Application research​

Writing in the console is quite inconvenient, so we immediately create a file hello.js in the current directory and enter the following code into it.

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


Then we load the script in the console. The script runs, and we can view the object.

[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 ]->


Now we can use console autocompletion to explore the available methods in Activity. Now all that's left is exploration. But even with zero knowledge of virtual machine bytecode, we can look at the decompiled code left over from executing the command frida-gadget.

The current directory contains a directory named after the APK file, and inside are various artifacts, including the app's smali code. We quickly navigate through the directories and kgb-messenger/smali/com/tlamb96/kgbmessengerfind three interesting classes: MainActivity, , LoginActivityand MessengerActivity.

Smali, like any machine code, isn't easy to read. But if you ever have to, I recommend @LionZXY's translated cheat sheet .
The initial goal of this app is to force you to understand what exactly the app is checking and what input it expects, since that input is a flag, or response to a task. In our case, the flag is irrelevant; the "working" app is far more important. We make a bold suggestion that you can ignore the error and move on to LoginActivity.

We supplement the function 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);
}
});
})


We were allowed to access the login page without any problems.

We were allowed to access the login page without any problems.
Then we run the command in the console %reloadand see the success on the phone. The question arises: "Do I need to enter this in the console after each script change %reload? And is there any way to automate this?"

The answer is yes. When launching, you can specify a script to load, and Frida will monitor its changes and apply them immediately.

frida -H 127.0.0.1 Gadget -l hello.js


However, you'll soon notice that every time you reload the script, a new one is launched LoginActivity. Let's fix this:

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");
}
});
})


Now, on first run, MainActivityit will change to LoginActivity, which we can examine. We'll use Frida functions to retrieve the functions and class fields declared specifically in 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)
}
})


We save the script and immediately see the result:

====== 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


Our attention is drawn to two private functions and three private fields. It seems that [ ] nand o[ ] are strings where form values are saved. We enter "admin" in the login field and "12345" in the password field, click the login button, and then "peek" into the private fields.

[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)


Please note that to access the value you need to access the field value, otherwise you will get the class field description.

  • The method i()returns a boolean value and likely checks the password for correctness.
  • The method j()clearly expects the fields to contain the correct login and password.
Let's update the values and try again.

[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)


Eureka! The method i()is indeed linked to the username and password and is attempting to display a toast—a pop-up window. All GUI actions must be performed on the main thread. We issue a command to execute on the main thread and see the pop-up window.

[Remote::Gadget ]-> Java.scheduleOnMainThread(() => {login.i();})


The application shows us a flag, but it is clearly incorrect.

The application shows us a flag, but it is clearly incorrect.
The correct flag will only appear if the login and password pair are correct. But again, finding the flag is beyond the scope of our task. Therefore, we'll modify the methods LoginActivityto allow logging into the application using our credentials, and also disable the flag display.

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();
}
})


We launch the app and discover that it doesn't check the login/password pair; it first checks the login and then the password. We'll have to find the login somehow without Frida.

Spoiler alert for the CTF puzzle

An excerpt from a fictitious correspondence.

An excerpt from a fictitious correspondence.
If everything is done correctly, we now have an application that ignores device verification and adds a "backdoor" - the ability to log in using the password "admin".

The main problem with this modification is its complete inoperability without being tethered to a computer. Let's add some autonomy to the modification.

Saving changes​

Frida-gagdet has a "script" interaction format, which executes a script instead of launching a server for interactive interaction. It would seem that we just add the script to the APK file, switch to "script" interaction mode, and we're done. But no. First, let's prepare the script: remove unnecessary debug lines and variables.

// Импортируем функции для взаимодействия с 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);


The main difference between the non-interactive script and the Java script is the explicit import of functions for interfacing with Java. If this isn't done, the script simply won't execute, and Frida won't tell you why.

The second difference is the need to defer search actions indefinitely to allow everything needed to load into memory. Ideally, you should override the onCreate function in MainActivity, but this is where Frida is initialized, making it impossible to change its behavior.

Now we compile the script into a format for integration into an APK file and build a new 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
# Далее выравниваем и подписываем, как описывалось ранее


Now we have a standalone version that simply works. It's all good in theory, but there are many nuances that only become apparent through interaction with Frida.

Subscribe to my Telegram channel , where you can see notes on the topics of the articles I'm working on, as well as short educational posts. Fridays are always meme time.

Bonus​

I decided to document some of the issues I encountered while working with Frida. I don't always understand why something works or doesn't, but I found workarounds and got it working.

Registration of new classes​

This is an obvious point, and it's found pretty quickly: JavaScript strings cannot be arguments to fields that accept a Java string.

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


Initializing communication with Java​

Although I wrapped all the code in a lambda function passed to Java.perform in this article, console commands are executed in the global context. However, for commands like Java.use to work in the global context, you need to initialize the connection with Java and call .perform at least once Java.perform.

String type in Java​

Frida allows you to register classes at runtime. For example, if you need to define a callback interface ( 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) {
// Реализация
}
}],
}
});
})


In some cases, Frida refused to register a class. The only solution was to move it Java.registerClassto a separate class Java.perform, and everything miraculously started working again.

Several search attempts​

The article suggests delaying the search MainActivityby 200 ms. A good idea would be to implement a retry mechanism in case of unsuccessful search, for example, up to five times with a period of 500 ms.

Mobile Testing Course​

It seems like we've covered a simple CTF. But in reality, such challenges are often based on real-world vulnerability cases. Therefore, testing a mobile app before release is especially important.

Our colleagues have prepared a free course on mobile testing. Join us if you want to learn what's important to consider before going live.

Conclusion​

Frida is a powerful tool that allows you to examine and modify Android apps without lengthy recompilations and bytecode reading. Furthermore, adapting a Frida script to new app versions is much faster and more convenient than delving into bytecode. However, Frida is only one tool and is not omnipotent.
 
Top Bottom