Android Hacking, Sandboxing, & Security Exploits

Frida for the NDK: Mastering Native C/C++ Hooking in Android Applications

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to NDK Hooking with Frida

Android applications often leverage the Native Development Kit (NDK) to implement performance-critical logic, cryptographic operations, or obfuscation techniques in C/C++. This native code executes directly on the device’s CPU, making it a lucrative target for security researchers and penetration testers. While Java/Kotlin code is relatively straightforward to analyze and hook, interacting with the NDK layer presents unique challenges. This article delves deep into using Frida, a dynamic instrumentation toolkit, to master C/C++ hooking within Android’s native libraries, offering expert-level techniques and practical examples.

Frida allows you to inject JavaScript code into target processes, giving you unparalleled control over runtime behavior. For native binaries, Frida’s powerful `Interceptor` API combined with its memory manipulation capabilities enables precise and sophisticated hooking of C/C++ functions, whether they are exported or internal.

Setting Up Your Environment

Before we dive into advanced hooking, ensure your environment is correctly configured. You’ll need:

  • A rooted Android device or an emulator (e.g., AVD, Genymotion).
  • Android Debug Bridge (ADB) installed and configured on your host machine.
  • Python 3 and `pip` for installing Frida tools.
  • `frida-tools` installed: `pip install frida-tools`.
  • The appropriate `frida-server` binary pushed and running on your Android device.

Frida Server Installation Steps:

  1. Download the `frida-server` binary matching your device’s architecture (e.g., `arm64`, `x86`) from Frida Releases.
  2. Push it to the device:
    adb push frida-server /data/local/tmp/
  3. Make it executable and run:
    adb shellsucd /data/local/tmpsu./frida-server &

Verify Frida is running by listing processes:

frida-ps -U

Identifying Native Functions

The first step in hooking is identifying the target function’s address or symbol name. Native libraries (`.so` files) contain symbols, which are names associated with functions or data. There are two primary categories:

  • Exported Symbols: Functions explicitly made available for other modules or the Java Native Interface (JNI). These are easy to find.
  • Unexported (Internal) Symbols: Functions used internally within the library. These require static analysis to locate their offsets.

Using `nm` to List Exported Symbols:

The `nm` utility (part of the Android NDK toolchain, or a standalone version like `llvm-nm`) can list symbols within a `.so` file. Push the target library from your app’s `lib` directory to your host and analyze it:

adb pull /data/app/com.example.app/lib/arm64/libnative-lib.so .nm -D libnative-lib.so | grep "my_target_function"

This will show dynamic symbols (exports). For example, a typical JNI function might appear as `Java_com_example_app_MainActivity_nativeMethod`.

Static Analysis for Unexported Functions:

For unexported functions, you’ll need a disassembler/decompiler like IDA Pro or Ghidra. Load the `.so` file into these tools, analyze its control flow, and identify the target function’s relative virtual address (RVA) within the module. This RVA, when added to the module’s base address in memory, gives you the absolute address to hook.

Basic C/C++ Hooking: Interceptor.attach

Frida’s `Interceptor.attach` is your primary tool for hooking native functions. It takes an address and an optional object containing `onEnter` and `onLeave` callback functions.

Example: Hooking a JNI Function

Let’s assume our target app has a `libnative-lib.so` with a JNI method `nativeComputeSecret(int a, int b)`. Our goal is to observe its arguments and potentially modify them.

// In libnative-lib.c/cppJNIEXPORT jint JNICALL Java_com_example_app_MainActivity_nativeComputeSecret(JNIEnv* env, jobject thiz, jint a, jint b) {    // ... some computation ...    return a + b + 100;}

Here’s a basic Frida script:

Java.perform(function () {    var libnative = Module.findExportByName("libnative-lib.so", "Java_com_example_app_MainActivity_nativeComputeSecret");    if (libnative) {        console.log("[*] Found nativeComputeSecret at: " + libnative);        Interceptor.attach(libnative, {            onEnter: function (args) {                console.log("[+] nativeComputeSecret called!");                console.log("    Arg 1 (jint a): " + args[2].toInt32()); // JNIEnv*, jobject, jint a, jint b                console.log("    Arg 2 (jint b): " + args[3].toInt32());                // Modify an argument (e.g., change 'a' to 0x1337)                args[2] = ptr(0x1337);            },            onLeave: function (retval) {                console.log("[*] nativeComputeSecret returning: " + retval.toInt32());                // Modify the return value (e.g., always return 999)                retval.replace(ptr(999));                console.log("[+] Return value modified to 999.");            }        });    } else {        console.log("[-] Could not find nativeComputeSecret.");    }});

In `onEnter`, `args` is an array of `NativePointer` objects representing the function arguments. The first two arguments for JNI methods are always `JNIEnv*` and `jobject`. Subsequent arguments correspond to your function’s parameters. You can convert `NativePointer` to various types (e.g., `toInt32()`, `readCString()`). You can also modify `args[index]` to change the argument values before the original function executes.

In `onLeave`, `retval` is a `NativePointer` to the return value. Use `retval.replace(ptr(newValue))` to change what the calling function receives.

Advanced Hooking Techniques

Working with Complex Arguments and Return Values

Native functions often deal with pointers to custom structs, arrays, or objects. Frida provides powerful `Memory` APIs to read and write arbitrary memory.

Example: Hooking a function with a custom struct argument

// In libnative-lib.c/cpptypedef struct {    int id;    char name[64];    long timestamp;} MyCustomStruct;JNIEXPORT void JNICALL Java_com_example_app_MainActivity_processStruct(JNIEnv* env, jobject thiz, MyCustomStruct* data) {    // ... processes data ...    data->id = 123; // Modify the struct}

To hook `processStruct` and inspect/modify `MyCustomStruct`:

Java.perform(function () {    var libnative = Module.findExportByName("libnative-lib.so", "Java_com_example_app_MainActivity_processStruct");    if (libnative) {        Interceptor.attach(libnative, {            onEnter: function (args) {                console.log("[+] processStruct called!");                var structPtr = args[2]; // MyCustomStruct*                console.log("    Struct pointer: " + structPtr);                // Read struct fields (assuming 4-byte int, 64-byte char array, 8-byte long)                var id = structPtr.readS32();                var namePtr = structPtr.add(4); // Offset of name field                var name = namePtr.readCString();                var timestamp = structPtr.add(4 + 64).readS64(); // Offset of timestamp                console.log("    Struct data before: ");                console.log("        id: " + id);                console.log("        name: " + name);                console.log("        timestamp: " + timestamp);                // Modify the 'id' field within the struct (e.g., set to 0xDEADBEEF)                structPtr.writeS32(0xDEADBEEF);            },            onLeave: function (retval) {                // No return value for void function, but we can check if modification persisted                // Or if it returned a pointer to a newly allocated struct, inspect that.            }        });    }});
  • `ptr(address)`: Converts an integer address to a `NativePointer`.
  • `Memory.readS32(address)` / `Memory.writeS32(address, value)`: Read/write signed 32-bit integers.
  • `Memory.readU32(address)` / `Memory.writeU32(address, value)`: Read/write unsigned 32-bit integers.
  • `Memory.readS64(address)` / `Memory.writeS64(address, value)`: Read/write signed 64-bit integers (for `long` on 64-bit systems).
  • `Memory.readCString(address)`: Reads a null-terminated string.
  • `Memory.readByteArray(address, size)`: Reads raw bytes.

Calling Original Functions

Sometimes you need to call the original function, but with modified arguments, or from within `onLeave` after its initial execution. You can do this by creating a NativeFunction wrapper around the original function’s address.

Java.perform(function () {    var libnative = Module.findExportByName("libnative-lib.so", "Java_com_example_app_MainActivity_nativeComputeSecret");    if (libnative) {        var nativeComputeSecret = new NativeFunction(libnative, 'int', ['pointer', 'pointer', 'int', 'int']); // JNIEnv*, jobject, jint a, jint b        Interceptor.attach(libnative, {            onEnter: function (args) {                // ... (do something before) ...                this.originalArgs = [args[0], args[1], args[2], args[3]]; // Store original args            },            onLeave: function (retval) {                console.log("[+] Original return value: " + retval.toInt32());                // Call original again with potentially modified arguments                var result = nativeComputeSecret(this.originalArgs[0], this.originalArgs[1], ptr(10), ptr(20));                console.log("[+] Result of calling original with 10, 20: " + result);                retval.replace(ptr(result)); // Replace with our custom call's result            }        });    }});

Hooking Unexported Functions

Hooking unexported functions is crucial for bypassing internal checks or understanding complex algorithms. This requires pinpointing the function’s offset within its module using static analysis.

Java.perform(function () {    var moduleName = "libnative-lib.so";    var baseAddress = Module.findBaseAddress(moduleName);    if (baseAddress) {        console.log("[*] Base address of " + moduleName + ": " + baseAddress);        // Assume static analysis (Ghidra/IDA) revealed an internal function at offset 0x1234.        var unexportedFunctionOffset = 0x1234;        var targetAddress = baseAddress.add(unexportedFunctionOffset);        console.log("[+] Hooking unexported function at: " + targetAddress);        Interceptor.attach(targetAddress, {            onEnter: function (args) {                console.log("[+] Unexported function called!");                // Depending on ABI and function signature, args[0], args[1] etc. will be parameters                console.log("    Arg 0: " + args[0]);            },            onLeave: function (retval) {                console.log("[*] Unexported function returning: " + retval);            }        });    } else {        console.log("[-] Could not find base address for " + moduleName);    }});

Remember that offsets are architecture-dependent (ARM vs. ARM64, etc.), so ensure your static analysis matches your target device’s architecture.

Debugging and Common Pitfalls

  • Logging: Use `console.log()` and `send()`/`recv()` for debugging your Frida scripts. `send()` allows structured data to be sent back to your Python script.
  • Address Space: Be mindful of 32-bit vs. 64-bit processes. Pointers will be 4 bytes or 8 bytes respectively. Frida automatically handles this for `NativePointer` objects, but when using `readS32`/`readS64`, ensure you match the data type.
  • Module Loading Times: Native libraries might not be loaded immediately at app startup. Use `Module.findBaseAddress()` within `Java.perform()` to ensure the module is loaded when you attempt to hook. For modules loaded later, consider using `Interceptor.attach(Module.getExportByName(‘libname.so’, ‘target_function’))` which will wait for the export to become available, or even `Process.set Scheduler(Process.getCurrentThreadId(), { onModuleLoad: … })` if a module is loaded dynamically.
  • Crash Investigations: If your app crashes after hooking, review your argument types carefully. Incorrect types (e.g., trying to read an `int` as a `struct*`) or bad memory writes can lead to segmentation faults. Use `strace` or `logcat` for crash dumps.

Conclusion

Frida is an indispensable tool for anyone delving into Android native security. By mastering `Interceptor.attach`, understanding `NativePointer` and `Memory` APIs, and employing static analysis tools, you gain the power to deeply inspect, modify, and even bypass complex logic hidden within NDK libraries. This knowledge empowers you to perform advanced reverse engineering, vulnerability research, and security assessments of Android applications, unlocking insights that traditional Java-level analysis might miss.

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