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 → loadLibraryJNI_OnLoadJNINativeMethod[] → ARM64 function.

Java to JNI_OnLoad to .so
Figure 1. RegisterNatives hides Java_* from the dynamic symbol table.

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. nInit0x13f0. 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;)Vlibhide.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