Introduction to JNI, Native Calls, and Frida on ARM64
Android applications often leverage Java Native Interface (JNI) to execute high-performance or platform-specific code written in C/C++. This native code can be a goldmine for security researchers, containing critical algorithms, obfuscated logic, or sensitive data handling. Analyzing these native libraries, especially on ARM64 architectures prevalent in modern Android devices, requires specialized tools and techniques. Frida, a dynamic instrumentation toolkit, stands out as an indispensable tool for runtime analysis, allowing us to hook, inspect, and modify native function calls without recompiling the application.
This guide will demystify JNI interactions and native function hooking on ARM64 using Frida, providing practical steps and code examples for Android app penetration testers.
The Java Native Interface (JNI) Primer
JNI is the bridge between Java and native code. When an Android app calls a native method, the Java Virtual Machine (JVM) loads a shared library (.so file) and looks for a corresponding native function. There are two primary ways to link Java methods to native functions:
- Dynamic Linking (Implicit): The most common method. Java declares a native method, and the JVM automatically resolves it to a C/C++ function following a specific naming convention:
Java_PackageName_ClassName_MethodName. For example,Java_com_example_app_NativeLib_performCalc. - Static Linking (Explicit): Native code explicitly registers methods using
RegisterNatives, typically within theJNI_OnLoadfunction. This allows for arbitrary native function names and is often used for obfuscation or more flexible linking.
Native functions called via JNI always receive at least two arguments: JNIEnv* env and jclass clazz (for static methods) or jobject thiz (for instance methods), followed by any user-defined arguments.
Frida Basics for Native ARM64 Hooking
Before diving into ARM64 specifics, let’s review essential Frida concepts for native hooking:
- Attach to Process: Start Frida and attach to the target Android application process.
frida -U -f com.example.app --no-pauserequire('frida-trace').setup({ ... }) - Module Enumeration: Identify loaded native libraries (
.sofiles) usingProcess.enumerateModules()orModule.findByName(). - Symbol Resolution: Find the address of exported functions within a module using
Module.findExportByName(moduleName, exportName). - Interceptor.attach: The core mechanism for hooking. It takes the function address and an object with
onEnterandonLeavecallbacks. These callbacks provide athiscontext, which includes CPU registers (e.g.,this.context.x0for ARM64) and stack information.
ARM64 Calling Convention Highlights
Understanding the ARM64 (AArch64) calling convention is crucial for correctly interpreting arguments and return values:
- Argument Passing: The first eight arguments (up to 64 bits each) are passed in registers
x0throughx7. Additional arguments are pushed onto the stack. - Return Value: The return value (up to 64 bits) is stored in register
x0. - Callee-Saved Registers: Registers
x19tox30must be preserved by the called function if they are used. - Link Register (LR): Register
x30holds the return address. - Stack Pointer (SP): Register
sppoints to the top of the stack.
When hooking native functions, you’ll primarily interact with x0-x7 to read or modify arguments and x0 to modify return values.
Practical Example: Hooking a Native Encryption Function
Let’s imagine an Android app uses a native library libnativecrypto.so with a JNI function Java_com_example_app_CryptoUtils_encryptData that internally calls native_aes_encrypt.
1. Identifying Native Functions
First, we need to locate our target functions. We can use Frida’s `Module.enumerateExports()` or `frida-trace`.
// Using Frida REPL or script to enumerate exportsfrida -U -f com.example.app --no-pauserequire('frida-trace').setup({ decorate: function(exports) { return ['*!*encrypt*']; // Trace all exports containing 'encrypt' }});
Alternatively, if you have the .so file, use nm -D libnativecrypto.so to list exported symbols, or analyze with Ghidra/IDA Pro to find internal function names and offsets.
2. Hooking a JNI-Exported Function
Let’s hook Java_com_example_app_CryptoUtils_encryptData, which takes JNIEnv*, jclass, jbyteArray (input data), and jbyteArray (key).
// frida_jni_hook.jsAgent.onRuntimeInitialized = function() { const targetModule = Module.findByName("libnativecrypto.so"); if (!targetModule) { console.error("libnativecrypto.so not found!"); return; } const encryptDataPtr = targetModule.findExportByName("Java_com_example_app_CryptoUtils_encryptData"); if (!encryptDataPtr) { console.error("Java_com_example_app_CryptoUtils_encryptData not found!"); return; } console.log("[*] Hooking Java_com_example_app_CryptoUtils_encryptData at " + encryptDataPtr); Interceptor.attach(encryptDataPtr, { onEnter: function (args) { console.log("n[!] Inside Java_com_example_app_CryptoUtils_encryptData"); // JNIEnv* env = args[0] // jclass clazz = args[1] // jbyteArray data = args[2] // jbyteArray key = args[3] this.data = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[2], null).readByteArray(Java.vm.getEnv().getArrayLength(args[2])) : Memory.readByteArray(args[2].add(0x10), 256); // Simplified access this.key = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[3], null).readByteArray(Java.vm.getEnv().getArrayLength(args[3])) : Memory.readByteArray(args[3].add(0x10), 32); // Simplified access console.log(" Original Data: " + hexdump(this.data, { length: Math.min(this.data.byteLength, 32) })); console.log(" Encryption Key: " + hexdump(this.key, { length: Math.min(this.key.byteLength, 32) })); }, onLeave: function (retval) { // retval is jbyteArray for the encrypted data let encryptedResult = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(retval, null).readByteArray(Java.vm.getEnv().getArrayLength(retval)) : Memory.readByteArray(retval.add(0x10), 256); // Simplified console.log(" Encrypted Result: " + hexdump(encryptedResult, { length: Math.min(encryptedResult.byteLength, 32) })); // Optionally modify return value: // Memory.writeUtf8String(retval, "MODIFIED_RESULT"); } });};
Explanation of `onEnter` arguments: On ARM64, `args[0]` maps to register `x0`, `args[1]` to `x1`, and so on. In our JNI function, `args[0]` is `JNIEnv*`, `args[1]` is `jclass`, `args[2]` is the `jbyteArray` for data, and `args[3]` is `jbyteArray` for the key. Reading `jbyteArray` contents from a `jobject` requires using `JNIEnv` methods, which can be accessed via `Java.vm.getEnv()`. The simplified `Memory.readByteArray(args[X].add(0x10), …)` is a common heuristic for direct memory access if the `jbyteArray` object pointer itself contains a pointer to the actual array data at a fixed offset, but using `JNIEnv` methods is more robust.
3. Hooking an Internal Native Function (ARM64 Register Awareness)
Now, let’s assume `Java_com_example_app_CryptoUtils_encryptData` internally calls `native_aes_encrypt(char* data, int dataLen, char* key, int keyLen)`. This function is not exported via JNI but is a regular C function. We would typically find its address via reverse engineering (Ghidra/IDA) or `frida-trace` showing calls from the JNI function.
// Continuing frida_jni_hook.jsconst nativeAesEncryptPtr = targetModule.base.add(0x12345); // Replace 0x12345 with actual offset from Ghidra/IDAif (!nativeAesEncryptPtr) { console.error("native_aes_encrypt not found!"); return;}console.log("[*] Hooking native_aes_encrypt at " + nativeAesEncryptPtr);Interceptor.attach(nativeAesEncryptPtr, { onEnter: function (args) { console.log("n[!] Inside native_aes_encrypt"); // ARM64 calling convention: // x0 = char* data // x1 = int dataLen // x2 = char* key // x3 = int keyLen this.dataPtr = args[0]; this.dataLen = args[1].toInt32(); this.keyPtr = args[2]; this.keyLen = args[3].toInt32(); console.log(" Data Buffer Address (x0): " + this.dataPtr); console.log(" Data Length (x1): " + this.dataLen); console.log(" Key Buffer Address (x2): " + this.keyPtr); console.log(" Key Length (x3): " + this.keyLen); // Read contents from memory addresses console.log(" Data: " + hexdump(Memory.readByteArray(this.dataPtr, Math.min(this.dataLen, 32)))); console.log(" Key: " + hexdump(Memory.readByteArray(this.keyPtr, Math.min(this.keyLen, 32)))); // Example: modify input data // Memory.writeUtf8String(this.dataPtr, "MODIFIED_INPUT_DATA"); }, onLeave: function (retval) { // x0 contains the return value (e.g., pointer to encrypted data or status code) console.log(" native_aes_encrypt Returned (x0): " + retval); // If retval is a pointer to the result, you can read it: // console.log(" Encrypted Result from native_aes_encrypt: " + hexdump(Memory.readByteArray(retval, 32))); }});
Explanation of `onEnter` arguments for internal functions: Here, args[0] directly corresponds to the first function argument (char* data) passed in x0, args[1] to `x1` (int dataLen), and so on. We use `toInt32()` for `dataLen` and `keyLen` because `args` elements are `NativePointer` objects, and an `int` would be stored as a 64-bit value in the register. `Memory.readByteArray` is used to inspect the buffer contents directly from the pointers.
Conclusion
Frida provides an unparalleled capability to inspect and manipulate native code at runtime, a critical skill for Android app penetration testing. By understanding JNI’s interaction with native libraries and the ARM64 calling convention, you can accurately identify target functions, interpret arguments passed in registers, and effectively hook internal logic. Whether it’s to bypass obfuscation, understand proprietary algorithms, or discover vulnerabilities, mastering Frida for native analysis on ARM64 empowers you to delve deeper into the heart of Android applications.
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 →