Android App Penetration Testing & Frida Hooks

From Obfuscation to Cleartext: Unpacking Android ARM64 Native Secrets with Frida

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction: The Native Code Labyrinth

Android applications often rely on native code for performance-critical operations, cryptographic routines, or to protect intellectual property. While Java/Kotlin code is relatively straightforward to decompile and analyze, native libraries, especially those compiled for ARM64 architecture, present a more formidable challenge. Developers frequently employ obfuscation techniques to obscure critical logic and sensitive data within these libraries, turning a simple reverse engineering task into a complex puzzle. This article dives deep into using Frida, a dynamic instrumentation toolkit, to cut through this obfuscation, inspect ARM64 native functions, and extract hidden secrets in real-time.

Why Native Code? Challenges of ARM64 Analysis

Native code (typically C/C++ compiled into .so files) is a double-edged sword. It offers significant performance benefits and closer interaction with the underlying system, but also presents a steeper learning curve for security analysts. For many applications, particularly those handling sensitive data like financial apps or DRM, core logic is moved to native code to deter tampering and reverse engineering. When combined with obfuscation, analyzing these binaries becomes a test of patience and skill.

ARM64 (AArch64) architecture adds another layer of complexity. Unlike its 32-bit predecessor (ARM32), ARM64 utilizes a larger register set (x0-x30), a different calling convention (arguments passed in x0-x7 registers, return value in x0), and a new instruction set. Traditional static analysis tools like Ghidra or IDA Pro are powerful, but dynamic analysis with Frida allows us to observe the code’s behavior during execution, bypassing many static obfuscation tricks.

Prerequisites for Your Frida Journey

Before we embark, ensure you have the following setup:

  • Rooted Android Device/Emulator: Essential for running frida-server.
  • Frida-tools: Installed on your host machine (pip install frida-tools).
  • ADB (Android Debug Bridge): For interacting with your Android device.
  • Basic ARM64 Assembly Knowledge: Helpful for understanding register usage and function calls.
  • Target Application: For demonstration, we’ll assume a simple Android app with a native library (e.g., libnative-lib.so) that performs some operation.

Setting Up Your Frida Environment

First, download the correct frida-server for your device’s architecture (e.g., frida-server-*-android-arm64) from the official Frida releases page. Push it to your device and make it executable:

adb push frida-server /data/local/tmp/frida-serveradb shell 'chmod +x /data/local/tmp/frida-server'

Now, run frida-server on the device. It’s often best to run it in the background:

adb shell '/data/local/tmp/frida-server &'

Finally, set up port forwarding so your host machine can communicate with the server:

adb forward tcp:27042 tcp:27042

Verify your setup by listing running processes with Frida:

frida-ps -U

Identifying Native Functions: Static Analysis Primer

Even with dynamic analysis, a little static reconnaissance helps. Use tools like readelf or nm on your target .so file to list exported functions. For deeper insights, Ghidra or IDA Pro are invaluable for disassembling the binary and identifying potential functions of interest, especially unexported ones. You’ll typically look for Java Native Interface (JNI) functions (e.g., Java_com_example_app_MainActivity_getStringFromNative) or internal C/C++ functions.

Let’s say we have an exported function in libnative-lib.so that takes two integers, adds them, and returns a string representation:

// native-lib.cint addAndGetString(int a, int b, char* buffer, size_t buffer_size) {    int sum = a + b;    snprintf(buffer, buffer_size, "Sum is: %d", sum);    return sum;}

This function might be called by a JNI wrapper:

// jni_wrapper.cppextern "C" JNIEXPORT jstring JNICALLJava_com_example_app_MainActivity_getSumNative(JNIEnv* env, jobject /* this */, jint a, jint b) {    char result_buffer[256];    addAndGetString(a, b, result_buffer, sizeof(result_buffer));    return env->NewStringUTF(result_buffer);}

Crafting Your First ARM64 Frida Hook: Exported Functions

Frida’s Interceptor.attach is your primary weapon. To hook an exported function by name:

// hook_exported.jsFrida.on("spawn", function(spawn) {    console.log("Spawned: " + spawn.pid + " - " + spawn.filePath);    Frida.resume(spawn.pid);});Interceptor.attach(Module.findExportByName("libnative-lib.so", "addAndGetString"), {    onEnter: function(args) {        // Arguments for ARM64 functions are typically in registers x0-x7.        // For our addAndGetString(int a, int b, char* buffer, size_t buffer_size):        // x0 = a (int)        // x1 = b (int)        // x2 = buffer (char*)        // x3 = buffer_size (size_t)        console.log("[+] Entering addAndGetString");        console.log("  Argument a (x0): " + args[0].toInt32());        console.log("  Argument b (x1): " + args[1].toInt32());        this.buffer_ptr = args[2]; // Save buffer pointer for onLeave    },    onLeave: function(retval) {        // Return value is in x0 for ARM64        console.log("[-] Leaving addAndGetString");        console.log("  Return value (sum): " + retval.toInt32());        if (this.buffer_ptr) {            // Read the string written to the buffer            let result_string = Memory.readUtf8String(this.buffer_ptr);            console.log("  String in buffer: " + result_string);        }    }});console.log("Frida script loaded!");

To run this script against your target application (replace com.example.app with your package name):

frida -U -l hook_exported.js com.example.app

Diving Deeper: Hooking Unexported Functions by Offset

Often, the most interesting functions are not exported. In such cases, you need their memory offset within the library. You’d typically find this offset using static analysis tools like Ghidra or IDA Pro by examining the disassembled code. Once you have the offset, you can calculate the absolute address by adding it to the library’s base address:

// hook_by_offset.js// Assume 0x12345 is the offset of an unexported function 'secret_calc' within libnative-lib.soInterceptor.attach(Module.findBaseAddress("libnative-lib.so").add(0x12345), {    onEnter: function(args) {        console.log("[+] Entering unexported secret_calc");        // Inspect arguments as needed        console.log("  Arg 0 (x0): " + args[0]);    },    onLeave: function(retval) {        console.log("[-] Leaving unexported secret_calc");        console.log("  Return value (x0): " + retval);    }});

The .add() method is crucial for constructing the absolute address from the base address and the offset. This technique allows you to target any function within the native library, exported or not.

Advanced Techniques: Register Inspection & Memory Manipulation

Frida allows profound interaction with the native execution context. You can read and write to registers, inspect arbitrary memory regions, and even modify function arguments or return values on the fly.

Example: Intercepting a Cryptographic Key

Imagine a function encrypt_data(uint8_t* data, size_t data_len, uint8_t* key, size_t key_len). We want to extract the key:

// hook_crypto_key.jsInterceptor.attach(Module.findExportByName("libnative-lib.so", "encrypt_data"), {    onEnter: function(args) {        console.log("[+] Entering encrypt_data");        this.data_ptr = args[0];        this.data_len = args[1].toInt32();        this.key_ptr = args[2]; // x2 holds the key pointer        this.key_len = args[3].toInt32(); // x3 holds the key length        console.log("  Data Ptr (x0): " + this.data_ptr);        console.log("  Data Len (x1): " + this.data_len);        console.log("  Key Ptr (x2): " + this.key_ptr);        console.log("  Key Len (x3): " + this.key_len);        if (this.key_ptr && this.key_len > 0) {            let key_bytes = Memory.readByteArray(this.key_ptr, this.key_len);            console.log("  Extracted Key: " + Array.from(new Uint8Array(key_bytes)).map(b => b.toString(16).padStart(2, '0')).join(''));        }    },    onLeave: function(retval) {        console.log("[-] Leaving encrypt_data");    }});

This script intercepts the call, reads the `key` pointer (args[2], which is x2 in ARM64 calling convention), and then uses Memory.readByteArray to dump the key’s contents. You could similarly modify `args[2]` using `args[2] = Memory.allocUtf8String(“newkey”)` if you wanted to inject a different key.

Examining Register Context

Inside onEnter or onLeave, `this.context` provides access to all CPU registers. For ARM64, you can access registers like `this.context.x0`, `this.context.x1`, `this.context.sp` (stack pointer), `this.context.pc` (program counter), etc. This is invaluable for understanding the full execution state.

// Accessing context in a hookonEnter: function(args) {    console.log("Program Counter (PC): " + this.context.pc);    console.log("Stack Pointer (SP): " + this.context.sp);    // You can even modify registers, though caution is advised    // this.context.x0 = ptr('0x12345');}

Best Practices and Troubleshooting

  • Scope your hooks: Don’t try to hook every function. Focus on areas identified during static analysis as sensitive.
  • Error Handling: Frida scripts can be fragile. Use try...catch blocks, especially when dealing with memory operations or potentially invalid pointers.
  • Performance: Excessive logging or complex operations in hooks can slow down the target application. Be mindful of performance implications.
  • Anti-Frida Measures: Some applications detect Frida. Techniques like obfuscating Frida’s process name or checking for loaded Frida modules exist. Advanced Frida users often employ techniques like

    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