Introduction to Frida and Native Hooking
Frida is a dynamic instrumentation toolkit that allows developers, reverse engineers, and security researchers to inject scripts into running processes. While powerful for Java/Kotlin bytecode manipulation in Android applications, its true strength for penetration testing often lies in its ability to interact with native (C/C++, ARM/ARM64) code. Many critical security checks, encryption routines, and anti-tampering mechanisms are implemented in native libraries to hinder reverse engineering. This masterclass will guide you through the process of identifying and hooking native ARM/ARM64 functions within Android libraries, providing practical examples and expert insights.
Why Native Hooking is Crucial
Android applications frequently leverage native libraries (.so files) for performance-critical operations, cross-platform compatibility, or to protect sensitive logic from easy decompilation. Bypassing root detection, certificate pinning, or obfuscated algorithms often requires interacting directly with these native functions. Frida’s Interceptor API, combined with an understanding of ARM/ARM64 assembly and calling conventions, makes this possible.
Prerequisites
- A rooted Android device or emulator with Frida Server running.
- ADB (Android Debug Bridge) installed and configured on your host machine.
- Basic familiarity with ARM/ARM64 assembly and calling conventions.
- Static analysis tools like Ghidra or IDA Pro for reverse engineering native libraries.
- Frida-tools installed on your host machine (
pip install frida-tools).
Step 1: Identifying the Target Native Function
Static Analysis (Ghidra/IDA Pro)
The first step is typically to reverse engineer the target native library (e.g., libnative-lib.so). Tools like Ghidra or IDA Pro allow you to disassemble the ARM/ARM64 code, analyze control flow, and identify functions of interest. Look for:
- Exported functions: Functions exposed via the dynamic symbol table, often prefixed with
Java_if called from Java. - Internal functions: Non-exported functions called by other functions within the library. You’ll need their offset from the library’s base address.
For example, you might find a function at offset 0x1A20 within libnative-lib.so responsible for a license check.
Dynamic Analysis (Frida)
Frida itself can help in discovery. If you suspect a function is exported, you can enumerate exports:
Frida.on('attach', function() { Module.findExportByName('libnative-lib.so', 'my_exported_func'); });
Or, to list all exports:
Process.enumerateModules().forEach(function(module) { if (module.name === 'libnative-lib.so') { console.log('Exports for ' + module.name + ':'); module.enumerateExports().forEach(function(exp) { console.log(' ' + exp.name + ' at ' + exp.address); }); } });
Step 2: Hooking Native ARM/ARM64 Functions
Method A: Hooking Exported Functions by Name
If your target function is exported (e.g., Java_com_example_app_NativeLib_decryptData), Frida makes hooking straightforward using Module.findExportByName.
Interceptor.attach(Module.findExportByName('libnative-lib.so', 'Java_com_example_app_NativeLib_decryptData'), { onEnter: function (args) { console.log('Entering Java_com_example_app_NativeLib_decryptData'); console.log('Arg 0 (JNIEnv*): ' + args[0]); console.log('Arg 1 (jobject this): ' + args[1]); console.log('Arg 2 (jbyteArray data): ' + args[2]); // Read jbyteArray - requires JNIEnv to call GetByteArrayElements }, onLeave: function (retval) { console.log('Leaving Java_com_example_app_NativeLib_decryptData, original return value: ' + retval); // Modify return value retval.replace(ptr('0x1')); // Example: force return 1 } });
Note that handling JNI arguments (like jstring, jbyteArray) often requires calling JNIEnv functions from your Frida script, which is an advanced topic in itself.
Method B: Hooking Internal Functions by Address (Offset)
Most interesting functions are internal and not exported. Here, you need to calculate the function’s absolute memory address at runtime.
1. Get the Library Base Address: When a library is loaded, its base address can vary due to ASLR (Address Space Layout Randomization).
let libNativeLib = Module.findBaseAddress('libnative-lib.so'); if (libNativeLib) { console.log('libnative-lib.so base address: ' + libNativeLib); } else { console.log('libnative-lib.so not found!'); }
2. Calculate the Target Address: Add the function’s static offset (found via Ghidra/IDA) to the dynamic base address.
let functionOffset = 0x1A20; // Example offset from Ghidra/IDA let targetFunctionAddress = libNativeLib.add(functionOffset); console.log('Target function address: ' + targetFunctionAddress);
3. Attach Interceptor: Now, attach to this calculated address.
Interceptor.attach(targetFunctionAddress, { onEnter: function (args) { console.log('Hooked function at ' + targetFunctionAddress + ' entered.'); // Access arguments based on ARM/ARM64 calling conventions // For ARM32: args[0]-args[3] are r0-r3, rest on stack // For ARM64: args[0]-args[7] are x0-x7, rest on stack console.log('Arg 0 (x0/r0): ' + args[0]); console.log('Arg 1 (x1/r1): ' + args[1]); // Example: read a string pointer if (args[0].isReadable()) { console.log('String at arg0: ' + args[0].readUtf8String()); } }, onLeave: function (retval) { console.log('Hooked function at ' + targetFunctionAddress + ' left. Original return value: ' + retval); // Example: always return 0 for a
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 →