Android App Penetration Testing & Frida Hooks

Practical Scenario: Intercepting Cryptographic Calls in Android ARM64 Native Libraries with Frida

Google AdSense Native Placement - Horizontal Top-Post banner

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 x0 through x7. Additional arguments are pushed onto the stack.
  • Return Value: The return value is typically stored in register x0.
  • Callee-Saved Registers: Registers x19x30 must 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. args is an array of NativePointer objects, representing the values in x0x7.
  • this.keyPtr = args[0]: We store argument values in the this context to access them in onLeave.
  • args[2].toInt32(): Converts a NativePointer representing 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. retval is the return value (from x0).

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 →
Google AdSense Inline Placement - Content Footer banner