Introduction to Frida ARM64 Hooking on Android
Frida, a dynamic instrumentation toolkit, is indispensable for security researchers and penetration testers. While much focus is often placed on Java-layer Android hooking, understanding and manipulating native libraries (NDK and system libraries) is crucial for deeper analysis, especially given the performance benefits and security-critical operations often implemented in C/C++. This guide dives into advanced Frida techniques specifically tailored for ARM64 Android environments, demonstrating how to effectively hook both exported and unexported functions within native libraries.
Android applications frequently leverage the Native Development Kit (NDK) to execute performance-intensive tasks, interact directly with hardware, or implement security features in C/C++ native libraries (.so files). Bypassing client-side security controls often requires going beyond the Java layer and into this native code. Understanding ARM64 architecture and calling conventions is paramount for successful native hooking.
Prerequisites and Setup
Before we begin, ensure you have the following:
- A rooted Android device or emulator (API 23+ recommended)
- ADB (Android Debug Bridge) installed and configured on your host machine
- Python 3 and
pipinstalled on your host machine - Basic familiarity with ARM64 assembly (registers, calling conventions)
- Tools like Ghidra or IDA Pro for static analysis (optional but highly recommended for unexported functions)
Frida Environment Setup
- Install Frida Tools on Host:
pip3 install frida-tools - Download Frida Server: Navigate to the Frida releases page and download the appropriate
frida-serverfor your device’s architecture (e.g.,frida-server-x.y.z-android-arm64). - Push Frida Server to Device:
adb push /path/to/frida-server-x.y.z-android-arm64 /data/local/tmp/frida-server - Set Permissions and Run:
adb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"Confirm Frida is running by executing
frida-ps -Uon your host.
Understanding ARM64 Native Libraries and Calling Conventions
When an Android app loads a native library, it typically uses System.loadLibrary() or System.load(). These libraries are ELF (Executable and Linkable Format) files. On ARM64 (AArch64), parameters are passed to functions primarily via registers x0 through x7. Additional parameters are pushed onto the stack. The return value is typically in x0.
Identifying Native Functions
Before hooking, you need to know what to hook. You can identify exported functions using various methods:
- Static Analysis with
readelf/nm:adb shell "find /data/app -name "*.so"" # Find target .so fileadb pull /path/to/target.so .readelf -s target.so | grep FUNCnm -D target.so | grep T # Only global/dynamic symbols - Dynamic Enumeration with Frida:
// script.jsModule.enumerateExportsSync('libc.so').forEach(function(exp) { if (exp.type === 'function') { console.log('Exported function: ' + exp.name + ' at ' + exp.address); }});
frida -U -l script.js -f com.target.app --no-pause
This will list all exported functions from libc.so, including common ones like strlen, malloc, etc.
Basic Native Hooking: Exported Functions
Let’s start by hooking a simple, well-known exported function: strlen from libc.so. This function takes one argument (a pointer to a string) and returns its length.
// hook_strlen.jsvar libc = Module.findBaseAddress('libc.so');if (libc) { var strlenPtr = Module.findExportByName('libc.so', 'strlen'); if (strlenPtr) { console.log('Hooking strlen at: ' + strlenPtr); Interceptor.attach(strlenPtr, { onEnter: function(args) { // args[0] holds the pointer to the string this.str = args[0].readUtf8String(); console.log('[+] strlen called with: "' + this.str + '"'); }, onLeave: function(retval) { console.log('[-] strlen returned: ' + retval.toInt32()); // Example: modify return value for strings matching a condition if (this.str && this.str.includes('secret')) { console.log(' Modifying strlen return for "secret" string!'); retval.replace(ptr(100)); // Force length to 100 } } }); } else { console.log('strlen not found in libc.so'); }} else { console.log('libc.so not found');}
frida -U -l hook_strlen.js -f com.target.app --no-pause
In onEnter, args[0] directly references the first argument passed to the function, which in ARM64 is held in register x0. Similarly, retval in onLeave corresponds to the return value, typically in x0 for ARM64 functions.
Advanced Hooking: Internal/Unexported Functions
Many critical functions are not exported. To hook these, you need to find their memory address relative to the library’s base address. This usually involves static analysis.
Finding Offsets with Static Analysis (Ghidra/IDA Pro)
- Pull the target
.sofile from the device. - Open it in Ghidra or IDA Pro.
- Identify the target function and its offset from the library’s base address (usually the first instruction’s address, or
0x0if the tool displays relative offsets). Let’s say we find an internal functiondo_something_secretat offset0x12345.
Hooking by Offset
Once you have the offset, you can calculate the absolute address at runtime:
// hook_unexported.jsvar targetLibName = 'libnative-lib.so'; // Replace with your target libraryvar targetLib = Module.findBaseAddress(targetLibName);var secretFunctionOffset = 0x12345; // Replace with the actual offsetvar secretFunctionPtr;if (targetLib) { secretFunctionPtr = targetLib.add(secretFunctionOffset); console.log('Hooking unexported function at: ' + secretFunctionPtr); Interceptor.attach(secretFunctionPtr, { onEnter: function(args) { console.log('[+] do_something_secret called!'); // Access arguments: args[0] for x0, args[1] for x1, etc. // Example: read an integer from x0 var arg0_val = args[0].toInt32(); console.log(' Argument x0: ' + arg0_val); // Example: modify an argument (e.g., set x0 to 0) // args[0] = ptr(0); // This will change the argument value this.arg0_on_enter = arg0_val; // Store for onLeave }, onLeave: function(retval) { console.log('[-] do_something_secret returned: ' + retval.toInt32()); // Example: modify return value based on enter state if (this.arg0_on_enter === 1337) { retval.replace(ptr(9999)); } } });} else { console.log('Target library ' + targetLibName + ' not found.');}
Important ARM64 Register Note: When hooking unexported functions, especially if they are called directly or indirectly from Java via JNI, pay close attention to the calling convention. The first 8 arguments are passed in registers x0-x7. For example, if a native function is declared as void myNativeFunc(JNIEnv* env, jobject thiz, jint a, jstring b), then env will be in x0, thiz in x1, a in x2, and b in x3. Frida’s args array aligns with these registers.
Advanced Scenarios and Tips
- Hooking
JNI_OnLoad: This function is called when a native library is loaded and is often used to register native methods. Hooking it early can provide insights into method registrations.// Hook JNI_OnLoad from libnative-lib.soModule.set `JNI_OnLoad` interceptor here (example):var jniOnLoadPtr = Module.findExportByName('libnative-lib.so', 'JNI_OnLoad');if (jniOnLoadPtr) { Interceptor.attach(jniOnLoadPtr, { onEnter: function(args) { console.log('[+] JNI_OnLoad called for libnative-lib.so'); // args[0] is JNIEnv*, args[1] is void* reserved }, onLeave: function(retval) { console.log('[-] JNI_OnLoad returned: ' + retval.toInt32()); } });} - Memory Manipulation: Use
Memory.readByteArray(address, size)andMemory.writeByteArray(address, byteArray)to inspect and modify memory regions. - Bypassing Anti-Frida/Anti-Debugger Checks: Native code often implements checks for debuggers or instrumentation frameworks. These can involve checking
/proc/self/maps, ` /proc/self/status` for TracerPid, or specific instruction patterns. Identify these checks with static analysis and then use Frida to patch them out (e.g., usingMemory.patchCodeor by always returning 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 →