DATE

March 27, 2026

Android applications increasingly rely on native code compiled to shared libraries (.so files) to handle sensitive operations such as cryptographic routines, license validation, root detection, and anti-tamper logic. For mobile penetration testers and reverse engineers, understanding how to interact with these native modules is a critical skill. Frida, the dynamic instrumentation toolkit, gives you the ability to hook, trace, and manipulate native functions at runtime without modifying the APK. This guide walks through the methodology, tooling, and commands needed to effectively explore native modules in Android using Frida.

If you are looking for professional Android application security assessments, the team at Redfox Cybersecurity brings deep expertise in mobile penetration testing across platforms.

What Are Native Modules in Android

Android applications are built primarily in Java or Kotlin and run on the Android Runtime (ART). However, developers can write performance-critical or security-sensitive components in C or C++ and compile them into native shared libraries. These libraries typically carry the .so extension and follow the ELF (Executable and Linkable Format) binary format for the ARM or x86 architecture.

These native libraries are loaded into the application process at runtime using the Java Native Interface (JNI). A typical invocation in the Java layer looks like this:

static {
   System.loadLibrary("nativelib");
}

public native String getSecretKey();

The native counterpart in C would be:

JNIEXPORT jstring JNICALL
Java_com_example_app_MainActivity_getSecretKey(JNIEnv *env, jobject thiz) {
   return (*env)->NewStringUTF(env, "s3cr3t_k3y_value");
}

From an attacker's perspective, these native functions are high-value targets because they often perform authentication checks, handle decryption keys, or bypass certificate pinning. Static analysis using tools like Ghidra or IDA Pro can partially reveal logic, but dynamic instrumentation with Frida is far more powerful because it operates at runtime with full context.

Setting Up Frida for Android Native Analysis

Before you begin, ensure the following prerequisites are met:

  • A rooted Android device or an Android emulator (x86/ARM)
  • ADB installed and device connected
  • Python 3 and pip installed on your workstation
  • Frida tools installed

Install Frida tools on your host machine:

pip install frida-tools

Download the appropriate Frida server binary for your device's architecture from the official GitHub releases page:

adb push frida-server-16.x.x-android-arm64 /data/local/tmp/frida-server
adb shell chmod 755 /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server &

Verify the Frida server is running:

frida-ps -U

This should list all running processes on the connected Android device. You are now ready to interact with native modules.

Enumerating Loaded Native Modules

The first step in native module analysis is understanding which .so libraries are loaded into the target application process.

Use frida-ps to find the process name or PID:

frida-ps -Ua

Attach to the target process and enumerate all loaded modules:

// enumerate_modules.js
Java.perform(function () {
   Process.enumerateModules().forEach(function (module) {
       console.log("Name: " + module.name);
       console.log("Base: " + module.base);
       console.log("Size: " + module.size);
       console.log("Path: " + module.path);
       console.log("---");
   });
});

Run the script:

frida -U -n com.example.targetapp -l enumerate_modules.js

This gives you a full map of all native libraries loaded in memory, including their base addresses, which are essential for calculating offsets during hooking.

Hooking Exported Native Functions

If a native function is exported (i.e., its symbol is visible in the ELF symbol table), Frida can hook it by name directly.

List all exported functions in a specific module:

// list_exports.js
var exports = Module.enumerateExports("libnativelib.so");
exports.forEach(function (exp) {
   if (exp.type === 'function') {
       console.log(exp.name + " @ " + exp.address);
   }
});

Hook an exported function to intercept arguments and return values:

// hook_exported.js
Interceptor.attach(Module.getExportByName("libnativelib.so", "Java_com_example_app_MainActivity_getSecretKey"), {
   onEnter: function (args) {
       console.log("[*] getSecretKey called");
       console.log("[*] JNIEnv: " + args[0]);
   },
   onLeave: function (retval) {
       console.log("[*] Return value (raw): " + retval);
       var jniEnv = this.context.x0; // ARM64
       // Read the Java string from return value
       var str = Java.vm.tryGetEnv().newStringUtf("HOOKED_KEY");
       retval.replace(str);
       console.log("[*] Return value replaced with HOOKED_KEY");
   }
});

This technique is extremely effective for bypassing license checks or extracting hardcoded secrets embedded in native code.

Working with Unexported Functions Using Pattern Scanning

Security-conscious developers often strip symbols from their native libraries to hinder reverse engineering. When exports are unavailable, you need to locate functions using byte pattern scanning.

First, extract the target .so from the device:

adb pull /data/app/com.example.targetapp-1/lib/arm64/libnativelib.so .

Disassemble using objdump or radare2 to identify a unique byte sequence near the target function:

objdump -d libnativelib.so | grep -A 20 "some_function_hint"

Or use radare2:

r2 -A libnativelib.so
aaa
afl | grep -i "check"
pdf @ sym.fun.check_license

Once you have the byte pattern, scan memory at runtime with Frida:

// pattern_scan.js
var pattern = "FF 43 01 D1 F4 4F 02 A9"; // Example ARM64 prologue bytes
var module = Process.getModuleByName("libnativelib.so");

Memory.scan(module.base, module.size, pattern, {
   onMatch: function (address, size) {
       console.log("[*] Pattern found at: " + address);
       Interceptor.attach(address, {
           onEnter: function (args) {
               console.log("[*] Stripped function hit at " + address);
           },
           onLeave: function (retval) {
               console.log("[*] Retval: " + retval.toInt32());
               retval.replace(1); // Patch return value
           }
       });
   },
   onComplete: function () {
       console.log("[*] Scan complete");
   }
});

This approach requires some ARM disassembly knowledge but is highly effective for bypassing stripped anti-tamper functions.

Need help going beyond the script kiddie layer and performing real-world mobile pentests? Redfox Cybersecurity's penetration testing services are built for exactly this kind of deep-dive engagement.

Tracing Native Function Calls with frida-trace

For black-box analysis where you do not yet know which functions to target, frida-trace is invaluable. It auto-generates hooks for all matching functions and logs call arguments automatically.

Trace all JNI-related native calls:

frida-trace -U -n com.example.targetapp -i "Java_*"

Trace all functions in a specific library:

frida-trace -U -n com.example.targetapp -I "libnativelib.so"

Trace specific functions by pattern:

frida-trace -U -n com.example.targetapp -i "*check*" -i "*verify*" -i "*license*"

The auto-generated JavaScript handlers are placed in __handlers__/ and can be edited in real time to add custom logging or tampering logic. This is one of the fastest ways to map out the call graph of a native module during an initial assessment.

Intercepting JNI Bridge Calls

The JNI bridge is the interface between the Java and native worlds. Monitoring calls across this bridge can reveal sensitive data like keys, tokens, and plaintext passwords being passed between layers.

Hook the JNI FindClass and GetMethodID functions to observe Java class lookups from native code:

// jni_bridge_monitor.js
var JNI_FindClass = null;
var JNI_GetMethodID = null;

Interceptor.attach(Module.findExportByName(null, "JNI_OnLoad"), {
   onEnter: function (args) {
       console.log("[*] JNI_OnLoad called in: " + Process.getCurrentThreadId());
   }
});

// Hook GetStringUTFChars to capture string data crossing the JNI boundary
var libdvm = Process.getModuleByName("libart.so");
var getStringUTFChars = libdvm.findExportByName("GetStringUTFChars");
if (getStringUTFChars) {
   Interceptor.attach(getStringUTFChars, {
       onLeave: function (retval) {
           if (!retval.isNull()) {
               console.log("[*] GetStringUTFChars -> " + retval.readCString());
           }
       }
   });
}

This level of visibility into the JNI boundary often surfaces secrets that are never visible in the Java decompilation alone.

Bypassing Native Root and Integrity Checks

Many production applications implement root detection and integrity verification inside native code to make tampering harder. With Frida, these checks can be bypassed systematically.

Typical native root detection checks look for:

  • Presence of /system/xbin/su or /system/app/Superuser.apk
  • access() or stat() syscall results on common root paths
  • Running processes like magiskd

Hook the access libc function to fake file absence:

// bypass_root_check.js
Interceptor.attach(Module.findExportByName("libc.so", "access"), {
   onEnter: function (args) {
       var path = args[0].readCString();
       if (path.indexOf("su") !== -1 || path.indexOf("magisk") !== -1 || path.indexOf("Superuser") !== -1) {
           console.log("[*] Blocking access() call for: " + path);
           this.bypass = true;
       }
   },
   onLeave: function (retval) {
       if (this.bypass) {
           retval.replace(-1); // Return ENOENT (file not found)
       }
   }
});

Hook fopen for file-based root detection:

Interceptor.attach(Module.findExportByName("libc.so", "fopen"), {
   onEnter: function (args) {
       var path = args[0].readCString();
       if (path && (path.indexOf("su") !== -1 || path.indexOf("magisk") !== -1)) {
           console.log("[*] fopen blocked for: " + path);
           args[0] = Memory.allocUtf8String("/nonexistent_path");
       }
   }
});

These hooks are frequently needed during mobile application security assessments to reach the actual application functionality and evaluate real business logic vulnerabilities.

If your organization needs a thorough assessment of Android application security including native binary analysis, get in touch with Redfox Cybersecurity to scope a professional engagement.

Reading and Writing Native Memory

Frida allows direct manipulation of process memory, which is critical when dealing with obfuscated strings, encrypted keys stored in heap allocations, or global variables holding sensitive state.

Read a null-terminated C string from an address:

var addr = ptr("0x7b9c3f20a0"); // Replace with actual address from module base + offset
console.log(addr.readCString());

Read raw bytes:

console.log(hexdump(addr, { length: 64, ansi: true }));

Write to memory (for patching return values or flags):

Memory.protect(addr, 4, 'rwx');
addr.writeInt(1); // Patch a flag from 0 to 1

Allocate new memory and write a string:

var newStr = Memory.allocUtf8String("patched_value");
console.log("Allocated at: " + newStr);

Scripting a Full Native Analysis Workflow

Combining everything above into a structured workflow accelerates the analysis phase of a mobile pentest dramatically. Below is a skeleton script to get you started on any new target:

// full_native_analysis.js
Java.perform(function () {
   console.log("[*] Enumerating modules...");
   Process.enumerateModules().forEach(function (m) {
       if (m.name.indexOf("native") !== -1 || m.name.indexOf("lib") !== -1) {
           console.log("[+] Module: " + m.name + " @ " + m.base);
           m.enumerateExports().forEach(function (exp) {
               if (exp.type === 'function') {
                   console.log("    Export: " + exp.name);
                   try {
                       Interceptor.attach(exp.address, {
                           onEnter: function (args) {
                               console.log("    --> Called: " + exp.name);
                           }
                       });
                   } catch (e) {
                       // Some addresses cannot be hooked
                   }
               }
           });
       }
   });
});

Run it with verbose output:

frida -U -n com.example.targetapp -l full_native_analysis.js --no-pause 2>&1 | tee native_analysis_output.txt

Wrapping Up

Native modules represent one of the most challenging yet rewarding areas in Android reverse engineering and mobile penetration testing. Frida's combination of dynamic instrumentation, memory access, and JavaScript flexibility gives testers an unmatched toolkit for exploring these binary boundaries. Whether you are tracing JNI calls, bypassing stripped root checks, or hunting for hardcoded keys inside ELF binaries, the techniques above form a practical foundation for real-world mobile security work.

The breadth of skill required to assess native Android components properly spans ARM assembly, ELF internals, JNI mechanics, and runtime instrumentation. It is not a domain where automated scanners deliver meaningful results.

If your application relies on native code for sensitive operations and you want to understand your actual exposure, Redfox Cybersecurity offers mobile penetration testing services that go deep into native binary analysis, JNI bridge inspection, and runtime tamper bypass validation. Reach out to the team to discuss your assessment needs.