Android App Penetration Testing & Frida Hooks

Frida’s JNIEnv: Handling Complex C/C++ Objects and Pointers in Native Hooks

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction

Android applications frequently bridge the gap between Java/Kotlin and native C/C++ code using the Java Native Interface (JNI). While hooking simple native functions with Frida is often straightforward, interacting with complex Java objects, arrays, or raw C/C++ pointers passed across the JNI boundary presents unique challenges. This article delves into advanced JNI hooking techniques using Frida’s powerful JNIEnv proxy, empowering penetration testers and reverse engineers to manipulate intricate data structures within native contexts.

Understanding JNI and Native Methods

JNI acts as a foreign function interface, allowing Java code running in the Java Virtual Machine (JVM) to call and be called by native applications and libraries written in other languages like C and C++. Each native method registered with JNI receives a JNIEnv* pointer as its first argument (after JNIEnv* and jclass/jobject). This pointer is crucial; it provides access to a vast array of functions (the JNI function table) that allow native code to interact with the JVM, create Java objects, call Java methods, throw exceptions, and manipulate Java types.

A typical native method declaration in C++ looks like this:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_app_MainActivity_myNativeMethod(
        JNIEnv* env,
        jobject /* this */,
        jstring javaInputString,
        jobject javaCustomObject,
        jlong nativeStructAddress) {
    // Native implementation
}

Here, env is our gateway to JVM interaction, while javaInputString and javaCustomObject are opaque JNI references (`jstring`, `jobject`) that require JNIEnv methods to be dereferenced or manipulated.

The Challenge of Complex Types in Native Hooks

When hooking a native function, Frida provides the raw arguments. For primitive types (like jint, jboolean, jlong), these are directly usable in JavaScript. However, JNI types like jstring, jarray, or generic jobject are merely references to Java objects managed by the JVM. Simply reading them as JavaScript strings or objects will yield meaningless memory addresses.

Furthermore, native functions might receive or return raw C/C++ pointers (often cast to jlong when crossing the JNI boundary) that point to complex C/C++ structures or dynamically allocated memory. Without understanding how to interpret these pointers and their contents, introspection becomes impossible.

Frida’s JNIEnv Proxy: Your Gateway to the JVM

Frida provides an elegant solution to this problem through its JavaScript JNIEnv proxy. You can obtain a reference to the current thread’s JNIEnv object using Java.vm.getEnv(). This env object mirrors many of the essential functions available via the native JNIEnv* pointer, but exposed as JavaScript methods.

let env = Java.vm.getEnv();
// Now 'env' contains methods like env.getStringUtfChars, env.callObjectMethod, etc.

This proxy allows you to directly call JNI functions from your Frida script, enabling sophisticated interaction with Java objects and the JVM from within your native hooks.

Case Study 1: Hooking a Native Method Receiving jstring and jobject

Consider a native function that takes a Java string and a custom Java object. Let’s assume our target APK contains a native library libnative-lib.so with a function:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_app_MainActivity_stringFromJNI(
        JNIEnv* env,
        jobject thiz, // 'this' reference to the Java object
        jstring inputString,
        jobject customObject,
        jlong nativeStructPtr) { /* ... */ }

First, we need to locate the address of this native function. You can use tools like nm -D libnative-lib.so or Ghidra for static analysis, or Frida’s Module.findExportByName() for dynamic resolution.

Java.perform(function() {
    let libnativeLib = Module.findBaseAddress("libnative-lib.so");
    if (libnativeLib) {
        console.log("Found libnative-lib.so at: " + libnativeLib);
        
        // Resolve the exact function address
        let targetFunction = Module.findExportByName("libnative-lib.so", "Java_com_example_app_MainActivity_stringFromJNI");

        if (targetFunction) {
            console.log("Hooking Java_com_example_app_MainActivity_stringFromJNI at: " + targetFunction);

            Interceptor.attach(targetFunction, {
                onEnter: function(args) {
                    console.log("[+] Native method entered!");
                    this.env = Java.vm.getEnv(); // Get JNIEnv for this thread

                    // Arg 0 is JNIEnv*
                    // Arg 1 is jobject (this)
                    // Arg 2 is jstring inputString
                    // Arg 3 is jobject customObject
                    // Arg 4 is jlong nativeStructPtr

                    let jInputString = args[2];
                    let jCustomObject = args[3];

                    // 1. Extract string from jstring
                    let javaString = this.env.getStringUtfChars(jInputString, null).readUtf8String();
                    console.log("  Input Java String: " + javaString);

                    // 2. Interact with the custom Java object
                    // We need to know its class and method signatures
                    let customClass = this.env.getObjectClass(jCustomObject);
                    let getValueMethodId = this.env.getMethodId(customClass, "getValue", "()Ljava/lang/String;");
                    if (getValueMethodId) {
                        let jValue = this.env.callObjectMethod(jCustomObject, getValueMethodId);
                        let customValue = this.env.getStringUtfChars(jValue, null).readUtf8String();
                        console.log("  Custom Object Value: " + customValue);
                        // Remember to release local references if you create many, or for long-lived objects
                        this.env.deleteLocalRef(jValue);
                    }
                    this.env.deleteLocalRef(customClass);

                    // Store values for onLeave if needed
                    this.javaString = javaString;
                },
                onLeave: function(retval) {
                    console.log("[-] Native method exited.");
                    console.log("  Original string was: " + this.javaString);
                    // Modify the return value if it's a jstring
                    // this.env.newStringUtf("Hooked Return Value");
                }
            });
        } else {
            console.log("Target function not found.");
        }
    }
});

In this script, this.env.getStringUtfChars() is used to convert the jstring into a C-style UTF-8 string, which Frida’s Memory.readUtf8String() can then read. Similarly, we use this.env.getObjectClass() and this.env.getMethodId() to reflect on the Java object and call its methods using this.env.callObjectMethod().

Case Study 2: Handling C/C++ Pointers and Structures

Native functions often work directly with memory addresses, especially when dealing with custom C/C++ structures or large data buffers. A jlong argument in JNI is frequently used to pass a raw memory address (a void* or a pointer to a struct) from Java to native code, as jlong can hold a 64-bit address.

Let’s extend our example where nativeStructPtr is a jlong pointing to a CustomNativeStruct:

struct CustomNativeStruct {
    int id;
    char name[32];
    void* data_ptr; // Pointer to more data
};

Within the onEnter hook, we can read this pointer and its contents:

// ... inside onEnter hook

let nativeStructAddress = args[4]; // jlong argument is a Number in JS, representing the address

if (nativeStructAddress.compare(0) > 0) { // Check if pointer is not null
    console.log("  Native Struct Pointer: " + nativeStructAddress);

    // Define the C structure in Frida for easier access
    // Using NativePointer.read* methods is also an option.
    let CustomNativeStruct = new (Java.use("java.lang.Object").$new().getClass().forName("java.lang.Integer"))({
        id: 'int',
        name: ['char', 32],
        data_ptr: 'pointer'
    });
    // Note: The above is a simplified concept; direct struct definition is complex.
    // A more practical approach is manual memory reading for complex structs.

    // Manual memory reading for CustomNativeStruct:
    let structId = nativeStructAddress.readInt();
    let structName = nativeStructAddress.add(4).readUtf8String(32); // name starts at offset 4, max 32 bytes
    let structDataPtr = nativeStructAddress.add(4 + 32).readPointer(); // data_ptr after id (4 bytes) and name (32 bytes)

    console.log("  Native Struct -> ID: " + structId);
    console.log("  Native Struct -> Name: " + structName);
    console.log("  Native Struct -> Data Ptr: " + structDataPtr);

    if (structDataPtr.compare(0) > 0) {
        // Example: If data_ptr points to a C string
        let pointedData = structDataPtr.readUtf8String();
        console.log("  Data Ptr points to string: " + pointedData);
    }
}

Frida’s NativePointer methods like readInt(), readUtf8String(), add(), and readPointer() are invaluable for navigating and interpreting memory pointed to by raw addresses. For more complex C++ objects (e.g., those with virtual tables), you would need to combine this with an understanding of the object’s memory layout and potentially RTTI information.

Advanced Techniques: Creating and Passing Java Objects

Frida’s JNIEnv also allows you to create new Java objects or obtain references to existing ones from within your native hook. This is powerful for injecting test data or modifying execution flow.

  • Creating new Java Strings:let newJavaString = this.env.newStringUtf("Injected string from Frida");
  • Finding and instantiating Java classes:
    let StringClass = this.env.findClass("java/lang/String");
    let constructorId = this.env.getMethodId(StringClass, "", "([B)V"); // Constructor taking byte array
    let byteArray = this.env.newByteArray(5);
    this.env.setByteArrayRegion(byteArray, 0, 5, [72, 101, 108, 108, 111]); // "Hello"
    let newString = this.env.newObject(StringClass, constructorId, byteArray);
    this.env.deleteLocalRef(StringClass);
    this.env.deleteLocalRef(constructorId);
    this.env.deleteLocalRef(byteArray);
    
  • Calling static Java methods:let SystemClass = this.env.findClass("java/lang/System"); let currentTimeMillisMethod = this.env.getStaticMethodId(SystemClass, "currentTimeMillis", "()J"); let time = this.env.callStaticLongMethod(SystemClass, currentTimeMillisMethod);

These new objects or references can then be passed to other JNI functions or used to replace arguments of the current hooked function on onEnter.

Conclusion

Frida’s JNIEnv proxy elevates native Android penetration testing from mere function hooking to deep introspection and manipulation of the entire Java-Native ecosystem. By mastering the JNIEnv methods, along with Frida’s powerful memory manipulation capabilities, you gain the ability to accurately interpret complex Java objects, dissect intricate C/C++ structures pointed to by raw addresses, and even inject custom data or objects into the application’s native execution flow. This level of control is indispensable for advanced vulnerability research, bypass development, and understanding the intricate workings of Android apps at their core.

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