Android Software Reverse Engineering & Decompilation

RE Lab: Unpacking & Analyzing Android Native Libraries with Frida JNI Hooking

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction: The World of Android Native Libraries

Android applications often rely on native libraries (typically .so files) to execute performance-critical code, implement complex algorithms, or protect sensitive logic from easy reverse engineering. These libraries are written in languages like C/C++ and interact with the Java/Kotlin application layer through the Java Native Interface (JNI). Understanding and manipulating these native components is a cornerstone of advanced Android reverse engineering, and Frida stands out as an indispensable tool for this task.

This guide will equip you with the knowledge and practical steps to set up your environment, identify native functions, and leverage Frida for dynamic JNI hooking, enabling you to inspect arguments, modify return values, and ultimately bypass protections implemented in native code.

Frida: Your Swiss Army Knife for Runtime Analysis

Frida is a dynamic instrumentation toolkit that allows developers and reverse engineers to inject their own scripts into running processes. Its powerful JavaScript API, coupled with deep access to system internals, makes it exceptionally well-suited for Android reverse engineering, especially when dealing with native libraries. With Frida, you can:

  • Intercept function calls in native libraries (JNI functions, exported symbols, internal functions).
  • Inspect and modify function arguments and return values.
  • Monitor memory reads and writes.
  • Enumerate loaded modules and symbols.
  • Bypass anti-tampering and anti-debugging checks.

Its ability to operate at the instruction level while offering a high-level JavaScript interface provides an unparalleled advantage in complex RE scenarios.

Setting Up Your RE Lab

Before diving into hooking, ensure your environment is correctly set up.

Prerequisites:

  • A rooted Android device or an emulator (e.g., Android Studio AVD, Genymotion) with root access.
  • ADB (Android Debug Bridge) installed and configured on your host machine.
  • Python 3 and pip installed on your host machine.
  • The target Android application (APK) for analysis.

Installation Steps:

  1. Install Frida-tools on your host:
    pip install frida-tools
  2. Download Frida-server for your Android device:

    Determine your device’s architecture (e.g., arm64, x86_64) using adb shell getprop ro.product.cpu.abi. Then, download the corresponding frida-server binary from the official Frida releases page.

    # Example for arm64-v8a:wget https://github.com/frida/frida/releases/download/16.1.4/frida-server-16.1.4-android-arm64.xzxz -d frida-server-16.1.4-android-arm64.xz
  3. Push and run Frida-server on your device:
    adb push frida-server-16.1.4-android-arm64 /data/local/tmp/frida-serverchmod 755 /data/local/tmp/frida-serveradb shell su -c "/data/local/tmp/frida-server &"

    Verify Frida-server is running by executing frida-ps -U on your host. You should see a list of processes on your device.

Identifying Native Entry Points and Functions

To hook native functions, you first need to identify them. Key areas to investigate include:

JNI_OnLoad: The Initializer

Every native library that interacts with JNI typically exports a function called JNI_OnLoad. This function is called when the library is loaded by the Java Virtual Machine (JVM) and is often used to perform initialization tasks and register native methods dynamically.

You can identify JNI_OnLoad using binary analysis tools like Ghidra, IDA Pro, or simply using readelf on the .so file:

readelf -s libyournative.so | grep JNI_OnLoad

Java Native Methods

These are Java/Kotlin methods declared with the native keyword, indicating their implementation is provided by a native library. Their corresponding C/C++ function names follow a specific pattern: Java_PackageName_ClassName_MethodName (with underscores replacing dots and various argument type signatures appended). For example, Java_com_example_app_NativeUtils_verifyLicense.

You can find these by decompiling the APK (e.g., with Jadx or Ghidra) and looking for native method declarations.

Exported Functions

Many native libraries export other functions besides the JNI-specific ones, making them directly callable by other libraries or even discoverable by tools. Use nm -D to list dynamic symbols:

nm -D libyournative.so

Basic JNI Hooking with Frida

Let’s start with a common scenario: observing strings passed to JNI functions. The GetStringUTFChars function is frequently used by native code to convert a Java string (jstring) into a C-style string (const char*).

Scenario: Hooking GetStringUTFChars

We’ll hook the `GetStringUTFChars` function within `libart.so` (the Android Runtime library) to log any Java strings being converted.

Java.perform(function () {    var GetStringUTFChars_addr = Module.findExportByName("libart.so", "_ZN3art9JNIEnvExt16GetStringUTFCharsEP7_jstringPb");    if (GetStringUTFChars_addr) {        console.log("[+] Found GetStringUTFChars at: " + GetStringUTFChars_addr);        Interceptor.attach(GetStringUTFChars_addr, {            onEnter: function (args) {                // args[0] is JNIEnv*, args[1] is jstring                this.env = args[0];                this.jstr = args[1];            },            onLeave: function (retval) {                if (this.jstr.isNull()) {                    return;                }                var env = new Java.api.jvm.JNIEnv(this.env);                var javaString = env.jstringToString(this.jstr);                console.log("[+] GetStringUTFChars called with string: " + javaString);            }        });        console.log("[+] Hooked GetStringUTFChars in libart.so!");    } else {        console.log("[-] Could not find GetStringUTFChars in libart.so. Is the symbol name correct for this Android version?");    }});

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

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

Advanced JNI Hooking: Intercepting Custom Native Functions and Bypasses

Now, let’s target a custom native function that might perform a critical check, like license verification. Imagine an application with a native method `NativeUtils.verifyLicense(String licenseKey)` that returns a boolean indicating validity.

Scenario: Bypassing a Native License Check

We want to always make `verifyLicense` return `true`, regardless of the input key.

  1. Identify the function: Using a decompiler, find the Java native method signature and derive its C/C++ equivalent. Let’s assume it’s Java_com_example_app_NativeUtils_verifyLicense within libappnative.so.
  2. Hook and modify return value:
Java.perform(function () {    // Replace 'libappnative.so' with the actual native library name    var libNative = Module.findExportByName("libappnative.so", "Java_com_example_app_NativeUtils_verifyLicense");    // If the function is not exported, you might need to find its address by offset from base address    // var baseAddress = Module.findBaseAddress("libappnative.so");    // var targetAddress = baseAddress.add(0x12345); // Replace 0x12345 with the actual offset found via disassembler    if (libNative) {        console.log("[+] Found verifyLicense at " + libNative);        Interceptor.attach(libNative, {            onEnter: function (args) {                // JNIEnv* env, jobject thiz, jstring licenseKey                this.env = args[0];                var env = new Java.api.jvm.JNIEnv(this.env);                var licenseKey = env.jstringToString(args[2]);                console.log("[-] verifyLicense called with licenseKey: " + licenseKey);            },            onLeave: function (retval) {                console.log("[-] Original verifyLicense return value: " + retval);                // Assuming it returns a jboolean (0 or 1)                retval.replace(1); // Force return value to 'true'                console.log("[-] Modified verifyLicense return value to: " + retval);            }        });        console.log("[+] Hooked verifyLicense for bypass!");    } else {        console.log("[-] Could not find Java_com_example_app_NativeUtils_verifyLicense function in libappnative.so.");    }});

This script intercepts the `verifyLicense` function, logs the input `licenseKey`, and then forcefully changes its return value to `1` (true), effectively bypassing the license check.

Beyond Simple Hooks: Advanced Techniques and Considerations

Dealing with RegisterNatives

Many applications use RegisterNatives within JNI_OnLoad to dynamically register native methods, making them harder to find by simple symbol lookup. You can hook RegisterNatives itself to catch these registrations:

Java.perform(function () {    var RegisterNatives_addr = Module.findExportByName("libart.so", "_ZN3art9JNIEnvExt14RegisterNativesEP7_jclassPK15JNINativeMethodi");    if (RegisterNatives_addr) {        Interceptor.attach(RegisterNatives_addr, {            onEnter: function (args) {                var env = new Java.api.jvm.JNIEnv(args[0]);                var jclass = new Java.api.jvm.JClass(args[1]);                var methods = args[2];                var numMethods = args[3].toInt32();                var className = env.jclassToString(jclass);                console.log("[+] RegisterNatives called for class: " + className + " with " + numMethods + " methods.");                for (var i = 0; i < numMethods; i++) {                    var methodName = methods.add(i * Process.pointerSize * 3).readPointer().readCString();                    var signature = methods.add(i * Process.pointerSize * 3 + Process.pointerSize).readPointer().readCString();                    var fnPtr = methods.add(i * Process.pointerSize * 3 + Process.pointerSize * 2).readPointer();                    console.log("    Method: " + methodName + ", Signature: " + signature + ", Function Ptr: " + fnPtr);                }            }        });        console.log("[+] Hooked RegisterNatives!");    }});

Dynamic Library Loading (dlopen/dlsym)

Applications might load native libraries dynamically at runtime using dlopen and resolve symbols with dlsym. Hooking these functions helps you track library loading and symbol resolution, especially for anti-tampering measures that load libraries stealthily.

Memory Manipulation

Frida’s Memory API allows you to read from and write to arbitrary memory addresses. This is useful for inspecting or altering data structures directly in memory, which might be critical for bypasses where simple function hooking isn’t enough.

Dealing with Anti-Frida and Obfuscation

Modern applications often employ anti-Frida techniques (e.g., checking for Frida server, detecting hooks, or process enumeration) and heavy obfuscation (e.g., control flow flattening, string encryption). Bypassing these requires additional techniques:

  • Anti-Frida: Use Frida’s gadget or stealth modes, or write specific hooks to disable detection mechanisms.
  • Obfuscation: Combine dynamic analysis with static analysis (Ghidra/IDA) to understand the obfuscated code and identify key logic. Hooking I/O functions or cryptographic APIs can often reveal deobfuscated data.

Conclusion

Frida is an exceptionally powerful tool for reverse engineering Android native libraries. By mastering JNI hooking, you gain unparalleled insight into an application’s core logic, allowing you to debug, analyze, and bypass complex protections. The ability to dynamically interact with code at runtime opens up a vast array of possibilities for security research, vulnerability discovery, and ethical hacking. Continue experimenting with different hooking scenarios and combining Frida with static analysis tools to unlock the full potential of your Android RE capabilities.

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