Android App Penetration Testing & Frida Hooks

Reverse Engineering Android NDK: Advanced Code Flow Analysis with Frida Stalker API

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction: The Labyrinth of Android NDK

Reverse engineering Android applications often presents a significant challenge when developers opt to implement critical logic within the Native Development Kit (NDK). Unlike Java or Kotlin code, which can be easily decompiled and deobfuscated, native C/C++ code compiled into shared libraries (.so files) is much harder to analyze statically. Obfuscation techniques like control flow flattening, string encryption, and anti-tampering checks further complicate matters, making it difficult to understand the true intent and functionality of native components. Traditional static analysis tools like Ghidra or IDA Pro provide excellent insights, but often fall short when dealing with dynamic execution paths, especially those influenced by runtime conditions or complex state transitions.

This is where dynamic instrumentation becomes indispensable. By observing code execution at runtime, we can bypass many static analysis hurdles and gain a clearer understanding of how native functions operate, what arguments they receive, and what values they return. Among the plethora of dynamic analysis tools, Frida stands out as a powerful and versatile framework for Android penetration testing and reverse engineering.

Frida: Your Dynamic Instrumentation Powerhouse

Frida is a dynamic instrumentation toolkit that allows developers, reverse engineers, and security researchers to inject JavaScript into native apps on Windows, macOS, Linux, iOS, Android, and QNX. It exposes powerful APIs to hook functions, inject custom code, and inspect memory, making it invaluable for runtime analysis. While Frida’s `Interceptor` API is excellent for hooking specific functions at their entry or exit points, it doesn’t provide fine-grained control over the *flow* of execution within a function’s body. For understanding intricate logic, especially in highly obfuscated native code, a deeper level of tracing is required: enter Frida Stalker.

Unveiling Frida Stalker: Deep Code Flow Analysis

Frida Stalker is a powerful code tracing engine built into Frida. Unlike `Interceptor`, which focuses on function boundaries, Stalker operates at the instruction or basic block level. It works by copying the target function’s code into a new, JIT-generated memory region. As the original code executes, Stalker replaces the original instructions with jumps to its JIT-generated trampolines. These trampolines then execute the original instruction (or a modified version), record information, and then jump to the next instruction’s trampoline. This mechanism allows Stalker to observe every instruction executed, providing an unparalleled view into the true control flow within a native function.

Key capabilities of Frida Stalker:

  • Basic Block Tracing: Record the execution of every basic block (a sequence of instructions with one entry point and one exit point) within a specified code range. This is excellent for understanding the control flow graph.
  • Instruction Tracing: Go a step further and record the execution of every single instruction, including registers before and after execution, and memory accesses. This provides the most granular level of detail.
  • Context Manipulation: Modify register values or memory at any point during execution.
  • Call/Return Monitoring: Automatically track calls made from within the stalked region and their returns.

Stalker is particularly useful when:

  • You need to understand the exact sequence of instructions executed within a complex or obfuscated native function.
  • The function’s logic depends heavily on intermediate calculations or conditional jumps.
  • You are trying to identify specific cryptographic routines or anti-tampering checks that might be hidden deep within the code.
  • You want to trace data flow through a function.

Setting the Stage: Prerequisites & Setup

To follow along, you’ll need:

  1. A rooted Android device or an Android emulator (e.g., Genymotion, Android Studio’s AVD) with ADB access.
  2. Frida server installed and running on the Android device. You can download the appropriate `frida-server` binary for your device’s architecture from the Frida releases page and push it to `/data/local/tmp`, then execute it with `chmod +x /data/local/tmp/frida-server && /data/local/tmp/frida-server &`.
  3. Frida tools installed on your host machine (`pip install frida-tools`).
  4. A target Android application that uses NDK. For this tutorial, we’ll assume an application with a native library (`libnative-lib.so`) containing a function like `Java_com_example_app_NativeLib_decryptData`.

Practical Application: Tracing NDK Functions with Stalker

Step 1: Identify the Target Function

First, we need to locate the native function we want to trace. You can use tools like `nm` on the `.so` file or more advanced reverse engineering tools like Ghidra or IDA Pro to find function names and their offsets. Let’s assume our target function is a JNI export named `Java_com_example_app_NativeLib_decryptData` in `libnative-lib.so`.

If you have access to the `.so` file (e.g., from the APK), you can use `readelf -s libnative-lib.so | grep decryptData` or `nm -D libnative-lib.so | grep decryptData` to find its symbol. If not, you might need to enumerate exports at runtime using Frida’s `Module.enumerateExports()`.

Step 2: Crafting Your Stalker Script

Now, let’s write a Frida script to use Stalker. This script will attach to our target application, find the native function, and then use `Stalker.follow()` to trace its execution.

console.log("Frida Stalker script loaded!");

Interceptor.attach(Module.findExportByName("libc.so", "open"), {
    onEnter: function(args) {
        // Example: Intercept open() to ensure Frida is working
        // console.log("open(" + args[0].readCString() + ", " + args[1] + ")");
    }
});

Process.enumerateModules()
    .filter(m => m.name === "libnative-lib.so")
    .forEach(targetModule => {
        console.log("Found libnative-lib.so at base address: " + targetModule.base);

        // Find the native function by its JNI export name
        const targetFunctionName = "Java_com_example_app_NativeLib_decryptData";
        let targetFunctionAddress = null;

        try {
            targetFunctionAddress = targetModule.findExportByName(targetFunctionName);
        } catch (e) {
            console.error("Could not find export " + targetFunctionName + ": " + e.message);
        }

        if (targetFunctionAddress) {
            console.log("Target function " + targetFunctionName + " found at " + targetFunctionAddress);

            // Define Stalker callbacks
            Stalker.on("call", function(call) {
                // Log calls made from within the stalked region
                // console.log("Call from " + call.address + " to " + call.target);
            });

            Stalker.on("ret", function(ret) {
                // Log returns from within the stalked region
                // console.log("Return from " + ret.address + ", target " + ret.target);
            });

            Stalker.on("exec", function(block) {
                // Process each basic block executed
                // console.log("Executing basic block at: " + block.address);
                block.instructions.forEach(instr => {
                    // Log each instruction. Customize this to filter for specific instructions/registers.
                    console.log(`0x${instr.address.toString(16)}: ${instr.mnemonic} ${instr.opStr}`);
                    // Example: Log register state after instruction (if needed, can be verbose)
                    // console.log(`  Registers: { x0: 0x${this.context.x0.toString(16)}, x1: 0x${this.context.x1.toString(16)} ... }`);
                });
            });

            // You can also use Stalker.on('block') for less granular tracing of basic blocks.

            // Create a wrapper for the target function to enable Stalker
            Interceptor.attach(targetFunctionAddress, {
                onEnter: function(args) {
                    console.log("[+] Entering " + targetFunctionName + ". Starting Stalker...");
                    // Follow execution within this thread
                    Stalker.follow(this.threadId, {
                        events: {
                            call: true, // Log calls
                            ret: true,  // Log returns
                            exec: true, // Log basic block execution (including instructions)
                            // block: true, // If using 'block' event instead of 'exec'
                        },
                        onReceive: function(events) {
                            // This callback receives a buffer of events from Stalker
                            // We've already handled logging in the individual Stalker.on() handlers
                            // For performance, you might want to process events here in batches.
                            // var parsed = Stalker.parse(events);
                            // parsed.forEach(event => {
                            //     if (event.type === 'exec') {
                            //         console.log(`Block executed at 0x${event.address.toString(16)}`);
                            //     }
                            // });
                        }
                    });
                },
                onLeave: function(retval) {
                    console.log("[-] Leaving " + targetFunctionName + ". Stopping Stalker.");
                    Stalker.unfollow(this.threadId);
                }
            });

        } else {
            console.error("Target function " + targetFunctionName + " not found!");
        }
    });

console.log("Stalker script initialized. Waiting for target function execution.");

Step 3: Executing and Analyzing

Save the script as `stalker_trace.js`. Now, run Frida to attach to your target application and inject the script. Replace `com.example.app` with your application’s package name.

frida -U -l stalker_trace.js -f com.example.app --no-pause

The `–no-pause` flag is crucial because Stalker needs the application to be running to trace code dynamically. Once the application starts and the `Java_com_example_app_NativeLib_decryptData` function is called, you will see a detailed trace of every instruction executed within that function in your console. The output will look something like this (depending on the target architecture, e.g., ARM64):

0x76b6b7a288: sub sp, sp, #0x40
0x76b6b7a28c: stp x29, x30, [sp, #0x30]
0x76b6b7a290: mov x29, sp
0x76b6b7a294: stp x20, x19, [sp, #0x20]
0x76b6b7a298: stp x22, x21, [sp, #0x10]
0x76b6b7a29c: stp x24, x23, [sp]
0x76b6b7a2a0: mov x19, x3
0x76b6b7a2a4: mov x20, x4
0x76b6b7a2a8: mov x21, x5
0x76b7a2ac: mov x22, x6
0x76b7a2b0: mov x23, x7
0x76b7a2b4: mov x24, x8
...

This granular output allows you to reconstruct the logic, identify conditional branches, pinpoint memory accesses, and understand the internal workings of the native function, even if it’s heavily obfuscated. You can filter the `exec` events to log only specific types of instructions, register values, or memory reads/writes to reduce verbosity and focus on critical operations.

Advanced Stalker Techniques

  • Context Switching: Frida Stalker allows you to manipulate the CPU context (registers) during execution, which can be useful for bypassing checks or injecting values.
  • Memory Access Tracing: By setting `events: { exec: true, memory: true }`, you can get events for memory reads and writes, providing insights into data flow.
  • Stack Walking: Combine Stalker with `Thread.backtrace()` or `DebugSymbol.fromAddress()` to reconstruct call stacks at various points within the traced function.
  • Instruction Filtering: Enhance your `Stalker.on(‘exec’)` callback to filter instructions by mnemonic or operands, focusing only on relevant operations like crypto instructions or specific comparisons.

Conclusion: Empowering Your NDK Reverse Engineering

Frida Stalker API is an incredibly powerful tool for anyone serious about reverse engineering Android NDK applications. While static analysis provides the blueprint, Stalker offers a dynamic, instruction-by-instruction replay of the code’s execution, revealing its true behavior under runtime conditions. By leveraging its capabilities, you can efficiently unravel complex logic, identify obfuscated routines, and gain deep insights into native code that would be otherwise impenetrable. Mastering Stalker is a significant step towards becoming a more proficient Android penetration tester and reverse engineer, allowing you to confidently navigate the challenging landscape of native Android binaries.

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