Android App Penetration Testing & Frida Hooks

The Ultimate Guide to Android Native Function Hooking with Frida (JNI_OnLoad & Beyond)

Google AdSense Native Placement - Horizontal Top-Post banner

Introduction to Android Native Hooking and Frida

Android applications often leverage native code (written in C/C++ and compiled into shared libraries like .so files) for performance-critical operations, obfuscation, or platform-specific interactions via the Java Native Interface (JNI). For penetration testers and security researchers, understanding and manipulating this native layer is paramount. Frida, a dynamic instrumentation toolkit, stands out as an invaluable tool for this purpose, allowing us to inspect, modify, and even inject code into running processes.

While hooking Java methods is relatively straightforward with Frida, interacting with native functions, especially early in the application’s lifecycle like during JNI_OnLoad, presents unique challenges and opportunities. This guide will walk you through the intricacies of hooking native functions, from the crucial JNI_OnLoad to arbitrary exported and unexported functions, using practical Frida scripts and real-world examples.

Understanding JNI_OnLoad: The Native Entry Point

What is JNI_OnLoad?

JNI_OnLoad is a special native function that acts as the entry point for a native library. When a Java application calls System.loadLibrary(), the Android system loads the specified .so file. If the library exports a function named JNI_OnLoad, the Java Virtual Machine (JVM) automatically invokes it immediately after the library is loaded and before any other JNI-related calls (like registering native methods dynamically or resolving static native methods). This makes JNI_OnLoad an ideal place for:

  • Initializing native components.
  • Setting up global variables or environments.
  • Performing anti-tampering checks.
  • Dynamically registering native methods with the JVM.

Why Hook JNI_OnLoad?

Hooking JNI_OnLoad is critical for several reasons:

  1. Early Interaction: It allows you to intercept logic that happens very early in the native library’s lifecycle, often before other security checks or key initializations.
  2. Dynamic Method Registration: If an application uses RegisterNatives within JNI_OnLoad to register its native methods, hooking JNI_OnLoad enables you to observe or even alter these registrations.
  3. Bypassing Protections: Many anti-tampering or anti-debugging mechanisms are initialized or checked within JNI_OnLoad. Intercepting it can help bypass or understand these protections.

Setting Up Your Frida Environment

Before we dive into hooking, ensure your Frida environment is set up:

  • Frida-tools: Install on your host machine: pip install frida-tools
  • Frida-server: Download the appropriate frida-server binary for your Android device’s architecture (e.g., arm64, x86) from Frida Releases.
  • Push and Run Frida-server:
    adb push /path/to/frida-server /data/local/tmp/frida-server adb shell "chmod 755 /data/local/tmp/frida-server" adb shell "/data/local/tmp/frida-server &"
  • ADB Access: Ensure ADB is configured and your device is accessible.

Scenario 1: Hooking JNI_OnLoad

Let’s consider a simple native library (libnativehooktest.so) that contains a JNI_OnLoad function. Here’s an example C++ snippet:

#include  #include  #define LOG_TAG "NativeHookTest" #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) { LOGD("JNI_OnLoad called!"); // Further initializations or method registrations return JNI_VERSION_1_6; }

Identifying JNI_OnLoad

You can verify if a library exports JNI_OnLoad using nm -D:

adb pull /data/app/~~.../com.example.app-XYZ/lib/arm64/libnativehooktest.so ./ arm-linux-androideabi-nm -D libnativehooktest.so | grep JNI_OnLoad # Example output: # 0000000000001234 T JNI_OnLoad

The `T` indicates it’s a defined text (code) symbol.

Frida Script to Hook JNI_OnLoad

Frida allows you to attach to functions by their exported name. We’ll use Module.findExportByName.

/** * jni_onload_hook.js */ console.log("Frida script loaded!"); var moduleName = "libnativehooktest.so"; var jniOnLoadPtr = Module.findExportByName(moduleName, "JNI_OnLoad"); if (jniOnLoadPtr) { console.log("Found JNI_OnLoad at: " + jniOnLoadPtr); Interceptor.attach(jniOnLoadPtr, { onEnter: function (args) { console.log("[*] JNI_OnLoad called in " + moduleName); console.log("    JavaVM*: " + args[0]); console.log("    reserved: " + args[1]); // You can inspect or modify args here }, onLeave: function (retval) { console.log("[*] JNI_OnLoad returned: " + retval); // You can inspect or modify retval here } }); } else { console.log("JNI_OnLoad not found in " + moduleName + "."); } console.log("Hooking JNI_OnLoad completed.");

Running the Script

frida -U -l jni_onload_hook.js --no-pause -f com.example.app

This command launches your target app (com.example.app), injects the Frida script, and then immediately pauses to allow Frida to attach before the app fully initializes. The --no-pause option is often useful here, but for early hooks, -f (spawn mode) with the script loaded will attach Frida before the app’s process starts. If you see the output from JNI_OnLoad in your Frida console, you’ve successfully hooked it!

Scenario 2: Hooking Other Native Functions

Beyond JNI_OnLoad, you’ll often need to hook other specific native functions.

Identifying Native Functions

  1. Exported Functions: These are the easiest. Use nm -D again to list all exported symbols. For example:
    extern "C" JNIEXPORT jstring JNICALL Java_com_example_app_NativeLib_getSecretKey(JNIEnv* env, jobject /* this */) { std::string secret = "SUPER_SECRET_KEY_12345"; return env->NewStringUTF(secret.c_str()); }

    `nm -D libnativehooktest.so | grep getSecretKey` would likely show `Java_com_example_app_NativeLib_getSecretKey`.

  2. Unexported (Internal) Functions: These functions are not listed by nm -D. You’ll need reverse engineering tools like IDA Pro or Ghidra to analyze the disassembly, identify the function’s starting address relative to the module’s base address (its offset), and determine its signature (arguments and return type).

Frida Script for Exported Functions

Hooking exported functions is similar to JNI_OnLoad.

/** * exported_func_hook.js */ console.log("Frida script loaded!"); var moduleName = "libnativehooktest.so"; var targetFunctionPtr = Module.findExportByName(moduleName, "Java_com_example_app_NativeLib_getSecretKey"); if (targetFunctionPtr) { console.log("Found getSecretKey at: " + targetFunctionPtr); Interceptor.attach(targetFunctionPtr, { onEnter: function (args) { console.log("[*] Java_com_example_app_NativeLib_getSecretKey called!"); console.log("    JNIEnv*: " + args[0]); console.log("    jobject (this): " + args[1]); }, onLeave: function (retval) { var secretKey = new NativePointer(retval); console.log("[*] getSecretKey returned: " + secretKey.readCString()); // Read the returned jstring (UTF-8) this.retval = Memory.allocUtf8String("HOOKED_SECRET_KEY_BY_FRIDA"); // Modify the return value } }); } else { console.log("getSecretKey not found in " + moduleName + "."); } console.log("Hooking getSecretKey completed.");

Here, we demonstrate how to read the returned `jstring` and even modify it to return a different value.

Frida Script for Unexported Functions (Offset-Based Hooking)

Suppose you’ve found an internal function, say nativeMultiply, at an offset of `0x1A40` from the base of `libnativehooktest.so` using IDA Pro:

extern "C" JNIEXPORT jint JNICALL nativeMultiply(JNIEnv* env, jobject /* this */, jint a, jint b) { LOGD("nativeMultiply called with %d and %d!", a, b); return a * b; }
/** * offset_func_hook.js */ console.log("Frida script loaded!"); var moduleName = "libnativehooktest.so"; var moduleBase = Module.findBaseAddress(moduleName); if (moduleBase) { console.log("Base address of " + moduleName + ": " + moduleBase); var targetOffset = new NativePointer("0x1A40"); // This offset is found via reverse engineering tools var targetFunctionPtr = moduleBase.add(targetOffset); console.log("Calculated nativeMultiply address: " + targetFunctionPtr); Interceptor.attach(targetFunctionPtr, { onEnter: function (args) { console.log("[*] nativeMultiply called!"); console.log("    JNIEnv*: " + args[0]); console.log("    jobject (this): " + args[1]); console.log("    arg a (jint): " + args[2].toInt32()); console.log("    arg b (jint): " + args[3].toInt32()); args[2] = ptr(100); // Modify 'a' to 100 }, onLeave: function (retval) { console.log("[*] nativeMultiply original return value: " + retval.toInt32()); retval.replace(ptr(200)); // Force return 200 } }); } else { console.log("Module " + moduleName + " not found."); } console.log("Hooking nativeMultiply completed.");

In this example, we:

  • Find the base address of the module using Module.findBaseAddress().
  • Calculate the target function’s absolute address by adding the known offset.
  • Hook the function, inspect arguments, and even modify both input arguments and the return value.

Advanced Considerations and Techniques

Handling JNIEnv* and JavaVM*

The JNIEnv* pointer provides access to a table of functions that allow native code to interact with the JVM (e.g., creating new strings, calling Java methods, throwing exceptions). When you get JNIEnv* as an argument, you can cast it to a C function pointer and use it in Frida, though this is often more complex than direct parameter manipulation. Similarly, JavaVM* allows obtaining a JNIEnv* for the current thread.

Argument Type Casting and Reading

Frida’s `args` array provides raw `NativePointer` objects. You need to know the expected C/JNI type to cast and read them correctly:

  • .toInt32(), .toInt64(), .readCString(), .readUtf16String(), .readByteArray()
  • For `jobject` references, these are pointers to Java objects. You can pass them back to Java code or use methods like `Java.cast()` if the object is already a `Wrapper` in Frida’s JavaScript environment.

Bypassing Anti-Frida/Anti-Tampering

Many applications implement checks within JNI_OnLoad or other native functions to detect debuggers, root, or Frida itself. Hooking these functions provides an opportunity to patch out or modify the logic of these checks, effectively bypassing them. This often involves inspecting the return value or specific arguments that control the flow of such checks.

Conclusion

Native function hooking with Frida is an incredibly powerful technique for reverse engineering and penetration testing Android applications. By mastering the ability to intercept functions like JNI_OnLoad and other native methods—whether exported or hidden behind offsets—you gain unparalleled visibility and control over an application’s deepest layers. This guide provides a solid foundation for your journey into advanced Android security analysis, equipping you with the knowledge to dissect complex native implementations and uncover hidden vulnerabilities.

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