Hooking JNI: When the Logic Lives in native.so
· #reverse-engineering #android #jni
public native String sign(String url, long timestamp);
That’s the entire method. No body, because there is no body in the DEX
— native means “implemented elsewhere,” and elsewhere is
libapp.so. jadx can show you the declaration and every call site that
uses it. It cannot show you what sign() actually does, because that
logic was never compiled to Dalvik bytecode in the first place. This is
where a lot of request-signing and anti-tamper logic ends up, precisely
because it’s a harder wall for tools like jadx to see through.
Why the logic ends up here
JNI — the Java Native Interface — is the bridge Android apps use to call into C or C++ code compiled for the device’s actual CPU. Anything performance-sensitive gets a legitimate reason to live there: crypto, codec work, hot loops. But it’s also just a good place to hide logic from casual reversing, since it forces anyone reading the app to switch tools entirely — from a Java-aware decompiler to a native disassembler — and that switch alone stops a lot of surface-level analysis.
Hooking the boundary with Frida instead of crossing it
You don’t need to fully reverse the native function to observe what
crosses the JNI boundary. Interceptor.attach on the exported symbol
gives you the arguments and return value at the call site, which is
often everything you actually need — the algorithm can stay a black
box as long as you can see its inputs and outputs.
const libapp = Process.findModuleByName('libapp.so');
const signAddr = libapp.findExportByName('Java_com_example_app_Signer_sign');
Interceptor.attach(signAddr, {
onEnter(args) {
// args[0] = JNIEnv*, args[1] = jobject (this)
// args[2..] are the actual Java parameters, as jobjects
this.env = args[0];
console.log('[jni] sign() called');
},
onLeave(retval) {
// retval is a jstring — read it back through JNI to get text
const jstr = ptr(retval);
const cStr = this.env.readCString
? this.env.readCString(jstr)
: Java.vm.getEnv().getStringUtfChars(jstr, null);
console.log('[jni] sign() returned:', cStr);
}
});
Java_com_example_app_Signer_sign isn’t a guess — it’s the mangled
name JNI requires for a method Java resolves automatically at load
time: Java_ plus the fully qualified class and method name, with _
substituted for .. If the symbol table has that name intact, you can
jump straight to it in both Frida and Ghidra without hunting.
When the name isn’t there to find
Some libraries register their native methods dynamically through
RegisterNatives inside JNI_OnLoad, specifically to avoid the
predictable Java_... symbol and make static discovery harder. The
function still has to exist and still has to run, so the fallback is
hooking RegisterNatives itself and logging every method it binds —
which hands you the real function pointer regardless of what the
library’s authors tried to keep off the symbol table.
const RegisterNatives = Module.findExportByName('libart.so', 'RegisterNatives');
Interceptor.attach(RegisterNatives, {
onEnter(args) {
const methodCount = args[3].toInt32();
console.log('[jni] RegisterNatives called, methods:', methodCount);
// args[2] is a JNINativeMethod*, walk it to log name + fnPtr
// per entry if you need the exact function addresses
}
});
Hooking the registration call instead of a named export is slower to set up but strictly more general — it works whether the app used the predictable naming convention or deliberately avoided it.
What I learned
native in a method signature isn’t a dead end, it’s a sign the
interesting part of the app moved to a different file format. The
Java/Kotlin layer usually still tells you when the native function
gets called and with what — Frida’s Interceptor lets you watch that
boundary without fully reverse engineering what happens on the other
side of it. Full static analysis in Ghidra is still worth doing when
you need to reimplement the logic yourself, but for a first pass,
watching the JNI boundary is almost always faster than reading the
.so from scratch.