Introduction
Android applications often leverage Java Native Interface (JNI) to interact with native C/C++ libraries. These native components are critical areas for security analysis, as they frequently contain performance-sensitive code, cryptographic operations, or obfuscated logic that can bypass Java-level protections. Frida, a dynamic instrumentation toolkit, is an indispensable tool for Android penetration testers looking to peer into these native layers. This guide will walk you through advanced Frida techniques to intercept the crucial JNI_OnLoad function and hook private (non-exported) native functions, providing a deeper understanding and control over an application’s native behavior.
Understanding JNI_OnLoad
When an Android application loads a native library (e.g., System.loadLibrary("mylib")), the Android system looks for and executes a function named JNI_OnLoad within that library. This function is typically where the library performs its initialization, including registering native methods with the Java Virtual Machine (JVM) using RegisterNatives. Intercepting JNI_OnLoad provides an early and powerful vantage point to observe which native methods are being registered and even to tamper with their registration process or arguments.
Prerequisites
- An Android device or emulator with root access.
- Frida server running on the Android device/emulator.
- Frida client installed on your host machine (
pip install frida-tools). - ADB (Android Debug Bridge) installed and configured.
- Basic familiarity with C/C++, JNI, and Android development concepts.
- Optional: Static analysis tools like Ghidra or IDA Pro for analyzing native libraries.
Hooking JNI_OnLoad with Frida
To hook JNI_OnLoad, we first need to identify the target native library. For this example, let’s assume our target library is named libnative-lib.so. We can find the address of JNI_OnLoad using Frida’s Module.findExportByName function.
Frida Script for JNI_OnLoad
Java.perform(function () {
var libraryName = "libnative-lib.so"; // Replace with your target library name
// Wait for the library to be loaded
Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), {
onEnter: function (args) {
this.library_path = Memory.readUtf8String(args[0]);
},
onLeave: function (retval) {
if (this.library_path.indexOf(libraryName) !== -1) {
console.log("[+] Target library loaded: " + this.library_path);
hookJniOnLoad(libraryName);
}
}
});
function hookJniOnLoad(libName) {
var jniOnLoadPtr = Module.findExportByName(libName, "JNI_OnLoad");
if (jniOnLoadPtr) {
console.log("[+] Found JNI_OnLoad at: " + jniOnLoadPtr);
Interceptor.attach(jniOnLoadPtr, {
onEnter: function (args) {
console.log("[*] JNI_OnLoad called!");
console.log(" -> JavaVM pointer: " + args[0]);
console.log(" -> Reserved (JNIEnv): " + args[1]);
// Optionally, peek into the JNIEnv structure to find RegisterNatives
// This is more advanced and requires parsing C structures in JS
},
onLeave: function (retval) {
console.log("[*] JNI_OnLoad finished.");
console.log(" -> Returned JNI version: " + retval.toInt32());
}
});
} else {
console.log("[-] JNI_OnLoad not found in " + libName);
}
}
});
Explanation:
- We first use a hook on
android_dlopen_ext(or__loader_dlopenfor older Android versions) to detect when our target librarylibnative-lib.sois loaded. This ensures ourJNI_OnLoadhook is applied at the correct time. - Once loaded,
hookJniOnLoadis called. Module.findExportByName(libName, "JNI_OnLoad")locates the entry point of theJNI_OnLoadfunction.Interceptor.attachis then used to intercept its execution, logging theJavaVM*and reserved arguments, and its return value (the JNI version).
Hooking Private Native Functions
Private native functions are those C/C++ functions within a native library that are not explicitly exported or registered with the JVM via RegisterNatives. They might be internal helper functions, complex algorithms, or core logic called by other exported JNI methods. Hooking these functions requires a different approach, often involving static analysis to find their exact memory offsets.
Method 1: Hooking Exported but Unregistered Functions
Sometimes, a function is exported from the shared library but not directly registered as a JNI method. You can find these using tools like nm -D on the library or readelf -s. If found, you can use Module.findExportByName similarly to JNI_OnLoad.
# On your host machine, pull the library from the device
adb pull /data/app/com.example.app/lib/arm64/libnative-lib.so
# Analyze exports
nm -D libnative-lib.so | grep "_Z" # For C++ mangled names
nm -D libnative-lib.so | grep "my_secret_func"
Once identified, the Frida script is straightforward:
// ... inside Java.perform ...
var secretExportedFuncPtr = Module.findExportByName("libnative-lib.so", "my_secret_exported_function");
if (secretExportedFuncPtr) {
Interceptor.attach(secretExportedFuncPtr, {
onEnter: function (args) {
console.log("[+] secret_exported_function called with arg0: " + args[0].toInt32());
},
onLeave: function (retval) {
console.log("[+] secret_exported_function returned: " + retval.toInt32());
}
});
}
Method 2: Hooking Non-Exported/Private Functions by Offset
This is the most common scenario for truly private functions. You will typically need to use static analysis tools like Ghidra or IDA Pro to find the relative offset of the function from the base address of the library.
Steps to Find Offset (using Ghidra as an example):
- Obtain the Native Library: Pull the target
.sofile from the Android device using ADB.adb pull /data/app/com.example.app/lib/arm64/libnative-lib.so . - Open in Ghidra: Launch Ghidra, create a new project, and import the
.sofile. Analyze it with default options. - Locate the Function: Navigate through the symbol tree or search for function names (e.g., in the Listing view, look for calls to your target function from exported JNI methods). Identify the function you want to hook.
- Determine Offset: Once you’ve located your target function (e.g.,
calculate_private_hash), observe its memory address in Ghidra. This address is relative to the start of the memory segment in the Ghidra view. The crucial part is to get the *relative offset* from the library’s base address. In Ghidra, this is usually the address shown for the function, minus the `0x100000000` (or similar, depending on load address) base of the ELF section. A simpler way is to look at the ‘Entry Point’ in the function’s properties or locate its address in the `Symbols` window. Let’s say Ghidra shows the function at `0x12345678`. If the base address of the `.text` segment starts at `0x100000000`, the offset would be `0x12345678 – 0x100000000`. More reliably, identify the function’s address in Ghidra and calculate the difference between that address and the *base address of the module as loaded by Ghidra*. Often, the base address of the `.text` segment in Ghidra starts at a fixed offset (e.g., 0x0 or 0x100000000 for PIE binaries), so the function’s address shown directly represents its offset from the library’s load address. For example, if a function is at `0x2120` in Ghidra’s disassembly of a PIE binary, `0x2120` is its offset.
Frida Script for Offset Hooking
Once you have the offset (e.g., 0x2120), you can combine Frida’s ability to find the base address of the loaded library with this offset.
Java.perform(function () {
var libraryName = "libnative-lib.so";
var privateFunctionOffset = 0x2120; // Replace with the actual offset found via static analysis
// Wait for the library to be loaded (same as JNI_OnLoad example)
Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), {
onEnter: function (args) {
this.library_path = Memory.readUtf8String(args[0]);
},
onLeave: function (retval) {
if (this.library_path.indexOf(libraryName) !== -1) {
console.log("[+] Target library loaded: " + this.library_path);
hookPrivateNativeFunction(libraryName, privateFunctionOffset);
}
}
});
function hookPrivateNativeFunction(libName, offset) {
var baseAddress = Module.findBaseAddress(libName);
if (baseAddress) {
var privateFuncPtr = baseAddress.add(offset);
console.log("[+] Hooking private function at calculated address: " + privateFuncPtr);
Interceptor.attach(privateFuncPtr, {
onEnter: function (args) {
console.log("[*] Private function called!");
console.log(" -> Argument 0: " + args[0].toInt32());
console.log(" -> Argument 1: " + Memory.readUtf8String(args[1]));
// Modify arguments if needed: args[0] = new NativePointer(123);
},
onLeave: function (retval) {
console.log("[*] Private function finished.");
console.log(" -> Original return value: " + retval.toInt32());
// Modify return value if needed: retval.replace(new NativePointer(456));
}
});
} else {
console.log("[-] Could not find base address for " + libName);
}
}
});
Explanation:
- Similar to
JNI_OnLoad, we wait for the library to be loaded. Module.findBaseAddress(libName)retrieves the actual runtime base address where the library is loaded into memory.- By adding our statically determined
offsetto this base address, we get the precise runtime address of the private function:baseAddress.add(offset). Interceptor.attachis then used to hook this specific address, allowing inspection and modification of arguments and return values.
Example C++ Native Library (native-lib.cpp)
#include <jni.h>
#include <string>
#include <android/log.h>
#define TAG "NativeLib"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
// A private helper function not exported or directly registered
int calculate_secret_value(int a, const char* msg) {
LOGD("[%s] calculate_secret_value called with %d and %s", TAG, a, msg);
return a * 2 + strlen(msg);
}
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_fridanatives_MainActivity_stringFromJNI(
JNIEnv* env, jobject /* this */) {
std::string hello = "Hello from C++";
int result = calculate_secret_value(10, "FridaTest");
hello += ", Secret Result: " + std::to_string(result);
return env->NewStringUTF(hello.c_str());
}
// JNI_OnLoad is called when the library is loaded
extern "C" JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
// Example: Registering a native method (though we use JNI_OnLoad for logging)
// This example focuses on hooking JNI_OnLoad itself rather than specific registrations
LOGD("[%s] JNI_OnLoad called! JVM: %p, Reserved: %p", TAG, vm, reserved);
return JNI_VERSION_1_6;
}
This C++ code defines a JNI_OnLoad function and a private function calculate_secret_value that is called by stringFromJNI. After compiling this into libnative-lib.so, you would use Ghidra to find the offset of calculate_secret_value.
Conclusion
Intercepting JNI_OnLoad and private native functions are powerful techniques for any Android penetration tester or reverse engineer. By understanding how native libraries initialize and how to locate functions not explicitly exposed, you gain unparalleled control and visibility into the deepest layers of an Android application’s logic. Frida’s dynamic instrumentation capabilities, combined with static analysis tools, provide a comprehensive toolkit for uncovering hidden behaviors and vulnerabilities within native code.
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 →