Android App Penetration Testing & Frida Hooks

Unmasking Secrets: Exfiltrating Data from Obfuscated JNI with Frida Scripts

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction: The Opaque World of Obfuscated JNI

Android applications frequently leverage the Java Native Interface (JNI) to execute performance-critical code or protect sensitive logic by moving it into native libraries (.so files). This native code, written in languages like C/C++, offers advantages in speed and direct hardware access. However, from a security perspective, it’s often used to hide critical algorithms, cryptographic keys, or proprietary business logic, making reverse engineering more challenging. When combined with code obfuscation techniques applied to these native libraries, uncovering the secrets within becomes a formidable task.

This article dives deep into using Frida, a dynamic instrumentation toolkit, to penetrate the defenses of obfuscated JNI implementations. We’ll explore practical strategies and Frida scripts to hook native functions, analyze their arguments and return values, and ultimately exfiltrate sensitive data, even when function names are mangled or dynamically registered.

Setting the Stage: Environment Setup and Core Concepts

Before we begin our unmasking journey, ensure you have the necessary tools:

  • Rooted Android Device or Emulator: Frida requires root access to inject its agent into target processes.
  • ADB (Android Debug Bridge): For pushing files, installing apps, and interacting with the device shell.
  • Frida-CLI: The Frida command-line tools (frida, frida-ps, frida-trace). Install via pip install frida-tools.
  • Frida-Server: The server component running on the Android device. Download the appropriate version from Frida’s GitHub releases (e.g., frida-server-*-android-arm64).

Frida Server Setup:

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

Understanding JNI Method Registration:

Native methods in Android are typically registered in two ways:

  1. Static Registration: Publicly exported functions with specific naming conventions (e.g., Java_com_example_app_NativeClass_nativeMethod). Easier to locate.
  2. Dynamic Registration: Using RegisterNatives within JNI_OnLoad. This allows native functions to have arbitrary names, making static analysis difficult, especially with obfuscation.

Phase 1: Identifying Target Native Libraries and Functions

Our first step is to identify which native library (.so file) contains the JNI methods we’re interested in. We can often infer this from the application’s package structure or by examining the /data/app/your.package.name/lib/ directory on the device.

adb shell "ls /data/app/your.package.name*/lib/"

Once identified, say libobfuscated.so, we can begin probing.

Hooking JNI_OnLoad for Dynamic Registration Discovery

Many sophisticated applications dynamically register their native methods within the JNI_OnLoad function. By hooking this function, we can intercept the calls to RegisterNatives and discover the actual native function pointers and their corresponding Java method signatures.

Java.perform(function() {
    const libname = "libobfuscated.so";
    const module = Module.findBaseAddress(libname);

    if (module) {
        console.log("[+] Target library '" + libname + "' loaded at " + module);
        
        const JNI_OnLoad_ptr = Module.findExportByName(libname, "JNI_OnLoad");
        if (JNI_OnLoad_ptr) {
            console.log("[+] JNI_OnLoad found at " + JNI_OnLoad_ptr);

            Interceptor.attach(JNI_OnLoad_ptr, {
                onEnter: function(args) {
                    console.log("[+] JNI_OnLoad called!");
                    this.env = args[0]; // JNIEnv*
                    this.vm = args[1];  // JavaVM*
                },
                onLeave: function(retval) {
                    console.log("[+] JNI_OnLoad returned: " + retval);
                }
            });

            // Hook RegisterNatives to dump dynamic registrations
            const JNIEnv = Java.vm.getEnv();
            const RegisterNativesPtr = JNIEnv.handle.add(230 * Process.pointerSize); // Offset for RegisterNatives
            
            Interceptor.attach(RegisterNativesPtr, {
                onEnter: function(args) {
                    console.log("[+] RegisterNatives called!");
                    this.env = args[0];
                    this.javaClass = Java.vm.getEnv().getClassName(args[1]);
                    this.methods = args[2];
                    this.numMethods = args[3].toInt32();

                    console.log("    Java Class: " + this.javaClass);
                    console.log("    Number of methods: " + this.numMethods);

                    for (let i = 0; i < this.numMethods; i++) {
                        const method = this.methods.add(i * (3 * Process.pointerSize));
                        const namePtr = method.readPointer();
                        const signaturePtr = method.add(Process.pointerSize).readPointer();
                        const fnPtr = method.add(2 * Process.pointerSize).readPointer();

                        const name = namePtr.readCString();
                        const signature = signaturePtr.readCString();
                        
                        console.log("        Method " + (i + 1) + ":");
                        console.log("            Name: " + name);
                        console.log("            Signature: " + signature);
                        console.log("            Native Function Pointer: " + fnPtr);
                    }
                }
            });
        } else {
            console.log("[-] JNI_OnLoad not found in " + libname);
        }
    } else {
        console.log("[-] Library '" + libname + "' not found.");
    }
});

Run this script using `frida -U -l your_script.js -f your.package.name –no-pause`. This will output all dynamically registered native methods, their Java signatures, and their actual native function pointers. This information is invaluable for subsequent, more targeted hooking.

Phase 2: Hooking Obfuscated Native Methods and Exfiltrating Data

Once we have the native function pointers (either from JNI_OnLoad interception or static analysis for known exports), we can proceed to hook them directly. The challenge with obfuscated methods is often deciphering their arguments and return types. We’ll focus on common data types and strategies.

Example: Hooking a Specific Native Function

Let’s assume our previous step revealed a native function pointer, say 0x12345678, corresponding to a Java method like nativeGetData(Ljava/lang/String;[B)Ljava/lang/String;. This method takes a String and a byte array, and returns a String. Our goal is to extract the input string and the returned string.

Java.perform(function() {
    const targetNativePtr = new NativePointer("0x12345678"); // Replace with your target address

    Interceptor.attach(targetNativePtr, {
        onEnter: function(args) {
            console.log("[+] nativeGetData called!");
            this.env = args[0]; // JNIEnv*
            
            // JNI methods typically start with JNIEnv*, followed by jobject (this) or jclass
            // Then actual arguments start from args[2] onwards for non-static, args[1] for static
            // For simplicity, let's assume it's an instance method and arguments start from args[2]

            // Argument 1: jstring (Java String)
            const jstrArg = args[2];
            const javaStr = this.env.getStringUtfChars(jstrArg, null).readCString();
            console.log("    Input String: " + javaStr);
            this.inputString = javaStr; // Store for onLeave if needed

            // Argument 2: jbyteArray (Java byte array)
            const jbyteArrayArg = args[3];
            const byteArrayLen = this.env.getArrayLength(jbyteArrayArg);
            const byteArrayElements = this.env.getByteArrayElements(jbyteArrayArg, null);
            console.log("    Input Byte Array (length: " + byteArrayLen + "):n" + hexdump(byteArrayElements, { length: Math.min(byteArrayLen, 64) })); // Dump first 64 bytes
            this.env.releaseByteArrayElements(jbyteArrayArg, byteArrayElements, 0); // Release elements
        },
        onLeave: function(retval) {
            console.log("[+] nativeGetData returned!");
            // Return value: jstring
            const jstrRet = retval;
            if (jstrRet.isNull()) {
                console.log("    Returned String: null");
            } else {
                const javaStrRet = this.env.getStringUtfChars(jstrRet, null).readCString();
                console.log("    Returned String: " + javaStrRet);
            }
            console.log("--------------------------------------------------");
        }
    });
    console.log("[+] Hooked target native function at " + targetNativePtr);
});

Handling Different JNI Data Types:

The JNIEnv* pointer (args[0]) is crucial for interacting with Java objects. Here’s how to handle common types:

  • jstring: Use env.getStringUtfChars(jstring, isCopy) to get a C-style UTF-8 string pointer, then .readCString(). Remember to call env.releaseStringUtfChars(jstring, c_string).
  • jbyteArray: Use env.getByteArrayElements(jbyteArray, isCopy) to get a C pointer to the array data, then Memory.readByteArray(ptr, length) or hexdump(). Release with env.releaseByteArrayElements(...).
  • jint, jboolean, jfloat, etc.: These are primitive types and can often be read directly from the NativePointer using .toInt32(), .toBoolean(), .toFloat(), etc.
  • jobject (Generic Java Object): For more complex objects, you might need to inspect its class using env.GetObjectClass(jobject) and then use Frida’s `Java.cast()` to cast it to a known Java type and access its fields or methods. This often requires prior knowledge of the Java class structure.

Phase 3: De-obfuscating and Decrypting Data on the Fly

Sometimes, the exfiltrated data might still be obfuscated or encrypted. Our Frida scripts can be extended to perform real-time de-obfuscation or decryption, provided we can identify the algorithms or keys.

Scenario: Encrypted String Argument

Imagine a native function that takes an encrypted string as an argument, decrypts it, processes it, and returns another encrypted string. If we can identify the decryption key/algorithm (e.g., from static analysis or by observing other parts of the app), we can integrate it into our hook.

Java.perform(function() {
    const cryptoNativePtr = new NativePointer("0xDEADBEEF"); // Hypothetical crypto function

    Interceptor.attach(cryptoNativePtr, {
        onEnter: function(args) {
            this.env = args[0];
            const encryptedJstr = args[2]; // Assuming jstring encrypted data
            const encryptedCstr = this.env.getStringUtfChars(encryptedJstr, null).readCString();
            
            // Hypothetical decryption logic (replace with actual logic)
            function decrypt(data) {
                // Example: simple XOR with a known key
                const key = "SECRET_KEY";
                let decrypted = "";
                for (let i = 0; i < data.length; i++) {
                    decrypted += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length));
                }
                return decrypted;
            }

            const decryptedData = decrypt(encryptedCstr);
            console.log("[+] Intercepted Encrypted Input: " + encryptedCstr);
            console.log("    Decrypted Input: " + decryptedData);

            this.env.releaseStringUtfChars(encryptedJstr, encryptedCstr);
        },
        onLeave: function(retval) {
            if (!retval.isNull()) {
                const encryptedJstrRet = retval;
                const encryptedCstrRet = this.env.getStringUtfChars(encryptedJstrRet, null).readCString();
                
                function decrypt(data) {
                    const key = "SECRET_KEY"; // Same key
                    let decrypted = "";
                    for (let i = 0; i < data.length; i++) {
                        decrypted += String.fromCharCode(data.charCodeAt(i) ^ key.charCodeAt(i % key.length));
                    }
                    return decrypted;
                }

                const decryptedRet = decrypt(encryptedCstrRet);
                console.log("    Intercepted Encrypted Return: " + encryptedCstrRet);
                console.log("    Decrypted Return: " + decryptedRet);
                this.env.releaseStringUtfChars(encryptedJstrRet, encryptedCstrRet);
            }
            console.log("--------------------------------------------------");
        }
    });
    console.log("[+] Hooked crypto native function at " + cryptoNativePtr);
});

This example highlights how Frida’s versatility allows you not just to observe, but also to process and transform the data in real-time within your hooks. Identifying the decryption algorithm and key often requires a combination of static analysis (e.g., using Ghidra or IDA Pro to analyze the native library) and dynamic observation.

Conclusion

Exfiltrating data from obfuscated JNI methods in Android applications is a challenging but achievable task with powerful tools like Frida. By understanding JNI’s mechanics, particularly dynamic method registration, and leveraging Frida’s dynamic instrumentation capabilities, security researchers and penetration testers can effectively bypass native code protections. The key lies in systematic identification of target functions, careful analysis of argument and return types, and the ability to integrate custom data processing logic directly into your Frida scripts. As obfuscation techniques evolve, so too must our dynamic analysis strategies to continue unmasking the secrets hidden within native code.

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