Introduction: Unveiling Native Secrets with Frida on ARM64
Android applications often rely on native libraries (written in C/C++ and compiled into .so files) for performance-critical operations, obfuscation, or interacting with system functionalities not exposed through Java APIs. For security researchers, penetration testers, and reverse engineers, understanding and manipulating these native functions at runtime is paramount. Frida, a dynamic instrumentation toolkit, stands out as an indispensable tool for this purpose. This article will guide you through crafting powerful Frida scripts specifically tailored for dynamic analysis of Android ARM64 native functions, from identifying targets to modifying their behavior.
Prerequisites and Setup
Before diving into the code, ensure you have the following:
- An Android device or emulator (rooted) with ARM64 architecture.
- Frida server running on the Android device.
- Frida client (Python environment) on your host machine.
- Basic familiarity with C/C++ and ARM64 assembly concepts.
- Optional: Disassembler/decompiler like IDA Pro or Ghidra for static analysis.
Setting Up Frida
1. Install Frida on your host:
pip install frida-tools
2. Download Frida server for your Android device: Navigate to Frida’s GitHub releases, find the latest version, and download the frida-server-*-android-arm64 file.
3. Push and run Frida server on your device:
adb push frida-server /data/local/tmp/frida-serveradbshell "chmod 755 /data/local/tmp/frida-server"adbshell "/data/local/tmp/frida-server &"
Verify it’s running by executing frida-ps -U on your host. If you see a list of processes, you’re good to go.
Identifying Native Functions for Hooking
The first step in any dynamic analysis is to identify your targets. Native functions can be categorized into exported (visible in the symbol table) and unexported (internal, only discoverable through static analysis or runtime observation).
Exported Functions
Exported functions are the easiest to hook. You can find them using tools like nm or readelf on the .so file, or dynamically using Frida itself.
Using nm on the device:
adbshell "nm -D /data/app/~~.../com.example.app-XYZ/lib/arm64/libnative-lib.so | grep ' T '"
The output will show symbols marked with ‘T’ (text/code segment), indicating functions.
Unexported Functions and Offsets
Many interesting functions are not exported. To hook these, you need their memory offset relative to the base address of their containing library. This often requires static analysis with a disassembler/decompiler:
- Load the
.sofile into IDA Pro or Ghidra. - Identify the target function (e.g., based on cross-references, strings, or code logic).
- Note its virtual address (VA).
- Subtract the library’s base address (usually
0x0or0x1000in static analysis) from the function’s VA to get the offset.
Frida’s Core Hooking Mechanisms
Frida provides two primary ways to hook native functions:
1. Hooking Exported Functions with Module.findExportByName
This method is straightforward. You provide the library name and the function’s exported name.
// my_exported_native_func.jsJava.perform(function () { var libName = "libnative-lib.so"; var funcName = "Java_com_example_app_NativeLib_add"; // Example: JNI function var targetModule = Module.findBaseAddress(libName); if (targetModule) { var targetFunction = Module.findExportByName(libName, funcName); if (targetFunction) { console.log("Hooking exported function: " + funcName + " at " + targetFunction); Interceptor.attach(targetFunction, { onEnter: function (args) { console.log("[" + funcName + "] Called from: " + DebugSymbol.fromAddress(this.returnAddress)); console.log("[" + funcName + "] Argument 0 (x0): " + args[0].toInt32()); console.log("[" + funcName + "] Argument 1 (x1): " + args[1].toInt32()); // ARM64 calling convention: x0-x7 for integer/pointer arguments }, onLeave: function (retval) { console.log("[" + funcName + "] Return value: " + retval.toInt32()); // Modify return value if needed // retval.replace(ptr(1337)); } }); } else { console.log("Function " + funcName + " not found in " + libName); } } else { console.log("Module " + libName + " not found."); }});
To run this script:
frida -U -l my_exported_native_func.js com.example.app
2. Hooking Unexported Functions by Offset with Module.base.add
Once you have the offset, you can calculate the absolute memory address by adding it to the library’s base address at runtime.
// my_unexported_native_func.jsJava.perform(function () { var libName = "libnative-lib.so"; var targetOffset = new NativePointer("0x1234"); // Replace with actual offset from Ghidra/IDA var targetModule = Module.findBaseAddress(libName); if (targetModule) { var targetFunction = targetModule.add(targetOffset); console.log("Hooking unexported function at base + offset: " + targetFunction); Interceptor.attach(targetFunction, { onEnter: function (args) { console.log("[Unexported Func] Called from: " + DebugSymbol.fromAddress(this.returnAddress)); console.log("[Unexported Func] x0: " + args[0]); console.log("[Unexported Func] x1: " + args[1]); // Modify arguments, e.g., to bypass a check // args[0] = ptr(1); }, onLeave: function (retval) { console.log("[Unexported Func] Original return: " + retval); // Force a successful return retval.replace(ptr(1)); // For a boolean return, 1 often means true } }); } else { console.log("Module " + libName + " not found."); }});
Remember to replace 0x1234 with the actual offset you found.
Understanding ARM64 Calling Convention for Argument Inspection
A crucial aspect of native hooking on ARM64 is understanding its calling convention. This dictates how arguments are passed and return values are handled. For AArch64 (ARM64):
- Integer/Pointer Arguments: The first eight arguments are passed in registers
x0throughx7. Additional arguments are pushed onto the stack. - Floating-Point Arguments: Passed in registers
v0throughv7. - Return Value: Integer/pointer return values are typically stored in
x0. Floating-point return values are inv0.
When you access args[0], args[1], etc., in your Frida script’s onEnter callback, Frida conveniently maps these to the correct registers (x0, x1, etc.) or stack locations for you. However, knowing the underlying convention helps immensely when debugging or dealing with complex function signatures.
Practical Example: Bypassing a License Check
Let’s imagine an Android app has a native function checkLicense() that returns 0 for an invalid license and 1 for a valid one. This function is not exported. After static analysis, we find it at offset 0x5F30 within libappcore.so.
// bypass_license.jsJava.perform(function () { var libName = "libappcore.so"; var licenseCheckOffset = new NativePointer("0x5F30"); var targetModule = Module.findBaseAddress(libName); if (targetModule) { var licenseCheckFunction = targetModule.add(licenseCheckOffset); console.log("[Frida] Hooking license check function at: " + licenseCheckFunction); Interceptor.attach(licenseCheckFunction, { onEnter: function (args) { console.log("[Frida] License check called. Context: " + JSON.stringify(this.context)); // You could inspect args here if the function took parameters }, onLeave: function (retval) { console.log("[Frida] Original license check return: " + retval.toInt32()); // Force the return value to 1 (true) retval.replace(ptr(1)); console.log("[Frida] Modified license check return to: 1"); } }); } else { console.log("[Frida] Module " + libName + " not found."); }});
Run this script while the target application is active:
frida -U -l bypass_license.js com.example.app
Now, whenever the application calls checkLicense(), Frida will intercept it and force a successful return, effectively bypassing the license verification.
Advanced Considerations
- Inline Hooking: For complex scenarios or highly optimized functions,
Interceptor.replaceallows you to completely replace a function’s implementation with your own JavaScript or native code. - Stalker: Frida’s Stalker engine allows you to observe, record, and even modify the execution path of a thread, providing extremely granular control at the instruction level. This is powerful for tracing code execution.
- Memory Access: Use
Memory.readByteArray(),Memory.writeByteArray(),ptr(),NativePointer.read*(), andNativePointer.write*()to read from and write to arbitrary memory locations, enabling runtime patching or data extraction.
Conclusion
Dynamic analysis of Android ARM64 native functions with Frida is an incredibly powerful technique for reverse engineering, penetration testing, and security research. By understanding how to identify target functions (exported or by offset), leverage Frida’s hooking mechanisms, and account for the ARM64 calling convention, you can gain deep insights into an application’s native behavior and even alter its logic on the fly. This guide provides a solid foundation; the true power of Frida lies in creatively combining these techniques to solve specific analysis challenges.
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 →