Android Software Reverse Engineering & Decompilation

Advanced Frida: Modifying JNI Arguments and Return Values in Android Native Functions

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to JNI and Frida for Reverse Engineering

The Android ecosystem extensively utilizes the Java Native Interface (JNI) to bridge the gap between Java/Kotlin code and native libraries written in C/C++. This allows developers to leverage existing C/C++ codebases, achieve performance critical operations, or implement security-sensitive logic outside the reach of typical Java-level introspection. For reverse engineers and security researchers, interacting with and manipulating these native functions becomes crucial for understanding application behavior, bypassing security controls, or injecting custom logic. Frida, a dynamic instrumentation toolkit, provides unparalleled capabilities for hooking and modifying code at runtime, making it an indispensable tool for JNI manipulation.

This article delves into advanced techniques for using Frida to inspect and modify arguments passed to, and return values received from, Android native functions. We will explore how to identify native functions, understand their JNI signatures, and use Frida’s powerful Interceptor API to achieve sophisticated runtime manipulation.

Setting Up Your Environment

Before diving in, ensure you have the necessary tools:

  • A rooted Android device or emulator.
  • ADB (Android Debug Bridge) installed and configured on your host machine.
  • Frida client (pip install frida-tools) and Frida server running on your Android device.

Running Frida Server

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

Identifying Native Functions

The first step in hooking a native function is to find its symbol. Native libraries are typically .so files located in the app’s lib directory (e.g., /data/app/com.example.app/lib/arm64/libnativelib.so). You can use various methods to find function symbols:

  • nm or readelf: On Linux/macOS, you can extract the .so file and use these tools to list symbols.
adb pull /data/app/com.example.app/lib/arm64/libnativelib.so .nm -D libnativelib.so | grep Java_
  • Static Analysis Tools (Ghidra/IDA Pro): These disassemblers provide a comprehensive view of the native library, including function names, arguments, and return types, which is essential for understanding complex JNI functions.
  • Frida’s Module.enumerateExports: Dynamically enumerate exports once the library is loaded.
Java.perform(function() {    var libName = "libnativelib.so";    var lib = Module.findBaseAddress(libName);    if (lib) {        console.log("[*] " + libName + " loaded at: " + lib);        Module.enumerateExportsSync(libName).forEach(function(exp) {            if (exp.name.startsWith("Java_")) {                console.log("  " + exp.name + ": " + exp.address);            }        });    } else {        console.log("[*] " + libName + " not found.");    }});

Basic JNI Hooking with Frida

JNI native functions follow a specific calling convention. The first argument is always a pointer to the JNIEnv interface, and the second is usually a jobject (for non-static methods) or jclass (for static methods) representing the Java object/class. Subsequent arguments correspond to the parameters passed from Java.

Let’s consider a native function:

JNIEXPORT jboolean JNICALL Java_com_example_app_NativeLib_checkLicense(JNIEnv* env, jobject thiz, jstring licenseKey, jint userId) {    // ... implementation ...}

To hook this, we first need its address and then use Interceptor.attach.

Java.perform(function() {    var libName = "libnativelib.so";    var funcName = "Java_com_example_app_NativeLib_checkLicense";    var libBase = Module.findBaseAddress(libName);    if (!libBase) {        console.log("[-] " + libName + " not loaded yet. Retrying...");        // Or use Module.load() if it's not loaded at all        return;    }    var funcAddress = Module.findExportByName(libName, funcName);    if (!funcAddress) {        console.log("[-] Function " + funcName + " not found.");        return;    }    console.log("[+] Hooking " + funcName + " at " + funcAddress);    Interceptor.attach(funcAddress, {        onEnter: function(args) {            console.log("[*] Entering " + funcName);            // args[0] is JNIEnv*            // args[1] is jobject (this)            // args[2] is jstring licenseKey            // args[3] is jint userId            this.licenseKeyPtr = args[2];            this.userId = args[3].toInt32();            console.log("  Original licenseKey (ptr): " + this.licenseKeyPtr);            console.log("  Original userId: " + this.userId);            // Convert jstring to JavaScript string for logging            var env = this.context.r0; // On ARM32, R0 usually holds JNIEnv*            // For ARM64, it's x0. A safer way is to use ptr(args[0])            var JNIEnv = new Java.API.JNIEnv(args[0]);            var originalLicenseKey = JNIEnv.getStringUtfChars(this.licenseKeyPtr, null).readCString();            console.log("  Original licenseKey (string): " + originalLicenseKey);        },        onLeave: function(retval) {            console.log("[*] Leaving " + funcName);            console.log("  Original return value: " + retval.toInt32());        }    });});

Modifying JNI Arguments

Frida allows you to directly manipulate the arguments passed to a native function in the onEnter callback. You access arguments via the args array (e.g., args[0], args[1], etc.). Each element in args is a NativePointer, representing the memory address of the argument.

Modifying a jint (Integer) Argument

To change an integer, you simply write a new value to its corresponding `NativePointer` using `replace`.

// ... inside onEnter for Java_com_example_app_NativeLib_checkLicensevar desiredUserId = 1337;console.log("  Modifying userId from " + this.userId + " to " + desiredUserId);args[3] = new NativePointer(desiredUserId); // or ptr(desiredUserId)

Modifying a jstring (String) Argument

Modifying a string is slightly more complex because jstring is a pointer to a Java string object, not a C-style string buffer. You need to use the JNIEnv functions to create a new jstring and then replace the argument.

// ... inside onEnter for Java_com_example_app_NativeLib_checkLicensevar JNIEnv = new Java.API.JNIEnv(args[0]);var newLicenseKeyString = "FRIDA_BYPASS_KEY_12345";var newLicenseKeyJstring = JNIEnv.newStringUtf(newLicenseKeyString);console.log("  Modifying licenseKey from '" + originalLicenseKey + "' to '" + newLicenseKeyString + "'");args[2] = newLicenseKeyJstring;

Modifying JNI Return Values

The onLeave callback is where you can inspect and modify the return value of a native function. The return value is exposed as retval, which is also a NativePointer.

Bypassing a License Check (jboolean return)

If Java_com_example_app_NativeLib_checkLicense returns jboolean (which is essentially a jint with 0 or 1), you can force it to return true (1).

// ... inside onLeave for Java_com_example_app_NativeLib_checkLicensevar originalRetVal = retval.toInt32();var newRetVal = 1; // jboolean trueconsole.log("  Modifying return value from " + originalRetVal + " to " + newRetVal + " (TRUE)");retval.replace(ptr(newRetVal)); // Set the new return value

Putting It All Together: A Complete Bypass Example

Let’s create a comprehensive script to bypass a hypothetical license check native function by modifying both its input arguments and forcing a successful return value.

Java.perform(function() {    var libName = "libnativelib.so";    var funcName = "Java_com_example_app_NativeLib_checkLicense";    // Wait for the library to be loaded    var libBase = null;    try {        libBase = Module.findBaseAddress(libName);    } catch (e) {        // Module not found, wait for it        console.log("[-] " + libName + " not loaded yet, attempting to hook Module.load.");    }    if (!libBase) {        Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {            onEnter: function (args) {                var path = args[0].readCString();                if (path.indexOf(libName) !== -1) {                    console.log("[+] Detected dlopen for " + libName + ". Waiting 100ms...");                    this.shouldHook = true;                }            },            onLeave: function (retval) {                if (this.shouldHook) {                    // Give it a moment to fully initialize                    setTimeout(function() {                        hookNativeFunction();                    }, 100);                }            }        });    } else {        hookNativeFunction();    }    function hookNativeFunction() {        var funcAddress = Module.findExportByName(libName, funcName);        if (!funcAddress) {            console.log("[-] Function " + funcName + " not found after library load.");            return;        }        console.log("[+] Hooking " + funcName + " at " + funcAddress);        Interceptor.attach(funcAddress, {            onEnter: function(args) {                console.log("n[*] Entering " + funcName);                var JNIEnv = new Java.API.JNIEnv(args[0]);                // Store original values for logging in onLeave                this.originalLicenseKeyPtr = args[2];                this.originalUserId = args[3].toInt32();                var originalLicenseKey = JNIEnv.getStringUtfChars(this.originalLicenseKeyPtr, null).readCString();                console.log("  Original licenseKey: '" + originalLicenseKey + "'");                console.log("  Original userId: " + this.originalUserId);                // --- MODIFY ARGUMENTS ---                var newLicenseKeyString = "ADVANCED_FRIDA_BYPASS_SUCCESS";                var desiredUserId = 99999;                var newLicenseKeyJstring = JNIEnv.newStringUtf(newLicenseKeyString);                args[2] = newLicenseKeyJstring; // Replace licenseKey argument                args[3] = ptr(desiredUserId);   // Replace userId argument                console.log("  Modified licenseKey to: '" + newLicenseKeyString + "'");                console.log("  Modified userId to: " + desiredUserId);            },            onLeave: function(retval) {                console.log("[*] Leaving " + funcName);                var originalRetVal = retval.toInt32();                console.log("  Original return value: " + originalRetVal + " (0=false, 1=true)");                // --- MODIFY RETURN VALUE ---                var newRetVal = 1; // Force true                retval.replace(ptr(newRetVal));                console.log("  Forced return value to: " + newRetVal + " (TRUE)");                console.log("[*] " + funcName + " bypass completed!n");            }        });    }});

Advanced Considerations and Bypasses

Anti-Frida Techniques

Applications may employ anti-tampering mechanisms, such as checking for Frida server processes, inspecting /proc/self/maps for Frida agent injections, or verifying the integrity of native libraries. Bypassing these often requires more sophisticated techniques, such as modifying Frida’s agent itself or employing advanced injection methods.

Memory Management

When creating new JNI objects (like JNIEnv.newStringUtf), remember that these are Java objects. While Frida manages some of this, be mindful of potential memory leaks if you repeatedly create large objects without proper management, though for argument replacement, it’s usually handled by the JNI environment.

Complex Data Structures

For more complex JNI types like jbyteArray, jobjectArray, or custom jobjects, you’ll need to use appropriate JNIEnv functions (e.g., GetByteArrayElements, NewByteArray, GetObjectClass, GetMethodID, CallObjectMethod) to read, create, and write data within the Java/JNI heap. Frida’s Java.cast() and Java.use() can also be invaluable here to interact with Java objects directly.

Conclusion

Frida offers an incredibly powerful framework for dynamic instrumentation of Android applications, particularly when it comes to JNI functions. By understanding JNI calling conventions and leveraging Frida’s Interceptor API along with the JNIEnv functions, you can gain deep control over native execution, modifying arguments and return values to bypass checks, alter logic, or simply gain a clearer insight into the application’s inner workings. This mastery is a cornerstone of advanced Android reverse engineering and security research.

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