Android App Penetration Testing & Frida Hooks

Understanding JNI & Native Calls: A Frida ARM64 Guide for Android App Analysis

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to JNI, Native Calls, and Frida on ARM64

Android applications often leverage Java Native Interface (JNI) to execute high-performance or platform-specific code written in C/C++. This native code can be a goldmine for security researchers, containing critical algorithms, obfuscated logic, or sensitive data handling. Analyzing these native libraries, especially on ARM64 architectures prevalent in modern Android devices, requires specialized tools and techniques. Frida, a dynamic instrumentation toolkit, stands out as an indispensable tool for runtime analysis, allowing us to hook, inspect, and modify native function calls without recompiling the application.

This guide will demystify JNI interactions and native function hooking on ARM64 using Frida, providing practical steps and code examples for Android app penetration testers.

The Java Native Interface (JNI) Primer

JNI is the bridge between Java and native code. When an Android app calls a native method, the Java Virtual Machine (JVM) loads a shared library (.so file) and looks for a corresponding native function. There are two primary ways to link Java methods to native functions:

  • Dynamic Linking (Implicit): The most common method. Java declares a native method, and the JVM automatically resolves it to a C/C++ function following a specific naming convention: Java_PackageName_ClassName_MethodName. For example, Java_com_example_app_NativeLib_performCalc.
  • Static Linking (Explicit): Native code explicitly registers methods using RegisterNatives, typically within the JNI_OnLoad function. This allows for arbitrary native function names and is often used for obfuscation or more flexible linking.

Native functions called via JNI always receive at least two arguments: JNIEnv* env and jclass clazz (for static methods) or jobject thiz (for instance methods), followed by any user-defined arguments.

Frida Basics for Native ARM64 Hooking

Before diving into ARM64 specifics, let’s review essential Frida concepts for native hooking:

  1. Attach to Process: Start Frida and attach to the target Android application process.frida -U -f com.example.app --no-pauserequire('frida-trace').setup({ ... })
  2. Module Enumeration: Identify loaded native libraries (.so files) using Process.enumerateModules() or Module.findByName().
  3. Symbol Resolution: Find the address of exported functions within a module using Module.findExportByName(moduleName, exportName).
  4. Interceptor.attach: The core mechanism for hooking. It takes the function address and an object with onEnter and onLeave callbacks. These callbacks provide a this context, which includes CPU registers (e.g., this.context.x0 for ARM64) and stack information.

ARM64 Calling Convention Highlights

Understanding the ARM64 (AArch64) calling convention is crucial for correctly interpreting arguments and return values:

  • Argument Passing: The first eight arguments (up to 64 bits each) are passed in registers x0 through x7. Additional arguments are pushed onto the stack.
  • Return Value: The return value (up to 64 bits) is stored in register x0.
  • Callee-Saved Registers: Registers x19 to x30 must be preserved by the called function if they are used.
  • Link Register (LR): Register x30 holds the return address.
  • Stack Pointer (SP): Register sp points to the top of the stack.

When hooking native functions, you’ll primarily interact with x0-x7 to read or modify arguments and x0 to modify return values.

Practical Example: Hooking a Native Encryption Function

Let’s imagine an Android app uses a native library libnativecrypto.so with a JNI function Java_com_example_app_CryptoUtils_encryptData that internally calls native_aes_encrypt.

1. Identifying Native Functions

First, we need to locate our target functions. We can use Frida’s `Module.enumerateExports()` or `frida-trace`.

// Using Frida REPL or script to enumerate exportsfrida -U -f com.example.app --no-pauserequire('frida-trace').setup({  decorate: function(exports) {    return ['*!*encrypt*']; // Trace all exports containing 'encrypt'  }});

Alternatively, if you have the .so file, use nm -D libnativecrypto.so to list exported symbols, or analyze with Ghidra/IDA Pro to find internal function names and offsets.

2. Hooking a JNI-Exported Function

Let’s hook Java_com_example_app_CryptoUtils_encryptData, which takes JNIEnv*, jclass, jbyteArray (input data), and jbyteArray (key).

// frida_jni_hook.jsAgent.onRuntimeInitialized = function() {    const targetModule = Module.findByName("libnativecrypto.so");    if (!targetModule) {        console.error("libnativecrypto.so not found!");        return;    }    const encryptDataPtr = targetModule.findExportByName("Java_com_example_app_CryptoUtils_encryptData");    if (!encryptDataPtr) {        console.error("Java_com_example_app_CryptoUtils_encryptData not found!");        return;    }    console.log("[*] Hooking Java_com_example_app_CryptoUtils_encryptData at " + encryptDataPtr);    Interceptor.attach(encryptDataPtr, {        onEnter: function (args) {            console.log("n[!] Inside Java_com_example_app_CryptoUtils_encryptData");            // JNIEnv* env = args[0]            // jclass clazz = args[1]            // jbyteArray data = args[2]            // jbyteArray key = args[3]            this.data = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[2], null).readByteArray(Java.vm.getEnv().getArrayLength(args[2])) : Memory.readByteArray(args[2].add(0x10), 256); // Simplified access            this.key = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(args[3], null).readByteArray(Java.vm.getEnv().getArrayLength(args[3])) : Memory.readByteArray(args[3].add(0x10), 32); // Simplified access            console.log("  Original Data: " + hexdump(this.data, { length: Math.min(this.data.byteLength, 32) }));            console.log("  Encryption Key: " + hexdump(this.key, { length: Math.min(this.key.byteLength, 32) }));        },        onLeave: function (retval) {            // retval is jbyteArray for the encrypted data            let encryptedResult = new Java.vm.get === 'ART' ? Java.vm.getEnv().getByteArrayElements(retval, null).readByteArray(Java.vm.getEnv().getArrayLength(retval)) : Memory.readByteArray(retval.add(0x10), 256); // Simplified            console.log("  Encrypted Result: " + hexdump(encryptedResult, { length: Math.min(encryptedResult.byteLength, 32) }));            // Optionally modify return value:            // Memory.writeUtf8String(retval, "MODIFIED_RESULT");        }    });};

Explanation of `onEnter` arguments: On ARM64, `args[0]` maps to register `x0`, `args[1]` to `x1`, and so on. In our JNI function, `args[0]` is `JNIEnv*`, `args[1]` is `jclass`, `args[2]` is the `jbyteArray` for data, and `args[3]` is `jbyteArray` for the key. Reading `jbyteArray` contents from a `jobject` requires using `JNIEnv` methods, which can be accessed via `Java.vm.getEnv()`. The simplified `Memory.readByteArray(args[X].add(0x10), …)` is a common heuristic for direct memory access if the `jbyteArray` object pointer itself contains a pointer to the actual array data at a fixed offset, but using `JNIEnv` methods is more robust.

3. Hooking an Internal Native Function (ARM64 Register Awareness)

Now, let’s assume `Java_com_example_app_CryptoUtils_encryptData` internally calls `native_aes_encrypt(char* data, int dataLen, char* key, int keyLen)`. This function is not exported via JNI but is a regular C function. We would typically find its address via reverse engineering (Ghidra/IDA) or `frida-trace` showing calls from the JNI function.

// Continuing frida_jni_hook.jsconst nativeAesEncryptPtr = targetModule.base.add(0x12345); // Replace 0x12345 with actual offset from Ghidra/IDAif (!nativeAesEncryptPtr) {    console.error("native_aes_encrypt not found!");    return;}console.log("[*] Hooking native_aes_encrypt at " + nativeAesEncryptPtr);Interceptor.attach(nativeAesEncryptPtr, {    onEnter: function (args) {        console.log("n[!] Inside native_aes_encrypt");        // ARM64 calling convention:        // x0 = char* data        // x1 = int dataLen        // x2 = char* key        // x3 = int keyLen        this.dataPtr = args[0];        this.dataLen = args[1].toInt32();        this.keyPtr = args[2];        this.keyLen = args[3].toInt32();        console.log("  Data Buffer Address (x0): " + this.dataPtr);        console.log("  Data Length (x1): " + this.dataLen);        console.log("  Key Buffer Address (x2): " + this.keyPtr);        console.log("  Key Length (x3): " + this.keyLen);        // Read contents from memory addresses        console.log("  Data: " + hexdump(Memory.readByteArray(this.dataPtr, Math.min(this.dataLen, 32))));        console.log("  Key: " + hexdump(Memory.readByteArray(this.keyPtr, Math.min(this.keyLen, 32))));        // Example: modify input data        // Memory.writeUtf8String(this.dataPtr, "MODIFIED_INPUT_DATA");    },    onLeave: function (retval) {        // x0 contains the return value (e.g., pointer to encrypted data or status code)        console.log("  native_aes_encrypt Returned (x0): " + retval);        // If retval is a pointer to the result, you can read it:        // console.log("  Encrypted Result from native_aes_encrypt: " + hexdump(Memory.readByteArray(retval, 32)));    }});

Explanation of `onEnter` arguments for internal functions: Here, args[0] directly corresponds to the first function argument (char* data) passed in x0, args[1] to `x1` (int dataLen), and so on. We use `toInt32()` for `dataLen` and `keyLen` because `args` elements are `NativePointer` objects, and an `int` would be stored as a 64-bit value in the register. `Memory.readByteArray` is used to inspect the buffer contents directly from the pointers.

Conclusion

Frida provides an unparalleled capability to inspect and manipulate native code at runtime, a critical skill for Android app penetration testing. By understanding JNI’s interaction with native libraries and the ARM64 calling convention, you can accurately identify target functions, interpret arguments passed in registers, and effectively hook internal logic. Whether it’s to bypass obfuscation, understand proprietary algorithms, or discover vulnerabilities, mastering Frida for native analysis on ARM64 empowers you to delve deeper into the heart of Android applications.

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