Introduction to Native Library Hooking on Android ARM64 with Frida
Android applications often rely on native libraries (typically .so files) written in C/C++ for performance-critical operations, cryptographic functions, or to obscure sensitive logic. Reverse engineering and manipulating these native components, especially on ARM64 architectures, is a crucial skill for security researchers and penetration testers. Frida, a dynamic instrumentation toolkit, provides unparalleled capabilities for this task. This guide will walk you through the process of mastering native library hooking on Android ARM64 using Frida, from identifying targets to intercepting and modifying function calls.
Prerequisites and Setup
Before diving into the code, ensure you have the following:
- An Android device or emulator (rooted) running an ARM64 architecture.
- ADB (Android Debug Bridge) installed and configured on your host machine.
- Python 3 installed on your host machine.
- Frida tools installed on your host:
pip install frida-tools
- Frida server pushed and running on your Android device. Download the correct ARM64 server from Frida Releases and push it:
adb push frida-server-/data/local/tmp/frida-serveradb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"
- A decompiler/disassembler like Ghidra or IDA Pro (optional but highly recommended for complex scenarios).
Understanding Android Native Libraries and ARM64
Native libraries interact with Java code via the Java Native Interface (JNI). When a Java method is declared native, its implementation resides in a .so file. On ARM64, the calling convention typically passes the first eight arguments in registers (x0 to x7) and subsequent arguments on the stack. The return value is usually in x0. Understanding this is key to correctly interpreting function arguments and return values in your Frida hooks.
Finding Your Target Function
Identifying the function you want to hook is the first critical step. There are two main scenarios:
1. Exported Functions
Many native libraries export their JNI functions and sometimes other helper functions. You can find these using readelf or nm on the .so file itself:
adb pull /data/app//lib/arm64/libyourlib.so .readelf -Ws libyourlib.sonm -D libyourlib.so
Look for symbols that match JNI naming conventions (e.g., Java_com_example_app_NativeClass_nativeMethod) or other clear function names.
2. Non-Exported (Internal) Functions
Often, the most interesting functions are not exported. In such cases, you need to use a decompiler/disassembler (like Ghidra or IDA Pro) to reverse engineer the library. Load the .so file into your tool, locate the JNI wrapper function, and then trace calls to internal functions. Note down the offset of the target function relative to the module’s base address.
Basic Native Hooking: Exported Functions
Let’s start with a simple example: hooking an exported function. Suppose we have a native function named my_exported_function.
import fridaimport sysdef on_message(message, data):print(f"[+] {message}")def hook_exported_function(package_name, library_name, function_name):script_code = f"""Interceptor.attach(Module.findExportByName('{library_name}', '{function_name}'), {{onEnter: function (args) {{console.log(`[+] Called {function_name} with arguments:`);console.log(`x0: ${{args[0]}}`);console.log(`x1: ${{args[1]}}`);}},onLeave: function (retval) {{console.log(`[+] {function_name} returned: ${{retval}}`);}}}});"""device = frida.get_usb_device()pid = device.spawn([package_name])session = device.attach(pid)script = session.create_script(script_code)script.on("message", on_message)script.load()device.resume(pid)print(f"[+] Hooked {function_name} in {library_name}. Press Ctrl+D or Ctrl+C to stop.")sys.stdin.read()session.detach()if __name__ == "__main__": # Replace with your target app and library nameshook_exported_function("com.example.myapp", "libmyapp.so", "my_exported_function")
This script attaches to the target process, finds the exported function, and prints its arguments (x0 and x1) and return value.
Advanced Hooking: Non-Exported Functions by Offset
When dealing with internal functions, you need their memory offset within the library. Assume through reverse engineering, you found an internal function at offset 0x12345 from the base of libmyapp.so.
import fridaimport sysdef on_message(message, data):print(f"[+] {message}")def hook_offset_function(package_name, library_name, offset):script_code = f"""var module_base = Module.findBaseAddress('{library_name}');if (module_base) {{var target_address = module_base.add({offset});console.log(`[+] {library_name} base address: ${{module_base}}`);console.log(`[+] Target function address: ${{target_address}}`);Interceptor.attach(target_address, {{onEnter: function (args) {{console.log(`[+] Called internal function at offset 0x{offset:x} with arguments:`);console.log(`x0: ${{args[0]}}`);console.log(`x1: ${{args[1]}}`);// You can read memory pointed by args[N] or modify them// For example, reading a string at args[0]:// console.log(`String at x0: ${{args[0].readUtf8String()}}`);}},onLeave: function (retval) {{console.log(`[+] Internal function at offset 0x{offset:x} returned: ${{retval}}`);// Modify return value, e.g., always return 1:// retval.replace(ptr(1));}}}});}} else {{console.error(`[-] Could not find module: {library_name}`);}}"""device = frida.get_usb_device()pid = device.spawn([package_name])session = device.attach(pid)script = session.create_script(script_code)script.on("message", on_message)script.load()device.resume(pid)print(f"[+] Hooked internal function at offset 0x{offset:x} in {library_name}. Press Ctrl+D or Ctrl+C to stop.")sys.stdin.read()session.detach()if __name__ == "__main__": # Replace with your target app, library, and offsethook_offset_function("com.example.myapp", "libmyapp.so", 0x12345)
This script first finds the base address of libmyapp.so and then adds the known offset to calculate the exact memory address of the target function. The onEnter and onLeave callbacks allow you to inspect and modify arguments and return values.
Working with Function Arguments and Return Values (ARM64 Specifics)
On ARM64, the first eight parameters are passed in registers x0 to x7. You access these in Frida’s onEnter callback via args[0] to args[7]. The return value is typically in x0 and can be accessed and modified via retval in the onLeave callback. For example, to read a `char*` string passed as the first argument, you’d use args[0].readUtf8String().
Practical Example: Bypassing a Simple Native Check
Imagine a native function, say checkLicense(), which returns 0 for a valid license and -1 for an invalid one. We want to always make it return 0.
import fridaimport sysdef on_message(message, data):print(f"[+] {message}")def bypass_license_check(package_name, library_name, function_name_or_offset, is_offset=False):script_code = f"""var target_addr;if ({is_offset}) {{var module_base = Module.findBaseAddress('{library_name}');if (!module_base) {{console.error(`[-] Could not find module: {library_name}`);return;}}target_addr = module_base.add({function_name_or_offset});}} else {{target_addr = Module.findExportByName('{library_name}', '{function_name_or_offset}');if (!target_addr) {{console.error(`[-] Could not find exported function: {function_name_or_offset}`);return;}}}}console.log(`[+] Targeting function at address: ${{target_addr}}`);Interceptor.attach(target_addr, {{onEnter: function (args) {{console.log(`[+] Bypassing {function_name_or_offset} check. Args[0]: ${{args[0]}}`);}},onLeave: function (retval) {{console.log(`[+] Original return value: ${{retval}}`);retval.replace(ptr(0)); // Force return 0 (success)console.log(`[+] Modified return value to: 0`);}}}});"""device = frida.get_usb_device()pid = device.spawn([package_name])session = device.attach(pid)script = session.create_script(script_code)script.on("message", on_message)script.load()device.resume(pid)print(f"[+] Bypassing license check for {function_name_or_offset}. Press Ctrl+D or Ctrl+C to stop.")sys.stdin.read()session.detach()if __name__ == "__main__": # Example: hooking an exported function called checkLicensebypass_license_check("com.example.myapp", "libmyapp.so", "checkLicense", False) # Example: hooking an internal function at offset 0xABCDEbypass_license_check("com.example.myapp", "libmyapp.so", 0xABCDE, True)
This script demonstrates how to conditionally handle both exported and offset-based functions, and more importantly, how to manipulate the return value in the onLeave callback to bypass a check. The retval.replace(ptr(0)); line is the core of the bypass.
Conclusion
Frida is an incredibly powerful tool for dynamic instrumentation of Android native libraries on ARM64. By understanding how to identify target functions (exported or internal), calculate their addresses, and leverage Frida’s Interceptor, you can gain deep insights into application behavior, analyze data flows, and even bypass security mechanisms. This step-by-step guide provides a solid foundation for your advanced Android penetration testing and reverse engineering endeavors. Remember to always use these techniques responsibly and ethically.
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 →