Introduction to JNI Hooking with Frida
The Android Native Development Kit (NDK) allows developers to implement parts of their application using native code languages like C and C++. This native code often interacts with the Java/Kotlin layer through the Java Native Interface (JNI). From a security perspective, understanding and manipulating these JNI interactions is crucial for bypassing security controls, uncovering hidden logic, or even developing exploits. Frida, a dynamic instrumentation toolkit, provides powerful capabilities to hook into these native functions.
This masterclass will guide you through advanced JNI hooking techniques using Frida, covering everything from identifying native functions to intercepting specific JNIEnv calls. By the end, you’ll be equipped to analyze and manipulate almost any native Android code interaction.
Prerequisites
- A rooted Android device or emulator (e.g., AVD, Genymotion, Nox, Memu)
- Frida installed on your host machine and Frida-server running on your Android device.
- Basic familiarity with C/C++ and Android NDK concepts.
- Basic understanding of ARM/ARM64 assembly (helpful but not strictly required).
- Tools: Android Studio, adb, a text editor, and optionally, a disassembler like Ghidra or IDA Pro.
Understanding JNI Calls and Signatures
JNI acts as a bridge. Java methods can call native C/C++ functions, and native functions can, in turn, call Java methods or access Java fields. When Java calls a native function, the JNI linker uses a specific naming convention to resolve the call. For a Java method public native String myNativeMethod(int a, String b); in com.example.app.MyClass, the corresponding C/C++ function would typically be named Java_com_example_app_MyClass_myNativeMethod.
The signature of a JNI native function generally looks like this:
JNIEXPORT jstring JNICALL Java_com_example_app_MyClass_myNativeMethod(JNIEnv* env, jobject thiz, jint a, jstring b) { // ... native implementation}
JNIEnv* env: A pointer to the JNI environment, providing access to a large table of JNI functions (e.g.,NewStringUTF,CallObjectMethod).jobject thiz: A reference to the calling Java object (for non-static methods) or the class (for static methods).jint a, jstring b: The arguments passed from the Java side, mapped to their corresponding JNI types.
Identifying Native Functions for Hooking
1. Using `nm` or `readelf` for Exported Symbols
The simplest way to find native functions is by listing exported symbols from the .so library.
adb shellsu -c 'find /data/app -name "*libnative-lib.so"' # Find the library pathadb pull /data/app/com.example.app/lib/arm64/libnative-lib.so .nm -D libnative-lib.so | grep Java_
This will show you functions explicitly exported by the library, typically those directly callable from Java.
2. Dynamic Loading Information with Frida
Frida itself can help locate modules and their exports:
Process.enumerateModules().forEach(function(module) { if (module.name.includes('libnative-lib.so')) { console.log("[+] Found library: " + module.name + " at base: " + module.base); module.enumerateExports().forEach(function(exp) { if (exp.name.startsWith('Java_')) { console.log(" Exported JNI function: " + exp.name + " at RVA: " + exp.relativeAddress); } }); }});
3. Using Disassemblers (Ghidra/IDA Pro) for Unexported Functions
For more complex scenarios, especially when dealing with unexported internal native functions, a disassembler is indispensable. You can analyze the `.so` file, find the function’s entry point, and calculate its offset from the module’s base address.
// In Ghidra/IDA Pro, after loading libnative-lib.so// Locate desired function, e.g., 'sub_1234'// Note its address: 0x1234// Module base address is usually 0x0// Offset = 0x1234 - 0x0 = 0x1234
Frida JNI Hooking Techniques
1. Hooking Exported JNI Functions
This is the most straightforward method. We use Module.findExportByName to get a pointer to the function and then Interceptor.attach to hook it.
Consider a simple native method:
// Java/Kotlinpublic native String stringFromJNI();/* C++ */JNIEXPORT jstring JNICALL Java_com_example_app_MainActivity_stringFromJNI(JNIEnv* env, jobject /* this */) { return env->NewStringUTF("Hello from JNI!");}
Frida script to hook stringFromJNI:
// jni_hook_exported.jsInterceptor.attach(Module.findExportByName("libnative-lib.so", "Java_com_example_app_MainActivity_stringFromJNI"), { onEnter: function (args) { console.log("[+] Entering Java_com_example_app_MainActivity_stringFromJNI"); // 'args[0]' is JNIEnv*, 'args[1]' is jobject (this) // We can inspect args here if the function had parameters }, onLeave: function (retval) { // retval is jstring const jniEnv = this.context.x0; // x0 for ARM64, r0 for ARM32 const newStringUTFAddr = jniEnv.readPointer().add(0x350); // Offset for NewStringUTF (may vary) const readString = new NativeFunction(newStringUTFAddr, 'pointer', ['pointer', 'pointer']); const resultStringPtr = readString(jniEnv, retval); const resultString = resultStringPtr.readUtf8String(); console.log("[+] Original Return Value: " + resultString); // Modify the return value const newString = jniEnv.readPointer().add(0x1a8); // Example offset for NewStringUTF const NewStringUTF = new NativeFunction(newString, 'pointer', ['pointer', 'pointer']); const modifiedString = "Hooked by Frida!"; retval.replace(NewStringUTF(jniEnv, Memory.allocUtf8String(modifiedString))); console.log("[+] Modified Return Value to: " + modifiedString); }});
To run this:
frida -U -f com.example.app --no-pause -l jni_hook_exported.js
Note: The exact offsets for JNIEnv functions (like NewStringUTF) can vary between Android versions and architectures. A more robust way to get them is by hooking JNI_OnLoad or dynamically resolving the function table.
2. Hooking JNIEnv Functions (Advanced)
This technique allows you to intercept any call made by native code to a JNIEnv function (e.g., NewStringUTF, FindClass, CallObjectMethod). This is incredibly powerful as it gives you visibility into all native-to-Java interactions.
The JNIEnv pointer points to a table of function pointers. We need to get a hold of this table and replace specific entries.
A common strategy is to hook JNI_OnLoad, which is called when a native library is loaded. Inside JNI_OnLoad, we get a valid JNIEnv* and can then manipulate its function table.
// jni_env_hook.jsInterceptor.attach(Module.findExportByName("libnative-lib.so", "JNI_OnLoad"), { onEnter: function (args) { console.log("[+] Entering JNI_OnLoad"); const jniEnv = args[0]; // JNIEnv* this.jniEnv = jniEnv; }, onLeave: function (retval) { console.log("[+] JNI_OnLoad finished."); if (this.jniEnv) { const jniEnvPtr = this.jniEnv.readPointer(); // Pointer to JNIEnv table const GetStringUTFChars_offset = 0x368; // Example offset for GetStringUTFChars (ARM64) const NewStringUTF_offset = 0x350; // Example offset for NewStringUTF (ARM64) const CallObjectMethod_offset = 0x228; // Example offset for CallObjectMethod (ARM64) // Hook NewStringUTF const originalNewStringUTF = jniEnvPtr.add(NewStringUTF_offset).readPointer(); Interceptor.replace(originalNewStringUTF, new NativeCallback(function (env, bytes) { const result = this.call(env, bytes); const str = result.readUtf8String(); console.log(`[JNIEnv::NewStringUTF] Creating string: ${str}`); // You could modify 'str' here or return a different jstring return result; }, 'pointer', ['pointer', 'pointer'])); console.log("[+] Hooked JNIEnv::NewStringUTF!"); // Hook GetStringUTFChars const originalGetStringUTFChars = jniEnvPtr.add(GetStringUTFChars_offset).readPointer(); Interceptor.replace(originalGetStringUTFChars, new NativeCallback(function (env, jstr, isCopy) { const result = this.call(env, jstr, isCopy); const str = result.readUtf8String(); console.log(`[JNIEnv::GetStringUTFChars] Reading string: ${str}`); // You could modify 'str' here or return a different char* return result; }, 'pointer', ['pointer', 'pointer', 'pointer'])); console.log("[+] Hooked JNIEnv::GetStringUTFChars!"); // ... Add more hooks for other JNIEnv functions as needed } }});
Finding the exact offsets for JNIEnv functions requires inspecting the JNIEnv structure or its dispatch table. These offsets are generally consistent for a given Android version and architecture, but can be found dynamically (e.g., by observing a known JNI function call in a debugger or through reverse engineering the libart.so JNIEnv table definitions).
3. Hooking Unexported Native Functions by Offset
If a function is not exported, you can still hook it if you know its relative virtual address (RVA) or offset within the module.
// jni_hook_offset.js// Assuming 'secret_function' is at offset 0x1234 in libnative-lib.so (from Ghidra/IDA)const moduleName = "libnative-lib.so";const secretFunctionOffset = 0x1234; // Replace with actual offsetconst libNativeLib = Module.findByName(moduleName);if (libNativeLib) { const secretFunctionPtr = libNativeLib.base.add(secretFunctionOffset); Interceptor.attach(secretFunctionPtr, { onEnter: function (args) { console.log("[+] Entering secret_function at " + secretFunctionPtr); // Assuming signature: void secret_function(JNIEnv* env, jobject thiz, jstring some_arg) // args[0] = JNIEnv* // args[1] = jobject // args[2] = jstring if (args[2]) { const jniEnv = args[0]; const GetStringUTFCharsAddr = jniEnv.readPointer().add(0x368); // GetStringUTFChars offset const GetStringUTFChars = new NativeFunction(GetStringUTFCharsAddr, 'pointer', ['pointer', 'pointer', 'pointer']); const secretArg = GetStringUTFChars(jniEnv, args[2], NULL).readUtf8String(); console.log("[+] Secret argument: " + secretArg); } }, onLeave: function (retval) { console.log("[+] Exiting secret_function"); } }); console.log(`[+] Hooked unexported function at ${secretFunctionPtr}`);} else { console.log(`[-] Module ${moduleName} not found.`);}
Challenges and Tips
- JNI Signature Complexity: Correctly identifying JNI argument types (e.g.,
jstring,jint,jobjectArray) is crucial for accurate hooking and data extraction. Refer to the official JNI specification for type mappings. - Architecture Differences: Register usage (
r0-r3vs.x0-x3) and calling conventions differ between ARM32 and ARM64. Ensure your scripts are architecture-aware or use `this.context.x0` for ARM64 and `this.context.r0` for ARM32 when accessing arguments/return values from `onEnter`/`onLeave`. - Frida Stalker: For deeper analysis of control flow within native functions, consider using Frida Stalker to trace individual instructions.
- Anti-Frida Measures: Many applications implement anti-tampering checks, including detecting Frida. You might need to bypass these checks before your hooks can execute reliably. Common bypasses include unlinking Frida-related libraries, modifying `/proc` maps, or patching specific detection calls.
- JNIEnv Offsets: As mentioned, JNIEnv function offsets can change. Always verify them for your target device’s Android version and architecture.
Conclusion
Frida provides an unparalleled capability to delve into the native layers of Android applications. By mastering JNI hooking, you gain powerful control over critical application logic, enabling in-depth security analysis, reverse engineering, and dynamic manipulation of app behavior. Remember to practice these techniques responsibly and ethically.
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 →