nm -D libfoo.so | grep Java_ returned empty. The Java side still had native void nInit(String);. That is RegisterNatives, not a missing .so. This note is the path I use every time: jadx → loadLibrary → JNI_OnLoad → JNINativeMethod[] → ARM64 function.
Lab layout (self-built APK, not a third-party app)
1com.lab.jni.hide
2 NativeBridge.java
3 lib/arm64-v8a/libhide.so
1package com.lab.jni.hide;
2
3public final class NativeBridge {
4 static { System.loadLibrary("hide"); }
5
6 public static native void nInit(String token);
7 public static native int nAdd(int a, int b);
8}
Java names will not appear as Java_com_lab_jni_hide_NativeBridge_nInit. Anyone grepping that string in the .so is already on the wrong path.
APK → .so
1unzip -l hide.apk | grep libhide
2# 18432 2021-06-09 lib/arm64-v8a/libhide.so
3
4unzip -p hide.apk lib/arm64-v8a/libhide.so > libhide.so
5file libhide.so
6# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, stripped
7
8readelf -s libhide.so | grep -E 'Java_|JNI_OnLoad'
9# 8: 00000000000012a0 96 FUNC GLOBAL DEFAULT 12 JNI_OnLoad
10# no Java_* lines
JNI_OnLoad is the only JNI-looking export. Good.
Ghidra / IDA decompile of JNI_OnLoad (cleaned)
I renamed locals. This is the shape, not a dump of a random app.
1jint JNI_OnLoad(JavaVM *vm, void *reserved) {
2 JNIEnv *env = NULL;
3 if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK)
4 return JNI_ERR;
5
6 jclass cls = (*env)->FindClass(env, "com/lab/jni/hide/NativeBridge");
7 if (cls == NULL)
8 return JNI_ERR;
9
10 /* table lives in .data; names are ordinary C strings */
11 (*env)->RegisterNatives(env, cls, gMethods, 2);
12 return JNI_VERSION_1_6;
13}
Xrefs to RegisterNatives (JNIEnv slot 215 on this NDK) from JNI_OnLoad are the hunting needle in stripped samples.
Recovering JNINativeMethod
1typedef struct {
2 const char *name;
3 const char *signature;
4 void *fnPtr;
5} JNINativeMethod;
In the lab .so the table is at 0x21c00 (file offset). readelf -x .data plus string xref:
1$ readelf -p .rodata libhide.so | grep -E 'nInit|nAdd|I'
2 [ 1c] nInit
3 [ 22] (Ljava/lang/String;)V
4 [ 38] nAdd
5 [ 3d] (II)I
6
7$ # Ghidra Data → 3-field structure, 2 rows:
8# [0] ptr_name=0x1c "nInit" sig="(Ljava/lang/String;)V" fn=0x13f0
9# [1] ptr_name=0x38 "nAdd" sig="(II)I" fn=0x14a8
That is the map. nInit → 0x13f0. Open that address, ignore the Java name mangling fantasy.
ARM64 at nInit
1; libhide.so VA 0x13f0 JNI: void nInit(JNIEnv*, jclass, jstring)
213f0: a9be7bfd stp x29, x30, [sp, #-0x20]!
313f4: 910003fd mov x29, sp
413f8: a90153f3 stp x19, x20, [sp, #0x10]
513fc: aa0003f3 mov x19, x0 ; JNIEnv*
61400: aa0203f4 mov x20, x2 ; jstring token
71404: f9400260 ldr x0, [x19] ; *env
81408: f9417c01 ldr x1, [x0, #0x2f8] ; GetStringUTFChars @ slot
9140c: aa1303e0 mov x0, x19
101410: aa1403e1 mov x1, x20
111414: d2800002 mov x2, #0 ; isCopy = NULL
121418: d63f0020 blr x1
13141c: aa0003f3 mov x19, x0 ; const char *utf
14; ... copies utf into a 32-byte stack slot, then ReleaseStringUTFChars
GetStringUTFChars is a JNIEnv function table lookup, not a PLT name. If you only follow PLT you will miss it. I keep a small table of JNIEnv slot indices for the NDK I actually see; slot numbers move across Android versions, so I confirm with the loaded libart.so / libnativehelper on the device image, not from memory.
The C string is copied to stack. Length is not checked against 32. That is a lab bug I planted so the next section has a crash, not a novel 0-day.
Sanitized reproduction (crash only)
Device: userdebug emulator, app is the lab APK I signed with a debug key.
1adb install -r hide.apk
2adb shell am start -n com.lab.jni.hide/.MainActivity
3# UI feeds nInit() from an EditText. I pasted 40 'A's.
4
5adb logcat -s DEBUG:E hide:V
6# F DEBUG : *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
7# F DEBUG : Build fingerprint: '[REDACTED]'
8# F DEBUG : ABI: 'arm64-v8a'
9# F DEBUG : pid: 4120, tid: 4120, name: lab.jni.hide
10# F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
11# F DEBUG : x19 0000007fd12a3c10
12# F DEBUG : backtrace:
13# F DEBUG : #00 pc 0000000000001428 /data/app/[REDACTED]/lib/arm64/libhide.so (nInit+0x38)
I am not publishing a string that pops a shell. The point of the repro is: the native at 0x13f0 is reachable from Java nInit, and a long Java string reaches a 32-byte stack copy. ASAN-on-NDK in a later rebuild made it a loud stack-buffer-overflow; the shipping .so was not ASan-built, so tombstone is what I had.
Frida, argument logging with truncation (no full token dump):
1/* trace_ninit.js — lab package only */
2const m = Module.getBaseAddress('libhide.so');
3const nInit = m.add(0x13f0);
4Interceptor.attach(nInit, {
5 onEnter(args) {
6 const env = args[0];
7 const jstr = args[2];
8 const utf = Java.vm.getEnv().getStringUtfChars(jstr);
9 const s = utf.readCString();
10 const shown = s.length > 8 ? s.slice(0, 4) + '…[' + s.length + ']' : s;
11 console.log('[nInit] len=' + s.length + ' preview=' + shown);
12 }
13});
1$ frida -U -f com.lab.jni.hide -l trace_ninit.js --no-pause
2[nInit] len=6 preview=labtok
3[nInit] len=40 preview=AAAA…[40]
Production traces: drop the preview or hash it. I do not log values that look like eyJ, AKIA, sk-, or session cookies.
What I file after this lab
- Map:
NativeBridge.nInit (Ljava/lang/String;)V→libhide.so+0x13f0 - Note: RegisterNatives, table in
.data, 2 entries - Bug class: unbounded
GetStringUTFChars→ 32-byte stack copy - Repro: 40-byte Java string, SIGSEGV, pc in
nInit+0x38 - Fix:
GetStringUTFLength+ bound, or heap buffer with max length from the Java layer
Commands appendix
1jadx hide.apk | less
2unzip -p hide.apk lib/arm64-v8a/libhide.so > libhide.so
3readelf -s libhide.so | grep JNI_OnLoad
4# Ghidra: xrefs to RegisterNatives / JNIEnv slot
5frida -U -f com.lab.jni.hide -l trace_ninit.js --no-pause
6adb logcat -b crash -d | tail -50