Introduction to Android NDK Reverse Engineering with Frida and Ghidra
The Android Native Development Kit (NDK) allows developers to implement parts of their applications using native code languages like C and C++. While this can offer performance benefits and access to low-level system APIs, it also introduces a new layer of complexity for security researchers and penetration testers. Reverse engineering native Android libraries (`.so` files) is crucial for understanding an application’s core logic, identifying vulnerabilities, or bypassing security mechanisms implemented at the native layer. This guide delves into an advanced methodology, combining the static analysis prowess of Ghidra with the dynamic instrumentation capabilities of Frida, to effectively hook and manipulate native functions within Android NDK applications.
Frida, a dynamic instrumentation toolkit, enables researchers to inject custom scripts into running processes, hook arbitrary functions, and modify their behavior or inspect data in real-time. Ghidra, developed by the NSA, is a free and open-source reverse engineering suite that provides disassembling, decompiling, graphing, and scripting capabilities to analyze binaries. Together, they form a formidable duo for tackling complex NDK reverse engineering challenges.
Setting Up Your Reverse Engineering Environment
Prerequisites
- An Android device or emulator running a rooted OS (Magisk is highly recommended for rooting).
- ADB (Android Debug Bridge) installed and configured on your host machine.
- Frida tools installed on your host machine (
pip install frida-tools). - Ghidra installed on your host machine.
- A sample Android NDK application. For demonstration purposes, you can create a simple app with a native library or use an existing one from a CTF challenge.
Installing Frida Server on Your Android Device
First, you need to download the appropriate Frida server for your device’s architecture (e.g., frida-server-16.x.x-android-arm64 for a 64-bit ARM Android device) from the Frida releases page. Then, push it to your device and start it:
adb push frida-server /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
Verify it’s running by executing frida-ps -U on your host. You should see a list of running processes on your device.
Unveiling Native Functions with Ghidra
Ghidra is essential for static analysis, allowing us to understand the native library’s structure, identify functions, and determine their memory offsets.
Acquiring the Native Library
To analyze an application’s native library, you first need to extract it from the device:
- Identify the package name of your target application (e.g.,
com.example.mynativeapp). - Locate the installed application’s base directory:
adb shell pm path com.example.mynativeapp(This will give you something likepackage:/data/app/com.example.mynativeapp-abcdef123==/base.apk) - Extract the
.sofile. The native libraries are typically located within thelibsubdirectory of the app’s installation path. For a 64-bit ARM device, it would be in/data/app/[PACKAGE_NAME]-[ID]/lib/arm64/libyournativeapp.so.adb pull /data/app/com.example.mynativeapp-abcdef123==/lib/arm64/libyournativeapp.so .
Loading and Initial Analysis in Ghidra
- Open Ghidra and create a new project.
- Import the
libyournativeapp.sofile into your project. - Upon import, Ghidra will prompt you to analyze the binary. Accept the default analysis options.
- Navigate to the “Symbol Tree” window. Here, you’ll find exported functions (those visible to the linker and typically called from Java via JNI). Functions starting with
Java_usually represent the JNI interface between Java and native code (e.g.,Java_com_example_mynativeapp_MainActivity_stringFromJNI). - Beyond exported functions, use Ghidra’s decompiler (the “Decompile” window) and cross-referencing capabilities to identify interesting internal functions. For instance, you might find a function named
validate_pinorcheck_licensethat isn’t exported. Note its memory address or, more importantly, its offset from the base address of the library. For example, if Ghidra shows a function at0x00001234, this is its offset from the library’s load address.
Dynamic Hooking with Frida: Techniques and Examples
Once you’ve identified target functions using Ghidra, Frida allows you to intercept and manipulate them at runtime.
Hooking Exported Functions
Exported functions are straightforward to hook using their symbolic names.
// hook_exported.js
Interceptor.attach(Module.findExportByName("libyournativeapp.so", "Java_com_example_mynativeapp_MainActivity_stringFromJNI"), {
onEnter: function (args) {
console.log("[+] Entering stringFromJNI");
// args[0] is JNIEnv*, args[1] is JClass, args[2] is JString for example
// JNI strings need to be converted to C strings if you want to read them
// var jni_string = new NativePointer(args[2]);
// var c_string = Jni.api.GetStringUTFChars(this.env, jni_string, null).readCString();
// console.log(" JNI String argument: " + c_string);
},
onLeave: function (retval) {
console.log("[-] Exiting stringFromJNI, original retval: " + retval);
// You can modify the return value here, e.g., retval.replace(ptr('0x0'));
}
});
console.log("Frida script loaded: Hooking JNI stringFromJNI function.");
To run this script:
frida -U -l hook_exported.js -f com.example.mynativeapp --no-pause
The -f flag spawns the app, --no-pause ensures it runs immediately.
Hooking Internal (Non-Exported) Functions by Address
For functions not exposed via JNI or exported symbols, you’ll use the base address of the library combined with the offset obtained from Ghidra.
// hook_internal.js
// Replace 0x1234 with the actual offset found in Ghidra for your target function
var targetOffset = 0x1234;
var baseAddr = Module.findBaseAddress("libyournativeapp.so");
if (baseAddr) {
var targetFunctionAddress = baseAddr.add(targetOffset);
console.log("[+] Target function address: " + targetFunctionAddress);
Interceptor.attach(targetFunctionAddress, {
onEnter: function (args) {
console.log("[+] Entering internal_validate_pin");
// For ARM64, args[0] typically corresponds to x0, args[1] to x1, etc.
// Assuming the first argument is a C-style string PIN
console.log(" Original PIN argument (x0): " + args[0].readCString());
// Modify argument to bypass authentication (e.g., change the PIN to "0000")
args[0].writeUtf8String("0000");
console.log(" PIN argument modified to: " + args[0].readCString());
},
onLeave: function (retval) {
console.log("[-] Exiting internal_validate_pin, original retval: " + retval);
// Force the return value to indicate success (e.g., 1 for true)
retval.replace(ptr(1));
console.log("[-] Exiting internal_validate_pin, modified retval: " + retval);
}
});
console.log("Frida script loaded: Hooking internal_validate_pin function.");
} else {
console.log("[-] Could not find base address for libyournativeapp.so");
}
Run this with:
frida -U -l hook_internal.js -f com.example.mynativeapp --no-pause
Handling Function Signatures and Calling Conventions
When hooking internal functions, understanding the calling convention (e.g., ARM64 ABI) is crucial for correctly interpreting and modifying arguments. For ARM64, the first eight integer arguments are passed in registers x0 through x7, and floating-point arguments in s0 through s7 (or d0 through d7). Frida’s args[n] directly maps to these, but you can also access them via this.context.x0, this.context.x1, etc., for more granular control, especially if dealing with complex structs or specific register manipulation.
Spawning and Attaching to Processes
- Spawning: Use
frida -U -f [PACKAGE_NAME] -l script.js --no-pauseto launch the application with your script injected. - Attaching: If the application is already running, use
frida -U [PACKAGE_NAME] -l script.jsto attach your script to the live process. This is useful for debugging issues that occur after app startup.
Advanced Techniques and Considerations
Bypassing Anti-Tampering Measures
Many applications implement anti-tampering checks, such as integrity verification of their native libraries, detection of debuggers, or checks for a rooted environment. Frida can often bypass these by hooking the relevant system calls or internal functions responsible for these checks and modifying their return values to always indicate a
Android Mobile Specs & Compare Directory
Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!
Compare Devices Specs →