Introduction to Native Function Hooking on Android with Frida
Frida is a dynamic instrumentation toolkit that allows developers and security researchers to inject JavaScript into running processes on various platforms, including Android. Its power lies in its ability to introspect, modify, and monitor applications at runtime without needing to recompile or repackage them. For Android Reverse Engineering (RE), Frida is an indispensable tool, especially when dealing with native code (JNI, C/C++) that often handles critical logic, sensitive data processing, or anti-tampering mechanisms.
Android applications frequently leverage the Java Native Interface (JNI) to bridge Java code with native libraries written in C/C++. This allows for performance-critical operations, access to low-level system APIs, or the reuse of existing C/C++ codebases. Hooking these native functions provides deep insights into an application’s behavior, allowing for bypassing security controls, modifying application flow, or understanding proprietary algorithms.
Prerequisites and Setup
Android Device Setup
To follow this guide, you will need:
- A rooted Android device or an emulator (e.g., Android Studio AVD, Genymotion) with root access enabled.
- Android Debug Bridge (ADB) installed and configured on your host machine.
- USB debugging enabled on your Android device/emulator.
Frida Environment Setup
On your host machine, install Frida tools via pip:
pip install frida-tools
On your Android device, you need to run the `frida-server`. Download the appropriate `frida-server` binary for your device’s architecture (e.g., `frida-server-*-android-arm64`) from the Frida releases page. Push it to your device and run it:
adb push frida-server /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"
Verify Frida is running by listing processes:
frida -U ps
Understanding JNI and Native Libraries
JNI functions in native libraries (`.so` files) typically follow a specific naming convention when they are exported to be callable from Java. For example, a Java method `public native String myNativeMethod(int arg);` in `com.example.app.MyClass` will correspond to a native function named `Java_com_example_app_MyClass_myNativeMethod__I` (the `__I` denotes an integer argument). You can find these signatures using `javap` on the compiled Java class file or by inspecting the native library’s exports.
Identifying Native Methods
To inspect a Java class for native method signatures:
javap -s -p MyNativeClass.class
This will reveal the full JNI signature, which is crucial for direct hooking.
Strategy for Hooking Native Functions
Frida offers powerful capabilities to hook functions within native libraries. The approach varies slightly depending on whether the target function is exported, is a JNI-specific function, or is an internal, non-exported C/C++ function.
1. Exported JNI Functions (Direct Hooking)
These are the functions directly exposed by the native library for Java interaction. Their names follow the `Java_PackageName_ClassName_MethodName` convention. These are the easiest to hook.
Java.perform(function () {
var lib = Module.findExportByName("libnative-lib.so", "Java_com_example_app_NativeLib_stringFromJNI");
if (lib) {
Interceptor.attach(lib, {
onEnter: function (args) {
console.log("[*] Hooked Java_com_example_app_NativeLib_stringFromJNI");
},
onLeave: function (retval) {
console.log("[*] Original return value: " + retval.readCString());
retval.replace(Memory.allocUtf8String("Hooked String!"));
console.log("[*] New return value: " + retval.readCString());
}
});
} else {
console.log("[-] Function not found.");
}
});
2. Exported C/C++ Functions
These are functions within the native library that are explicitly marked for export (e.g., using `extern “C”` or export directives in the build system). You can list exported symbols using tools like `nm` or `readelf` on the device:
adb shell nm -D /data/app/com.example.app-1/lib/arm64/libnative-lib.so | grep my_exported_c_func
Once you have the exact name, hooking is similar to JNI functions:
Java.perform(function () {
var myExportedFunc = Module.findExportByName("libnative-lib.so", "my_exported_c_func");
if (myExportedFunc) {
Interceptor.attach(myExportedFunc, {
onEnter: function (args) {
console.log("[*] my_exported_c_func called with arg: " + args[0].toInt32());
},
onLeave: function (retval) {
console.log("[*] my_exported_c_func returned: " + retval.toInt32());
retval.replace(1337); // Change return value
}
});
}
});
3. Internal (Non-Exported) C/C++ Functions
These are functions that are not directly exported by the library and thus cannot be found by `Module.findExportByName`. To hook them, you need to:
- Reverse engineer the native library (e.g., using Ghidra, IDA Pro, or Binary Ninja) to find the function’s relative offset from the library’s base address.
- At runtime, determine the library’s base address in memory.
- Calculate the absolute memory address of the target function: `base_address + offset`.
Java.perform(function () {
var moduleName = "libnative-lib.so";
var internalFuncOffset = new NativePointer(0x1234); // Replace with actual offset from Ghidra/IDA
var targetModule = Process.findModuleByName(moduleName);
if (targetModule) {
var internalFuncAddr = targetModule.base.add(internalFuncOffset);
console.log("[*] Internal function address: " + internalFuncAddr);
Interceptor.attach(internalFuncAddr, {
onEnter: function (args) {
console.log("[*] Internal function called!");
// Access args, e.g., args[0].readCString()
},
onLeave: function (retval) {
console.log("[*] Internal function returned.");
// Modify retval, e.g., retval.replace(0);
}
});
} else {
console.log("[-] Module " + moduleName + " not found.");
}
});
Practical Example: Hooking a Simple Native Function
Let’s imagine a simple Android app with a `libcalc.so` native library that contains a function `int calculateSum(int a, int b)` which is exposed via JNI as `Java_com_example_calc_NativeCalc_addNumbers`. The C++ implementation might look like this:
// calc.cpp
#include <jni.h>
#include <string>
#include <android/log.h>
#define APPNAME "NativeCalc"
extern "C" JNIEXPORT jint JNICALL
Java_com_example_calc_NativeCalc_addNumbers(JNIEnv* env, jobject /* this */, jint a, jint b) {
__android_log_print(ANDROID_LOG_INFO, APPNAME, "addNumbers called with: %d, %d", a, b);
return a + b;
}
// An internal, non-exported C++ function
int secretMultiply(int a, int b) {
__android_log_print(ANDROID_LOG_INFO, APPNAME, "secretMultiply called with: %d, %d", a, b);
return a * b;
}
Crafting the Frida Script
We’ll create a Frida script to hook `addNumbers` and observe its arguments and return value. We’ll also simulate finding an offset for `secretMultiply` for a more advanced hook.
// hook_calc.js
Java.perform(function () {
console.log("[*] Frida script started. Waiting for libcalc.so...");
var targetModule = null;
// Hook JNI_OnLoad to ensure lib is loaded, or find it directly if already loaded
var jniOnLoad = Module.findExportByName("libart.so", "JNI_OnLoad"); // Example for hooking JNI_OnLoad
if (jniOnLoad) {
Interceptor.attach(jniOnLoad, {
onEnter: function (args) {
// This JNIEnv* can be useful, but for simply waiting for module, not strictly needed
},
onLeave: function (retval) {
// The module might be loaded here, but it's often safer to poll or use 'onLoad' for specific modules
}
});
}
// Easier way: Directly find the module. If it's not loaded, use a scheduled check.
function hookNativeCalc() {
targetModule = Process.findModuleByName("libcalc.so");
if (targetModule) {
console.log("[+] Found libcalc.so at base address: " + targetModule.base);
// Hooking the JNI-exposed addNumbers function
var addNumbersPtr = Module.findExportByName("libcalc.so", "Java_com_example_calc_NativeCalc_addNumbers");
if (addNumbersPtr) {
console.log("[+] Hooking Java_com_example_calc_NativeCalc_addNumbers at " + addNumbersPtr);
Interceptor.attach(addNumbersPtr, {
onEnter: function (args) {
// args[0] is JNIEnv*, args[1] is jobject
this.a = args[2].toInt32();
this.b = args[3].toInt32();
console.log(" [.] addNumbers called with a=" + this.a + ", b=" + this.b);
},
onLeave: function (retval) {
console.log(" [.] addNumbers original return: " + retval.toInt32());
// Optionally modify the return value
retval.replace(ptr(this.a + this.b + 100)); // Add 100 to the original sum
console.log(" [.] addNumbers new return: " + retval.toInt32());
}
});
} else {
console.log("[-] Java_com_example_calc_NativeCalc_addNumbers not found.");
}
// Hooking an internal (non-exported) function: secretMultiply
// Assume we found offset 0xABCD in Ghidra for secretMultiply
var secretMultiplyOffset = new NativePointer(0xABCD); // Replace with actual offset
var secretMultiplyPtr = targetModule.base.add(secretMultiplyOffset);
// Verify the address points to a valid instruction (optional but good practice)
if (secretMultiplyPtr.readByteArray(4) !== null) { // Check if memory is readable
console.log("[+] Hooking secretMultiply at " + secretMultiplyPtr);
Interceptor.attach(secretMultiplyPtr, {
onEnter: function (args) {
this.a = args[0].toInt32(); // Assuming standard C calling convention
this.b = args[1].toInt32();
console.log(" [.] secretMultiply called with a=" + this.a + ", b=" + this.b);
},
onLeave: function (retval) {
console.log(" [.] secretMultiply original return: " + retval.toInt32());
retval.replace(ptr(1)); // Always return 1
console.log(" [.] secretMultiply new return: " + retval.toInt32());
}
});
} else {
console.log("[-] secretMultiply address appears invalid or unreadable.");
}
} else {
console.log("[-] libcalc.so not found yet. Retrying...");
setTimeout(hookNativeCalc, 1000); // Retry after 1 second
}
}
hookNativeCalc();
});
Executing the Hook
Save the above script as `hook_calc.js` and run it against your target Android application (replace `com.example.calc` with your app’s package name):
frida -U -f com.example.calc --no-pause -l hook_calc.js
Now, interact with your Android application. Every time `addNumbers` or `secretMultiply` is called, Frida will intercept it, log the arguments, and potentially modify the return value as defined in your script. Observe the output in your console.
Advanced Considerations and Best Practices
- Calling Conventions: Be mindful of architecture-specific calling conventions (e.g., ARM, ARM64) when accessing arguments in `onEnter`. The `args` array in Frida usually corresponds to registers or stack positions depending on the convention.
- Error Handling: Robust Frida scripts include checks for `null` pointers and handle cases where modules or functions are not found.
- Anti-Frida Measures: Modern applications employ anti-Frida techniques (e.g., checking for `frida-server` process, detecting hooked functions). Bypassing these often requires more sophisticated Frida techniques like injecting into `zygote` or using custom `gadget` payloads.
- Memory Access: Frida provides powerful `Memory` APIs for reading/writing arbitrary memory locations, useful for dumping data structures or modifying global variables.
- NativeCallback: For replacing a function entirely instead of just attaching, `NativeCallback` can be used to create a new native function in JavaScript.
Conclusion
Frida is an exceptionally versatile and potent tool for Android native reverse engineering. By understanding how to identify and hook JNI and C/C++ functions, both exported and internal, you gain unparalleled control and visibility into the deepest layers of an Android application’s logic. This capability is fundamental for security analysis, vulnerability research, and understanding complex, obfuscated software. Mastering Frida’s native hooking features is a critical skill for any serious Android security professional.
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 →