
Android applications increasingly push sensitive logic into native C and C++ libraries loaded through JNI, often specifically to make reverse engineering harder than working with decompiled Java or Kotlin bytecode. Encryption routines, license validation, anti-tampering checks, and even core business logic frequently live in these native libraries. Frida remains the most effective tool for exploring this layer at runtime, letting testers hook native functions, trace execution, and manipulate behavior without needing a fully static reverse engineering workflow. This guide walks through practical techniques for exploring native modules during an authorized Android security assessment. This kind of native-layer analysis is a regular part of mobile application assessments conducted by Sagefox Cybersecurity.
Standard Android static analysis tools decompile Java and Kotlin bytecode effectively, but native libraries compiled to ARM or x86 machine code require a fundamentally different analysis approach. An application can appear clean at the Java layer while all of its actual sensitive logic executes inside a native library that static Java tooling never touches.
Native module analysis with Frida requires the same rooted environment used for other dynamic instrumentation work.
adb root
adb remount
adb push frida-server-16.1.4-android-x86_64 /data/local/tmp/frida-server
adb shell "chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server &"Confirm the target application and its loaded native libraries are visible before proceeding.
frida-ps -U | grep com.target.appBefore hooking any native function, it helps to know exactly which native libraries the application has loaded into memory. Frida's Process API can enumerate this directly through a short script.
javascript
Process.enumerateModules().forEach(function(module) {
console.log(module.name + " - " + module.base);
});Running this against the target application lists every loaded module, including system libraries, letting you identify which ones belong to the application itself rather than the Android framework.
Once the relevant native library is identified, listing its exported functions reveals the entry points available for hooking.
javascript
var module = Process.findModuleByName("libtarget.so");
module.enumerateExports().forEach(function(exp) {
console.log(exp.name + " @ " + exp.address);
});This is often the first meaningful step in understanding what a native library actually does, since exported function names frequently hint at their purpose even before any deeper analysis.
For a faster initial pass without writing a custom script, frida-trace automatically hooks functions matching a given pattern and logs every call.
frida-trace -U -i "encrypt*" com.target.app
frida-trace -U -i "verify*" com.target.appRunning these traces while interacting with the application's login or payment flow reveals exactly which native functions get called during sensitive operations, along with the order and frequency of those calls.
Once a function of interest is identified, a custom Frida script provides full control over inspecting its arguments and return values.
javascript
Interceptor.attach(Module.findExportByName("libtarget.so", "validateLicense"), {
onEnter: function(args) {
console.log("License key argument: " + Memory.readUtf8String(args[0]));
},
onLeave: function(retval) {
console.log("Return value: " + retval);
retval.replace(1);
}
});This example logs the license key passed into a validation function and forcibly modifies the return value to always indicate success, a common technique for testing whether client-side license or entitlement checks can be bypassed entirely through native-layer manipulation.
[cta]
Native functions often accept pointers to structs rather than simple primitive types. Reading these requires understanding the structure layout, which can often be inferred through experimentation or by cross-referencing with decompiled code from a tool like Ghidra.
javascript
Interceptor.attach(Module.findExportByName("libtarget.so", "processPayment"), {
onEnter: function(args) {
var structPtr = args[0];
var amount = structPtr.readFloat();
var currency = structPtr.add(4).readCString();
console.log("Payment amount: " + amount + " " + currency);
}
});Not every function of interest is exported. Internal functions require locating their address by offset from the module's base address, typically determined through prior static analysis in a disassembler.
javascript
var module = Process.findModuleByName("libtarget.so");
var targetOffset = 0x4a2c;
var targetAddress = module.base.add(targetOffset);
Interceptor.attach(targetAddress, {
onEnter: function(args) {
console.log("Internal function hit at offset 0x4a2c");
}
});This approach depends on the offset remaining stable across the specific build being tested, which means it needs to be reconfirmed whenever the application updates.
Rather than relying on a hardcoded offset that breaks with every app update, Frida supports scanning memory for a specific byte pattern associated with a function's prologue, making the hook more resilient to minor build changes.
javascript
var module = Process.findModuleByName("libtarget.so");
Memory.scan(module.base, module.size, "55 8B EC 83 EC ??", {
onMatch: function(address, size) {
console.log("Pattern match found at: " + address);
},
onComplete: function() {
console.log("Scan complete");
}
});Applications using OpenSSL or BoringSSL for native-layer cryptography expose predictable function names that are useful hooking targets for understanding what data gets encrypted and with what key.
javascript
Interceptor.attach(Module.findExportByName("libcrypto.so", "AES_encrypt"), {
onEnter: function(args) {
console.log("Plaintext block: " + hexdump(args[0], { length: 16 }));
}
});Capturing plaintext immediately before it enters a standard encryption routine is one of the most reliable ways to recover sensitive data that the application intends to protect through encryption at rest or in transit.
[cta]
Where the key itself is passed as a function argument rather than derived internally, hooking the relevant key-scheduling function reveals it directly.
javascript
Interceptor.attach(Module.findExportByName("libcrypto.so", "AES_set_encrypt_key"), {
onEnter: function(args) {
var keyLength = args[1].toInt32();
console.log("AES key: " + hexdump(args[0], { length: keyLength / 8 }));
}
});Root detection implemented at the native layer often checks for the presence of specific files, binaries, or system properties associated with rooted devices. Tracing common file access functions reveals these checks in action.
frida-trace -U -i "fopen" -i "access" -i "stat" com.target.appReviewing the traced calls for paths like /system/bin/su or /system/xbin/busybox identifies exactly which checks the application performs and where they live in the native library.
Once the specific check is identified, hooking it directly to force a benign return value neutralizes it for the remainder of the testing session.
javascript
Interceptor.attach(Module.findExportByName("libtarget.so", "isDeviceRooted"), {
onLeave: function(retval) {
retval.replace(0);
}
});Frida excels at dynamic analysis, showing exactly what happens at runtime, but pairing it with static analysis in Ghidra provides the broader context needed to understand unfamiliar functions, locate offsets for internal functions without exports, and confirm struct layouts before writing argument-reading scripts. A typical workflow moves between the two: identify a function of interest statically in Ghidra, confirm its actual runtime behavior and argument values dynamically with Frida, then iterate.
Runtime values captured through Frida, such as confirmed struct offsets or decrypted constants, can be fed back into Ghidra's analysis to label data structures and constants accurately, making subsequent static analysis passes significantly faster.
As with any dynamic analysis finding, the value of native module exploration comes from clearly showing what was recovered and under what realistic conditions, whether that is a bypassed license check, an extracted cryptographic key, or plaintext payment data captured before encryption. A raw list of hooked functions without context does not communicate risk effectively to a development team.
Findings from native module analysis typically map to concrete recommendations:
This is the kind of structured, prioritized reporting that assessments from Sagefox Cybersecurity are built around, ensuring native-layer findings translate into fixes development teams can realistically implement.
Offsets for internal, non-exported functions can shift between build versions, compiler optimizations, or architecture targets. Relying on a hardcoded offset without validating it against the specific build under test produces unreliable results.
A native library compiled for ARM64 behaves differently at the binary level than one compiled for x86, and testing exclusively on an x86 emulator may miss checks or behavior specific to the ARM builds most real devices actually run.
Attempting to hook and interpret unfamiliar native functions purely through trial and error wastes significant time compared to a brief static analysis pass first to understand the function's likely purpose and argument structure.
Native modules remain one of the most effective places for Android applications to hide sensitive logic from casual reverse engineering, but Frida's ability to hook, trace, and manipulate native functions at runtime closes that gap effectively during an authorized assessment. Combining dynamic instrumentation with static analysis in a tool like Ghidra produces the most complete picture, turning what looks like an opaque compiled library into a fully understood and testable component of the application.
If your organization needs a mobile application assessment that goes deep into native-layer behavior rather than stopping at the Java bytecode layer, the team at Sagefox Cybersecurity can help uncover what your app's native code actually does at runtime.