Android System Securing, Hardening, & Privacy

Deep Dive: Hooking Android Native Functions with Frida (ARM64 & JNI Exploitation)

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to Dynamic Instrumentation with Frida

The Android ecosystem, with its blend of Java/Kotlin and native C/C++ components, presents unique challenges for security analysis and reverse engineering. While Dalvik/ART runtime provides a rich attack surface for Java-level instrumentation, understanding and manipulating the underlying native code – especially on ARM64 architectures – often requires more powerful tools. This is where Frida shines. Frida is a dynamic instrumentation toolkit that allows developers and security researchers to inject JavaScript snippets into arbitrary processes, providing unparalleled control over both Java/Kotlin and native (C/C++) functions.

This article will guide you through the process of hooking Android native functions using Frida, specifically focusing on ARM64 devices and exploiting the Java Native Interface (JNI). We’ll cover identifying native methods, basic function hooking, and advanced techniques like intercepting JNI_OnLoad to gain deep control over native library initialization.

Prerequisites and Setup

Before diving into the code, ensure you have the following setup:

  • A rooted Android device or emulator (API 23+ recommended).
  • ADB (Android Debug Bridge) installed and configured on your host machine.
  • Python 3 and frida-tools installed:
    pip install frida-tools

  • The appropriate Frida server for your Android device’s architecture (typically arm64 for modern devices). You can download it from the Frida releases page.

Setting Up Frida Server on Android

1. Push the Frida server to your device and set execute permissions:

adb push frida-server-x.x.x-android-arm64 /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server"

2. Start the Frida server in the background (you might need root privileges):

adb shell "/data/local/tmp/frida-server &"

Verify that Frida is running by listing processes:

frida-ps -U

You should see a list of processes running on your device.

Identifying Native Functions

Native functions in Android applications are typically found within .so (shared object) files. These libraries are loaded by the ART runtime when native methods are called or when System.loadLibrary() is invoked. To hook a native function, you first need to identify its symbol name and the library it belongs to.

Locating Libraries and Symbols

1. Pull the application’s native libraries from the device:

adb shell pm path com.your.package.name
# Example output: package:/data/app/com.your.package.name-xxxx/base.apk
adb pull /data/app/com.your.package.name-xxxx/base.apk
unzip base.apk -d base_apk_extracted
# Native libraries are usually in base_apk_extracted/lib/arm64-v8a/

2. Use tools like readelf or nm on your host machine to inspect the shared object files for exported symbols. For JNI functions, these often follow the naming convention Java_package_name_ClassName_methodName.

readelf -s base_apk_extracted/lib/arm64-v8a/libyourlib.so | grep Java_

This command will list all exported symbols, including JNI methods, from libyourlib.so.

Basic Native Function Hooking (ARM64)

Let’s consider a hypothetical scenario where an application uses a native function Java_com_example_app_NativeUtils_checkSignature to verify its integrity. We want to bypass this check.

Frida Script for Simple Native Hook

We’ll use Interceptor.attach() to hook the function. This method takes two arguments: the address of the function to hook and an object with onEnter and onLeave callbacks.

// hook_signature_check.js
Java.perform(function () {
var libName = "libnativeutils.so";
var funcName = "Java_com_example_app_NativeUtils_checkSignature";

var targetModule = Module.findExportByName(libName, funcName);
if (!targetModule) {
console.log("[-] Could not find export: " + funcName + " in " + libName);
return;
}

console.log("[+] Found function " + funcName + " at " + targetModule);

Interceptor.attach(targetModule, {
onEnter: function (args) {
console.log("[*] Entering " + funcName);
// args[0] is JNIEnv*, args[1] is Jobject (this)
// Further arguments depend on the native method's signature
// For example, if it takes a String, args[2] would be jstring.
// You'd typically need to read the actual arguments if you want to inspect them.
// Example: var signatureString = this.context.x2; // On ARM64, args are in x0-x7 registers
// However, JNIEnv* and Jobject are always the first two.
},
onLeave: function (retval) {
console.log("[*] Original return value: " + retval);
// Let's assume 0 means success for checkSignature
retval.replace(0);
console.log("[+] Tampered return value: " + retval);
}
});
});

Executing the Frida Script

frida -U -l hook_signature_check.js -f com.your.package.name --no-pause

This command injects the script into your application, spawns it if not running, and then applies the hook. When Java_com_example_app_NativeUtils_checkSignature is called, Frida will intercept it and modify its return value to 0, effectively bypassing the check.

JNI Exploitation: Intercepting JNI_OnLoad

The JNI_OnLoad function is a crucial entry point for native libraries. It’s called when the library is loaded, and it’s where the library typically registers its native methods and performs initial setup. Intercepting JNI_OnLoad gives you access to the JavaVM* and JNIEnv* pointers, which are essential for interacting with the JVM from native code.

Why Hook JNI_OnLoad?

  • Dynamic Method Registration: Many libraries register native methods dynamically using RegisterNatives within JNI_OnLoad. By hooking JNI_OnLoad, you can intercept these registrations or even replace them with your own.
  • Gaining JNIEnv*: Access to JNIEnv* allows you to call Java methods from your Frida script’s native context, create Java objects, and manipulate the Java heap directly.
  • Early Instrumentation: It allows for very early instrumentation of the native library’s lifecycle.

Frida Script for JNI_OnLoad Hook

This script shows how to hook JNI_OnLoad and extract valuable pointers. It also demonstrates how to get the current JNIEnv* and potentially call a Java method.

// hook_jni_onload.js
Java.perform(function () {
var libName = "libnative-lib.so"; // Replace with your target library
var jniOnLoadPtr = Module.findExportByName(libName, "JNI_OnLoad");

if (!jniOnLoadPtr) {
console.log("[-] JNI_OnLoad not found in " + libName);
return;
}

console.log("[+] Found JNI_OnLoad at " + jniOnLoadPtr);

Interceptor.attach(jniOnLoadPtr, {
onEnter: function (args) {
console.log("[+] Entering JNI_OnLoad in " + libName);
this.jvm = args[0]; // JavaVM* is the first argument
this.env = null;

// Get the JNIEnv* from JavaVM*
// On ARM64, args are passed in x0, x1, x2...
// The second argument to JNI_OnLoad is typically reserved (jint *reserved) and unused.
// To get JNIEnv*, we need to call GetEnv from JavaVM*
var vm_ptr = this.jvm;
var vm_functions = vm_ptr.readPointer();
var get_env_ptr = vm_functions.add(0x20).readPointer(); // Offset for GetEnv on ARM64 (might vary slightly)

// Define GetEnv function signature
var GetEnv = new NativeFunction(get_env_ptr, 'int', ['pointer', 'pointer', 'int']);
var jniEnvPtr = Memory.alloc(Process.pointerSize);
var JNI_VERSION_1_6 = 0x00010006;

var result = GetEnv(vm_ptr, jniEnvPtr, JNI_VERSION_1_6);

if (result === 0) { // JNI_OK
this.env = jniEnvPtr.readPointer();
console.log("[+] JNIEnv* obtained: " + this.env);

// Example: Using JNIEnv* to call a Java method
// This is advanced and requires knowing method IDs, etc.
// For simplicity, we'll just log that we have the env.
} else {
console.log("[-] Failed to get JNIEnv*. Result: " + result);
}
},
onLeave: function (retval) {
console.log("[+] Leaving JNI_OnLoad. Return value: " + retval);
// You could modify the JNI_OnLoad return value here if needed
// e.g., retval.replace(0x00010006); // JNI_VERSION_1_6
}
});
});

The offset for GetEnv (0x20) within the JavaVM function table is a common value but can occasionally differ slightly across Android versions or custom ROMs. For robust dynamic analysis, one might need to reverse engineer the JavaVM structure or dynamically scan for the GetEnv function signature.

Intercepting RegisterNatives

After hooking JNI_OnLoad and getting the JNIEnv*, you can then proceed to hook RegisterNatives, which is a method pointed to by JNIEnv*. This allows you to see or even modify which native functions are being registered and their corresponding method pointers.

// Inside onEnter of JNI_OnLoad hook, after getting this.env
// ...
if (this.env) {
console.log("[+] JNIEnv* obtained: " + this.env);

// Intercept RegisterNatives
var JNIEnv_functions = this.env.readPointer();
var RegisterNatives_ptr = JNIEnv_functions.add(0x400).readPointer(); // Common offset for RegisterNatives on ARM64

console.log("[+] RegisterNatives at " + RegisterNatives_ptr);

Interceptor.attach(RegisterNatives_ptr, {
onEnter: function (args) {
console.log("[*] Intercepting RegisterNatives!");
var env = args[0];
var javaClass = args[1]; // Jclass
var methods = args[2]; // JNINativeMethod*
var numMethods = args[3].toInt32(); // jint

console.log(" Class: " + env.getClassName(javaClass));
console.log(" Number of methods: " + numMethods);

for (var i = 0; i < numMethods; i++) {
var method = methods.add(i * Process.pointerSize * 3); // JNINativeMethod struct size
var name = method.readPointer().readUtf8String();
var signature = method.add(Process.pointerSize).readPointer().readUtf8String();
var fnPtr = method.add(Process.pointerSize * 2).readPointer();
console.log(" Name: " + name + ", Signature: " + signature + ", Function Pointer: " + fnPtr);
// You could modify fnPtr here to redirect to your own function
// method.add(Process.pointerSize * 2).writePointer(myHookedFuncPtr);
}
},
onLeave: function (retval) {
console.log("[*] Leaving RegisterNatives. Result: " + retval);
}
});
}

Again, the offset 0x400 for RegisterNatives is a common one but should be verified for specific JNIEnv implementations. It’s crucial to understand the JNINativeMethod structure (name, signature, fnPtr) for proper parsing.

Practical Use Cases and Further Exploration

The techniques discussed open doors to various advanced security analyses:

  • Anti-Tampering Bypass: Overriding native integrity checks, license validations, or root detection mechanisms.
  • Reverse Engineering Obfuscated Code: Intercepting the input/output of heavily obfuscated native functions to understand their logic without full disassembly.
  • Cryptographic Analysis: Hooking native cryptographic functions (e.g., in libssl.so or custom crypto libraries) to dump keys, IVs, or plaintext data.
  • Fuzzing Native Libraries: Modifying arguments to native functions to discover vulnerabilities.

For more advanced scenarios, consider:

  • Hooking Unexported Functions: Using Memory.scan with byte patterns to find and hook functions that are not exported by the library.
  • Inline Hooking: For complex hooks or very frequently called functions, inline hooking might be considered, though Frida’s Interceptor often handles this robustly under the hood.
  • Calling Native Functions: Using new NativeFunction(...) to call original or custom native functions from your Frida script.

Conclusion

Frida provides an incredibly powerful and flexible platform for dynamic instrumentation, allowing deep control over both Java/Kotlin and native code on Android devices. By mastering techniques like basic function hooking and intercepting crucial JNI lifecycle events like JNI_OnLoad, security researchers and developers can gain unprecedented visibility and control over an application’s native behavior. This deep dive should serve as a solid foundation for your journey into Android native exploitation with Frida on ARM64.

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 →
Google AdSense Inline Placement - Content Footer banner