Android Software Reverse Engineering & Decompilation

Reverse Engineering Android NDK Apps: Advanced Frida Native Function Overriding Techniques

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to Android NDK Reverse Engineering with Frida

Android applications often leverage the Native Development Kit (NDK) to implement performance-critical code, integrate third-party libraries written in C/C++, or secure sensitive operations. This native layer, compiled into shared objects (.so files), is a treasure trove for reverse engineers, offering deeper insights into an app’s core logic, especially in areas like cryptography, anti-tampering, and obfuscation. However, analyzing and interacting with native code dynamically requires powerful tools.

Frida, a dynamic instrumentation toolkit, stands out as an indispensable asset for Android NDK reverse engineering. It allows you to inject custom scripts into running processes, hook into functions, inspect memory, and modify execution flow, making it perfect for both static analysis verification and dynamic behavioral analysis of native components.

The Necessity of Native Function Overriding

Why Override Native Functions?

Overriding native functions is a critical technique for various reverse engineering and security analysis tasks:

  • Bypassing Security Checks: Many apps implement root detection, debugger detection, or license verification routines in native code. Overriding these functions can allow you to bypass these checks to proceed with analysis.
  • Modifying Cryptographic Operations: Intercepting encryption/decryption routines to log keys, plaintext, or ciphertext can be invaluable for understanding proprietary protocols or recovering data.
  • Observing Hidden Logic: Some functions might perform complex computations or interact with hardware in ways that are not obvious from static analysis. Overriding allows you to log inputs, outputs, and intermediate states.
  • Patching and Vulnerability Research: Temporarily patching a vulnerable function or injecting custom payloads to test exploit scenarios is made simpler with dynamic overriding.

Challenges in NDK Reverse Engineering

Working with NDK apps presents unique challenges. Unlike Java/Kotlin code which can be decompiled to readable source, native code compiles to machine instructions (ARM or ARM64 assembly). Symbols might be stripped, making function identification difficult, and advanced obfuscation techniques can further complicate analysis. This is where Frida’s ability to operate at a low level, interacting directly with memory and CPU registers, becomes crucial.

Identifying Target Native Functions

Before you can override a native function, you must first locate its address. Several methods can be employed:

  • Static Analysis with Disassemblers: Tools like IDA Pro or Ghidra are essential. Load the target .so file and look for exported functions, interesting string references, or cross-references to common Android APIs (e.g., JNI functions). Once identified, note the function’s offset within the module.
  • Dynamic Symbol Enumeration with Frida: If the function is exported, Frida can easily list it.
Java.perform(function() {    var targetLibrary = "libnative-lib.so"; // Replace with your target .so name    var module = Module.findByName(targetLibrary);    if (module) {        console.log("Found module: " + module.name + " at base: " + module.base);        Module.enumerateExports(targetLibrary).forEach(function(exp) {            console.log("Exported function: " + exp.name + " at address: " + exp.address);        });    } else {        console.log("Module " + targetLibrary + " not found.");    }});
  • Runtime Observation with dlopen/dlsym: Some libraries are loaded dynamically, and their functions resolved via dlsym. Hooking android_dlopen_ext or dlsym can reveal loaded libraries and their function pointers.

Frida’s Interceptor API: The Foundation

Basic Hooking with Interceptor.attach

The core of Frida’s native hooking capabilities lies in the Interceptor.attach API. It allows you to attach callbacks to a specific memory address, executing code before (onEnter) and after (onLeave) the original function.

Interceptor.attach(Module.findExportByName("libmymath.so", "add_numbers"), {    onEnter: function(args) {        console.log("add_numbers called!");        console.log("Argument 1: " + args[0].toInt32());        console.log("Argument 2: " + args[1].toInt32());    },    onLeave: function(retval) {        console.log("add_numbers returned: " + retval.toInt32());    }});

Advanced Overriding Techniques

Modifying Arguments and Return Values

One of the most powerful aspects of native overriding is the ability to change the inputs and outputs of a function. In onEnter, the args array (for ARM/ARM64, these correspond to general-purpose registers x0-xN or r0-rN) can be modified. Similarly, in onLeave, the retval can be changed.

Interceptor.attach(Module.findExportByName("libmymath.so", "add_numbers"), {    onEnter: function(args) {        this.original_arg1 = args[0].toInt32();        this.original_arg2 = args[1].toInt32();        console.log(`Original call: add_numbers(${this.original_arg1}, ${this.original_arg2})`);        // Modify arguments to always add 10 and 20        args[0] = ptr(10);        args[1] = ptr(20);        console.log(`Modified call: add_numbers(${args[0].toInt32()}, ${args[1].toInt32()})`);    },    onLeave: function(retval) {        console.log(`Original return was: ${retval.toInt32()}`);        // Override the return value to always be 999        retval.replace(ptr(999));        console.log(`Modified return is now: ${retval.toInt32()}`);    }});

Replacing Native Functions Entirely with Interceptor.replace

For more drastic changes, such as completely changing a function’s behavior or patching it out, Interceptor.replace is invaluable. This API replaces the target function’s entry point with your custom JavaScript function. Your replacement function will receive the original arguments directly.

var targetFunctionAddress = Module.findExportByName("libmymath.so", "add_numbers");Interceptor.replace(targetFunctionAddress, new NativeCallback(function(a, b) {    console.log(`Original add_numbers(${a}, ${b}) replaced!`);    // Perform custom logic instead of the original function    // For example, always return 42    return a + b + 100; // You can still use original args or custom logic}, 'int', ['int', 'int']));

In this example, `add_numbers` will no longer execute its original C/C++ code. Instead, it will execute the JavaScript logic provided in the `NativeCallback` function, which now adds 100 to the sum of its arguments.

Handling Calling Conventions (ARM32/ARM64)

Frida generally handles the underlying ARM32 or ARM64 calling conventions for you when using the args array in Interceptor.attach or passing arguments to NativeCallback. The args array will contain NativePointer objects representing the values passed in the argument registers (e.g., x0-x7 for ARM64, r0-r3 for ARM32). For arguments beyond the register limit, they are typically pushed onto the stack. For more advanced scenarios, such as inspecting floating-point registers or specific stack frames, you might need to directly interact with the this.context object within onEnter/onLeave, which exposes CPU registers (e.g., this.context.x0, this.context.r0, this.context.sp).

Interceptor.attach(targetFunctionAddress, {    onEnter: function(args) {        // Check architecture and access registers directly if needed        if (Process.pointerSize === 8) { // ARM64            console.log("x0: " + this.context.x0);            console.log("x1: " + this.context.x1);        } else { // ARM32            console.log("r0: " + this.context.r0);            console.log("r1: " + this.context.r1);        }    }});

Practical Example: Overriding a Simple NDK Function

Scenario: A native library libcalc.so with multiply_numbers(int a, int b).

Let’s assume we have a simple Android NDK application that exports a function called multiply_numbers. We want to override it to always return a fixed value or modify its input arguments before execution.

C/C++ Source for multiply_numbers (native-lib.cpp):

#include <jni.h>#include <string>extern "C" JNIEXPORT jint JNICALLJava_com_example_ndkcalculator_MainActivity_multiplyNumbers(        JNIEnv* env,        jobject /* this */,        jint a,        jint b) {    // For simplicity, we'll expose a direct C function from a JNI wrapper    // In a real scenario, you'd find this function via static analysis in the .so    // This example assumes 'multiply_numbers' is directly exported or findable.    return a * b;}extern "C" JNIEXPORT jint JNICALLmultiply_numbers(jint a, jint b) {    return a * b;}

Assume the multiply_numbers function is exported in libnative-lib.so (default for Android Studio NDK projects).

Frida Script to Modify Arguments and Return Value (hook.js):

Java.perform(function() {    var libName = "libnative-lib.so";    var funcName = "multiply_numbers";    var targetModule = Module.findByName(libName);    if (!targetModule) {        console.error("Library " + libName + " not found!");        return;    }    var targetFunctionPtr = targetModule.findExportByName(funcName);    if (!targetFunctionPtr) {        console.error("Function " + funcName + " not found in " + libName + "!");        return;    }    console.log("Hooking " + funcName + " at " + targetFunctionPtr);    Interceptor.attach(targetFunctionPtr, {        onEnter: function(args) {            this.original_a = args[0].toInt32();            this.original_b = args[1].toInt32();            console.log(`[onEnter] Original call: multiply_numbers(${this.original_a}, ${this.original_b})`);            // Modify arguments: change 'a' to 5 and 'b' to 10            args[0] = ptr(5);            args[1] = ptr(10);            console.log(`[onEnter] Modified call: multiply_numbers(${args[0].toInt32()}, ${args[1].toInt32()})`);        },        onLeave: function(retval) {            console.log(`[onLeave] Original return value before modification: ${retval.toInt32()}`);            // Modify the return value to always be 99            retval.replace(ptr(99));            console.log(`[onLeave] Modified return value: ${retval.toInt32()}`);        }    });    console.log("Hook deployed successfully for " + funcName + "!");});

Running the Hook:

First, set up Frida server on your Android device (root required or inject using Gadget):

adb push frida-server /data/local/tmp/adb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"

Then, attach Frida to your running application (e.g., com.example.ndkcalculator) and load the script:

frida -U -l hook.js com.example.ndkcalculator

Now, whenever multiply_numbers is called in the app, Frida will intercept it, modify its arguments to 5 and 10, let the original function run with these new values, and then override the return value to 99, regardless of what the original function computed.

Conclusion

Frida provides unparalleled capabilities for reverse engineering Android NDK applications by enabling dynamic instrumentation and native function overriding. From basic interception to complete function replacement and intricate argument/return value manipulation, these advanced techniques empower reverse engineers to deeply analyze, debug, and modify the behavior of native code. Mastering these skills is essential for anyone serious about understanding the inner workings of complex Android applications and uncovering their secrets. Always remember to use these powerful tools ethically and responsibly for security research and legitimate analysis.

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