Introduction: The Challenge of Native Android Analysis
Modern Android applications frequently leverage native libraries (.so files) written in C/C++ for performance-critical operations, cryptographic functions, or to implement security-sensitive logic. These native binaries often present significant challenges for security researchers and penetration testers. They can be heavily obfuscated, stripped of symbols, and operate outside the direct visibility of Java/Kotlin-level debugging tools. Dynamic analysis, specifically using powerful instrumentation toolkits like Frida, becomes indispensable for peeling back these layers of obscurity.
This article provides an expert-level guide on utilizing Frida to dynamically analyze and hook ARM64 native functions within Android applications. We’ll focus on identifying non-exported, often hidden, APIs and understanding their runtime behavior, a critical skill for advanced Android app penetration testing.
Why Native APIs Are Often Hidden
Native functions in Android binaries are hidden for several reasons:
- Security by Obscurity: Developers might rely on the complexity of native code to obscure sensitive logic, assuming it’s harder to reverse engineer.
- Performance Optimization: Direct system calls or highly optimized algorithms are often implemented natively.
- IP Protection: Core business logic or proprietary algorithms might reside in native code to protect intellectual property.
- JNI Interface: While some native functions are exported via JNI (Java Native Interface), many internal helper functions are not, making them harder to discover without dynamic analysis.
Setting Up Your Dynamic Analysis Environment
Before diving into hooking, ensure you have the necessary setup:
- Rooted Android Device or Emulator: Necessary for pushing and running the Frida server.
- ADB (Android Debug Bridge): For communication with the device.
- Frida-server: Download the appropriate ARM64 version for your Android device from the official Frida releases page.
- Frida-tools: Install on your host machine via
pip install frida-tools.
Frida Server Setup:
adb push frida-server-<version>-android-arm64 /data/local/tmp/frida-serveradb shell 'chmod 755 /data/local/tmp/frida-server'adb shell '/data/local/tmp/frida-server &'
Verify the server is running by executing frida-ps -U on your host. You should see a list of processes from your device.
Identifying Target Functions: Static Analysis & Runtime Discovery
The first step in hooking a native function is knowing what to hook. This often involves a combination of static and dynamic approaches:
Static Analysis (e.g., Ghidra, IDA Pro):
Tools like Ghidra or IDA Pro are invaluable for disassembling .so libraries. Look for:
- JNI_OnLoad: This function is often the entry point for JNI registration and can reveal pointers to other interesting functions.
- Cross-references: Follow calls from exported JNI functions or interesting strings.
- Patterns: Identify function prologue/epilogue patterns to delineate functions, even if stripped. Look for specific instruction sequences (e.g.,
STP X29, X30, [SP, #-16]!andLDP X29, X30, [SP], #16for ARM64 function frames).
For functions without symbols, you’ll work with their relative virtual addresses (RVAs) or offsets from the module’s base address.
Runtime Discovery (Frida’s Module Enumeration):
You can use Frida to list all loaded modules and their exports:
Java.perform(function() { Process.enumerateModules().forEach(function(module) { console.log("Module: " + module.name + " Base: " + module.base + " Size: " + module.size); if (module.name.includes("libnative-lib.so")) { module.enumerateExports().forEach(function(exp) { console.log(" Export: " + exp.name + " Address: " + exp.address); }); } });});
This is useful for exported functions. For non-exported functions, static analysis is crucial to derive the offset.
Crafting the Frida ARM64 Hook
Frida’s Interceptor.attach() is the primary mechanism for hooking native functions. It allows you to execute JavaScript code before (onEnter) and after (onLeave) the original function call.
Locating the Function Address:
If the function is exported, use Module.findExportByName():
var targetModule = Module.findExportByName("libnative-lib.so", "Java_com_example_app_NativeLib_stringFromJNI");
If the function is not exported and you have an offset from static analysis, calculate the absolute address:
var baseAddress = Module.findBaseAddress("libnative-lib.so");var targetOffset = new NativePointer(0x1234); // Replace with your function's relative offset from static analysisvar targetFunction = baseAddress.add(targetOffset);console.log("Target function address: " + targetFunction);
Implementing the Interceptor:
The onEnter callback provides access to the function’s arguments via the `this.context.A0`, `this.context.A1`, etc. registers (for ARM64, R0-R7 for the first 8 arguments). The onLeave callback allows inspection of the return value (retval).
Interceptor.attach(targetFunction, { onEnter: function(args) { console.log('Hooked function entered!'); console.log('Argument 0 (X0): ' + args[0]); // Often JNIEnv* console.log('Argument 1 (X1): ' + args[1]); // Often jobject (this) or jclass // Assuming a function that takes a jstring (ptr to string) as its third argument if (args[2] != null) { // Read jstring content (requires JNIEnv methods or direct pointer manipulation) // For simplicity, let's assume it's a direct C string for now for illustrative purposes // In a real scenario, you'd call JNIEnv->GetStringUTFChars try { var cStringArg = args[2].readCString(); console.log('Argument 2 (X2) as C string: ' + cStringArg); } catch (e) { console.log('Could not read Argument 2 as C string: ' + e.message); } } // Store context or arguments if needed for onLeave this.arg2 = args[2]; }, onLeave: function(retval) { console.log('Hooked function leaving!'); console.log('Return Value (X0): ' + retval); // If the function returns a modified string, you might want to read it // e.g., if retval is a pointer to a C string try { var returnedString = retval.readCString(); console.log('Return Value as C string: ' + returnedString); } catch (e) { console.log('Could not read return value as C string: ' + e.message); } console.log('------------------------------'); }});
Interpreting Arguments and Return Values (ARM64 Specifics):
On ARM64, the first 8 arguments are passed in registers X0-X7. Subsequent arguments are passed on the stack. The return value is typically in X0. Understanding the calling convention (e.g., AAPCS64 for ARM64) is crucial. When dealing with JNI functions, the first argument (X0) is always a pointer to the JNIEnv interface, and the second (X1) is usually a jobject (the this object for non-static methods) or jclass (for static methods).
Reading specific types:
- Pointers:
args[N]directly gives you aNativePointer. - C Strings:
args[N].readCString(). - Java Strings (jstring): Requires using the
JNIEnvpointer to call functions likeGetStringUTFChars. This is more complex and beyond a simple direct read from the register, involving calling other native functions. - Integers/Longs: Often directly accessible from the register value, or use
.toInt32(),.toUInt32(),.toInt64(),.toUInt64()on theNativePointerif it represents a numeric value.
Step-by-Step Example: Uncovering a String Transformation API
Let’s assume we’ve identified through static analysis that libnative-lib.so at offset 0x4567 has a function that takes a C-style string, processes it, and returns a new C-style string.
1. Prepare Your Frida Script (hook.js):
Java.perform(function() { console.log("[*] Frida script loaded for native analysis."); var moduleName = "libnative-lib.so"; var targetOffset = 0x4567; // This offset would be found via Ghidra/IDA var baseAddress = Module.findBaseAddress(moduleName); if (!baseAddress) { console.error("[-] Module '" + moduleName + "' not found!"); return; } var targetFunctionAddress = baseAddress.add(targetOffset); console.log("[+] Hooking function at address: " + targetFunctionAddress); Interceptor.attach(targetFunctionAddress, { onEnter: function(args) { console.log('[*] Native API entered!'); // Assuming the function takes one C string argument at X0 (args[0]) // And that it's NOT a JNI function (no JNIEnv* or jobject/jclass) try { this.inputString = args[0].readCString(); console.log(' Input String (X0): ' + this.inputString); } catch (e) { console.log(' Could not read input string from X0: ' + e.message); } }, onLeave: function(retval) { console.log('[*] Native API leaving!'); try { var outputString = retval.readCString(); console.log(' Original Input: ' + this.inputString); console.log(' Returned String (X0): ' + outputString); } catch (e) { console.log(' Could not read return string from X0: ' + e.message); } console.log('------------------------------'); } }); console.log("[*] Hook deployed on '" + moduleName + "' at offset 0x" + targetOffset.toString(16) + ".");});
2. Run Frida:
frida -U -l hook.js com.example.yourandroidapp
Replace com.example.yourandroidapp with the actual package name of your target application.
3. Interact with the App & Observe Output:
Now, interact with your Android application in a way that you expect to trigger the native function. For instance, if the function is called when a specific button is pressed or input is entered, perform that action. The Frida console on your host machine will display the logged input and output strings, revealing the hidden API’s behavior.
Conclusion
Frida is an exceptionally powerful tool for dynamic analysis of Android applications, particularly when dealing with native ARM64 binaries. By mastering techniques for locating non-exported functions and crafting precise hooks, penetration testers and security researchers can effectively reverse engineer complex native logic, uncover hidden APIs, and gain a deeper understanding of an application’s inner workings, ultimately leading to more comprehensive security assessments.
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 →