Introduction
Android applications often utilize native libraries (written in C/C++ and compiled to ARM64 for modern devices) for performance-critical operations, including complex cryptographic computations. When performing security assessments, understanding and manipulating these native cryptographic calls is paramount. Unlike Java methods, which are easily hooked with frameworks like Frida due to their well-defined JNI interfaces, intercepting functions within stripped or obfuscated native ARM64 libraries presents a unique set of challenges. This expert-level guide will walk you through the practical steps of identifying and intercepting cryptographic functions within ARM64 native libraries on Android using Frida, providing concrete examples and essential ARM64 calling convention insights.
Prerequisites and Environment Setup
Before diving into the hooking process, ensure you have the following:
- Rooted Android Device or Emulator: Necessary for running Frida server with full privileges.
- ADB (Android Debug Bridge): For interacting with your Android device.
- Frida: Installed on your host machine (
pip install frida-tools). - Frida Server: The correct ARM64 version pushed and running on your Android device.
- Basic Understanding of ARM64 Assembly: Familiarity with registers and calling conventions will greatly aid analysis.
- Optional: Static Analysis Tool: Ghidra or IDA Pro for deeper understanding of native binaries (though we’ll focus on dynamic).
Frida Server Setup:
Download the appropriate frida-server for your device’s architecture (e.g., frida-server-*-android-arm64) from the Frida releases page. Then, push and execute it on your device:
adb push /path/to/frida-server-*-android-arm64 /data/local/tmp/frida-serveradb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"
Identifying Native Cryptographic Functions
The first step is to locate the target cryptographic functions within the native library. Unlike Java APIs, native libraries might not explicitly export all internal functions, especially if they are designed to be obfuscated or are part of a statically linked library.
1. Exported Symbols:
Many libraries, particularly open-source ones like OpenSSL or BoringSSL derivatives, export their public API functions. You can list these using nm -D or readelf -s on the library file:
adb pull /data/app/your.package.name/lib/arm64/libyourlib.so .nm -D libyourlib.so | grep -i "AES|SHA|EVP_Cipher"
Look for common patterns like AES_set_encrypt_key, EVP_CipherInit_ex, SHA256_Update, etc.
2. Static Analysis (for Stripped Symbols):
If symbols are stripped, you’ll need a static analysis tool like Ghidra or IDA Pro. Load the .so file and search for known cryptographic constants (e.g., AES S-boxes), or analyze cross-references to common system calls (like mmap, open, read) to infer where crypto operations might occur. Identify the starting address or offset of interesting functions.
ARM64 Calling Convention Fundamentals for Hooking
Understanding ARM64 calling conventions is crucial for correctly interpreting function arguments and return values when using Frida’s Interceptor.attach. For standard C/C++ functions:
- Arguments: The first eight arguments (up to 64-bit size each) are passed in registers
x0throughx7. Additional arguments are pushed onto the stack. - Return Value: The return value is typically stored in register
x0. - Callee-Saved Registers: Registers
x19–x30must be preserved by the callee.
When using Interceptor.attach, Frida exposes these arguments in the onEnter(args) callback as an array-like object where args[0] corresponds to x0, args[1] to x1, and so on.
Crafting the Frida Hook: A Step-by-Step Guide
Let’s assume we want to intercept a hypothetical function custom_aes_decrypt(uint8_t *key, uint8_t *ciphertext, size_t len, uint8_t *plaintext) within libcryptolib.so.
1. Find the Module Base Address and Function Address:
First, get the base address of the target library within the application’s memory space. Then, calculate the absolute address of the function.
// script.jsfunction hookNativeCrypto() { var moduleName = "libcryptolib.so"; var targetModule = Module.findExportByName(moduleName, "custom_aes_decrypt"); // If symbol is exported if (!targetModule) { // Fallback: If symbol is stripped, you'd find the module base and add the static offset var lib = Module.findByName(moduleName); if (lib) { console.log("[+] Found libcryptolib.so at: " + lib.base); // Example: Offset found via static analysis (Ghidra/IDA) var offset = new NativePointer(0x12345); // Replace with actual offset targetModule = lib.base.add(offset); console.log("[+] Target function at calculated offset: " + targetModule); } else { console.error("[-] libcryptolib.so not found!"); return; } }}
2. Intercept the Function:
Now, use Interceptor.attach to define onEnter and onLeave callbacks.
// script.js...if (targetModule) { console.log("[+] Hooking custom_aes_decrypt at: " + targetModule); Interceptor.attach(targetModule, { onEnter: function(args) { // 'this' context stores data between onEnter and onLeave this.keyPtr = args[0]; this.ciphertextPtr = args[1]; this.len = args[2].toInt32(); // Cast NativePointer to integer this.plaintextPtr = args[3]; console.log("--------------------------------------------------"); console.log("[+] custom_aes_decrypt called!"); console.log(" Key Pointer (x0): " + this.keyPtr); console.log(" Ciphertext Pointer (x1): " + this.ciphertextPtr); console.log(" Length (x2): " + this.len + " bytes"); console.log(" Plaintext Output Pointer (x3): " + this.plaintextPtr); // Dump key and ciphertext if (this.keyPtr.isNull() || this.ciphertextPtr.isNull()) { console.warn(" Pointers are null, cannot dump data."); } else { try { console.log(" Key Data (first 16 bytes):"); console.log(hexdump(this.keyPtr, { length: Math.min(this.len, 16) })); console.log(" Ciphertext Data (first 32 bytes):"); console.log(hexdump(this.ciphertextPtr, { length: Math.min(this.len, 32) })); } catch (e) { console.error(" Error dumping data: " + e); } } }, onLeave: function(retval) { console.log("[+] custom_aes_decrypt returned."); // Assuming retval is an integer representing success/failure console.log(" Return Value (x0): " + retval); // If plaintext buffer is written to, dump its content after the call if (this.plaintextPtr && !this.plaintextPtr.isNull() && this.len > 0) { try { console.log(" Decrypted Plaintext Data (first 32 bytes):"); console.log(hexdump(this.plaintextPtr, { length: Math.min(this.len, 32) })); } catch (e) { console.error(" Error dumping plaintext data: " + e); } } console.log("--------------------------------------------------"); } }); console.log("[+] custom_aes_decrypt hook installed.");} else { console.error("[-] Target function 'custom_aes_decrypt' not found.");}function main() { hookNativeCrypto();}rpc.exports = { init: main};
Explanation of the Script:
Module.findExportByName(moduleName, functionName): Tries to find the function by its exported symbol.Module.findByName(moduleName): Gets the base address of the loaded module.lib.base.add(offset): Calculates the absolute address if the symbol is stripped and you have an offset from static analysis.Interceptor.attach(address, callbacks): The core Frida API for hooking.onEnter(args): Called before the target function executes.argsis an array ofNativePointerobjects, representing the values inx0–x7.this.keyPtr = args[0]: We store argument values in thethiscontext to access them inonLeave.args[2].toInt32(): Converts aNativePointerrepresenting an integer to a JavaScript number.hexdump(ptr, { length: num }): A powerful Frida utility to dump memory at a given pointer, showing both hexadecimal and ASCII representations.onLeave(retval): Called after the target function executes.retvalis the return value (fromx0).
Full Frida Script Example and Execution
Save the above script as hook_crypto.js.
// hook_crypto.js (complete script)function hookNativeCrypto() { var moduleName = "libcryptolib.so"; var targetFunctionName = "custom_aes_decrypt"; var targetModule = Module.findExportByName(moduleName, targetFunctionName); if (!targetModule) { var lib = Module.findByName(moduleName); if (lib) { console.log("[+] Found " + moduleName + " at: " + lib.base); // Example: Replace 0x12345 with the actual offset found via static analysis var offset = new NativePointer("0x12345"); targetModule = lib.base.add(offset); console.log("[+] Target function at calculated offset: " + targetModule); } else { console.error("[-] " + moduleName + " not found!"); return; } } if (targetModule) { console.log("[+] Hooking " + targetFunctionName + " at: " + targetModule); Interceptor.attach(targetModule, { onEnter: function(args) { this.keyPtr = args[0]; this.ciphertextPtr = args[1]; this.len = args[2].toInt32(); this.plaintextPtr = args[3]; console.log("--------------------------------------------------"); console.log("[+] " + targetFunctionName + " called!"); console.log(" Key Pointer (x0): " + this.keyPtr); console.log(" Ciphertext Pointer (x1): " + this.ciphertextPtr); console.log(" Length (x2): " + this.len + " bytes"); console.log(" Plaintext Output Pointer (x3): " + this.plaintextPtr); if (this.keyPtr.isNull() || this.ciphertextPtr.isNull()) { console.warn(" Pointers are null, cannot dump data."); } else { try { console.log(" Key Data (first 16 bytes):"); console.log(hexdump(this.keyPtr, { length: Math.min(this.len, 16) })); console.log(" Ciphertext Data (first 32 bytes):"); console.log(hexdump(this.ciphertextPtr, { length: Math.min(this.len, 32) })); } catch (e) { console.error(" Error dumping data: " + e); } } }, onLeave: function(retval) { console.log("[+] " + targetFunctionName + " returned."); console.log(" Return Value (x0): " + retval); if (this.plaintextPtr && !this.plaintextPtr.isNull() && this.len > 0) { try { console.log(" Decrypted Plaintext Data (first 32 bytes):"); console.log(hexdump(this.plaintextPtr, { length: Math.min(this.len, 32) })); } catch (e) { console.error(" Error dumping plaintext data: " + e); } } console.log("--------------------------------------------------"); } }); console.log("[+] " + targetFunctionName + " hook installed."); } else { console.error("[-] Target function '" + targetFunctionName + "' not found."); }}setImmediate(hookNativeCrypto);
Execution:
Run Frida, injecting your script into the target application. Replace your.package.name with the actual package name of the Android app.
frida -U -l hook_crypto.js -f your.package.name --no-pause
When the application executes the custom_aes_decrypt function, your console will display the intercepted key, ciphertext, and subsequently, the decrypted plaintext, along with the function’s return value.
Conclusion
Intercepting cryptographic calls in Android ARM64 native libraries with Frida is a powerful technique for penetration testers and security researchers. By understanding ARM64 calling conventions and leveraging Frida’s Interceptor API, you can gain deep visibility into how applications handle sensitive data at a low level, even in the presence of obfuscation or stripped symbols. This practical approach enables comprehensive analysis of cryptographic implementations and helps uncover potential vulnerabilities that might otherwise remain hidden.
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 →