Two ways to bind a Java native method to a .so: the VM looks up Java_<pkg>_<class>_<method>, or JNI_OnLoad calls RegisterNatives. This lab APK does both in one library so the contrast is a readelf -s line, not a slogan.

Java to JNI_OnLoad to .so
Figure 1. ART resolves Java_* exports, or JNI_OnLoad installs a JNINativeMethod table.

Lab APK

Self-signed debug APK. Package is mine. Not a Play sample.

1com.lab.onload
2  Bridge.java
3  lib/arm64-v8a/libmangle.so
 1package com.lab.onload;
 2
 3public final class Bridge {
 4    static { System.loadLibrary("mangle"); }
 5
 6    public static native void nInit(String token);   // Java_* export
 7    public static native int  nAdd(int a, int b);    // Java_* export
 8    public static native int  nAdd(long a, long b);  // overloaded → extra mangling
 9    public native String      nTag();                // RegisterNatives only
10}

nTag has no Java_* symbol. nInit / nAdd do. That split is the whole point of the binary.

javah-style names (what ART searches)

javac -h (old javah) emits the C stub the VM will dlsym:

 1/* com_lab_onload_Bridge.h — generated, then I filled the bodies */
 2#include <jni.h>
 3
 4JNIEXPORT void JNICALL
 5Java_com_lab_onload_Bridge_nInit(JNIEnv *, jclass, jstring);
 6
 7JNIEXPORT jint JNICALL
 8Java_com_lab_onload_Bridge_nAdd__II(JNIEnv *, jclass, jint, jint);
 9
10JNIEXPORT jint JNICALL
11Java_com_lab_onload_Bridge_nAdd__JJ(JNIEnv *, jclass, jlong, jlong);
12
13/* nTag is NOT in this header. Bound in JNI_OnLoad. */

Mangling rules I actually used (JNI spec, not folklore):

JavaNative symbol
. in package/class_
_ in a Java name_1
Unicode_0xxxx
overload__ + descriptor with /_, ;_2, [_3

So nAdd(int,int) is Java_com_lab_onload_Bridge_nAdd__II. A single nAdd without overloads would have been Java_com_lab_onload_Bridge_nAdd with no __II. The __ suffix appears only when the VM must disambiguate.

If a method were named n_init, the export would be Java_com_lab_onload_Bridge_n_1init. Grepping Java_com_lab_onload_Bridge_n_init is a miss.

readelf -s on the extracted .so

1unzip -p onload.apk lib/arm64-v8a/libmangle.so > libmangle.so
2file libmangle.so
3# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, not stripped
1$ readelf -s libmangle.so | grep -E 'Java_|JNI_OnLoad|nTag'
2    9: 0000000000001200    88 FUNC    GLOBAL DEFAULT   12 JNI_OnLoad
3   10: 0000000000001280   112 FUNC    GLOBAL DEFAULT   12 Java_com_lab_onload_Bridge_nInit
4   11: 0000000000001300    40 FUNC    GLOBAL DEFAULT   12 Java_com_lab_onload_Bridge_nAdd__II
5   12: 0000000000001330    48 FUNC    GLOBAL DEFAULT   12 Java_com_lab_onload_Bridge_nAdd__JJ
6# no Java_*nTag*

nm -D agrees. nTag is a local ARM64 function at 0x1380; it is not an export. Anyone who stops at grep Java_ thinks nTag does not exist. The Java side still has native String nTag().

1$ readelf -p .rodata libmangle.so | grep -E 'nTag|Bridge|Lcom'
2  [    40]  com/lab/onload/Bridge
3  [    58]  nTag
4  [    5d]  ()Ljava/lang/String;

Those three strings are the RegisterNatives table, not leftover javah names.

JNI_OnLoad: FindClass + one-row table

 1/* mangle.c — lab, NDK r20, arm64-v8a */
 2#include <jni.h>
 3
 4static jstring nTag(JNIEnv *env, jobject thiz) {
 5    jclass cls = (*env)->GetObjectClass(env, thiz);
 6    (void)cls;
 7    return (*env)->NewStringUTF(env, "lab-onload");
 8}
 9
10static const JNINativeMethod kTab[] = {
11    {"nTag", "()Ljava/lang/String;", (void *)nTag},
12};
13
14jint JNI_OnLoad(JavaVM *vm, void *reserved) {
15    JNIEnv *env = NULL;
16    if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK)
17        return JNI_ERR;
18    jclass cls = (*env)->FindClass(env, "com/lab/onload/Bridge");
19    if (!cls) return JNI_ERR;
20    if ((*env)->RegisterNatives(env, cls, kTab, 1) != 0)
21        return JNI_ERR;
22    return JNI_VERSION_1_6;
23}

nInit / nAdd are not in kTab. ART binds them by export name after loadLibrary. Mixing both styles in one .so is legal; I have seen it in SDKs that grew a hidden native after the public Java_* surface froze.

ARM64: GetEnv, FindClass, GetObjectClass

JNIEnv / JavaVM are function tables. Slot index × 8 is the ARM64 offset. I confirm against the jni.h on the NDK that built the lab, not from a blog table.

CallSlotOffset
JavaVM::GetEnv6#0x30
JNIEnv::FindClass6#0x30
JNIEnv::GetObjectClass31#0xf8
JNIEnv::RegisterNatives215#0x6b8
JNIEnv::GetStringUTFChars169#0x548
JNIEnv::NewStringUTF167#0x538
 1; llvm-objdump -d libmangle.so   JNI_OnLoad @ 0x1200
 21200:  a9be7bfd   stp     x29, x30, [sp, #-0x20]!
 31204:  910003fd   mov     x29, sp
 41208:  a90153f3   stp     x19, x20, [sp, #0x10]
 5120c:  aa0003f3   mov     x19, x0          ; JavaVM*
 61210:  f9400268   ldr     x8, [x19]        ; vm function table
 71214:  f9401d08   ldr     x8, [x8, #0x30]  ; GetEnv
 81218:  aa1303e0   mov     x0, x19
 9121c:  910043e1   add     x1, sp, #0x10    ; &env
101220:  528000c2   mov     w2, #0x6
111224:  72a00022   movk    w2, #0x1, lsl #16 ; JNI_VERSION_1_6 = 0x00010006
121228:  d63f0100   blr     x8
13122c:  350001c0   cbnz    w0, 1264         ; GetEnv != JNI_OK
14; FindClass("com/lab/onload/Bridge")
151230:  f9400be0   ldr     x0, [sp, #0x10]  ; JNIEnv*
161234:  f9400008   ldr     x8, [x0]
171238:  f9401d08   ldr     x8, [x8, #0x30]  ; FindClass
18123c:  90000001   adrp    x1, 0x2000
191240:  91010021   add     x1, x1, #0x40    ; "com/lab/onload/Bridge"
201244:  d63f0100   blr     x8
21; RegisterNatives(env, cls, kTab, 1)
221248:  f9400be0   ldr     x0, [sp, #0x10]
23124c:  f9400008   ldr     x8, [x0]
241250:  f9435d08   ldr     x8, [x8, #0x6b8] ; RegisterNatives
251254:  90000002   adrp    x2, 0x3000       ; kTab in .data
261258:  52800023   mov     w3, #1
27125c:  d63f0100   blr     x8

nTag uses GetObjectClass on this (instance native: x1 is jobject, not jclass):

 1; nTag @ 0x1380   jstring nTag(JNIEnv*, jobject)
 21380:  a9be7bfd   stp     x29, x30, [sp, #-0x20]!
 31384:  910003fd   mov     x29, sp
 41388:  aa0003f3   mov     x19, x0          ; JNIEnv*
 5138c:  aa0103f4   mov     x20, x1          ; jobject this
 61390:  f9400268   ldr     x8, [x19]
 71394:  f9407d08   ldr     x8, [x8, #0xf8]  ; GetObjectClass, slot 31
 81398:  aa1303e0   mov     x0, x19
 9139c:  aa1403e1   mov     x1, x20
1013a0:  d63f0100   blr     x8               ; jclass in x0, unused in lab
1113a4:  f9400268   ldr     x8, [x19]
1213a8:  f9429d08   ldr     x8, [x8, #0x538] ; NewStringUTF
1313ac:  aa1303e0   mov     x0, x19
1413b0:  90000001   adrp    x1, 0x2000
1513b4:  9101e021   add     x1, x1, #0x78    ; "lab-onload"
1613b8:  d63f0100   blr     x8

If a dump shows bl Java_com_lab_onload_Bridge_nTag, the sample is not this one. nTag is a table pointer.

nInit: mangled export, planted stack copy

1JNIEXPORT void JNICALL
2Java_com_lab_onload_Bridge_nInit(JNIEnv *env, jclass cls, jstring token) {
3    char buf[32];
4    const char *u = (*env)->GetStringUTFChars(env, token, NULL);
5    if (!u) return;
6    strcpy(buf, u);          /* lab bug: no bound vs 32 */
7    (*env)->ReleaseStringUTFChars(env, token, u);
8    (void)buf[0];
9}
 1; Java_com_lab_onload_Bridge_nInit @ 0x1280
 21280:  a9bd7bfd   stp     x29, x30, [sp, #-0x30]!
 31284:  910003fd   mov     x29, sp
 41288:  a90153f3   stp     x19, x20, [sp, #0x10]
 5128c:  aa0003f3   mov     x19, x0
 61290:  aa0203f4   mov     x20, x2          ; jstring
 71294:  f9400268   ldr     x8, [x19]
 81298:  f942a508   ldr     x8, [x8, #0x548] ; GetStringUTFChars, slot 169
 9129c:  aa1303e0   mov     x0, x19
1012a0:  aa1403e1   mov     x1, x20
1112a4:  d2800002   mov     x2, #0
1212a8:  d63f0100   blr     x8
1312ac:  910083e1   add     x1, sp, #0x20    ; buf[32]
1412b0:  aa0003e0   mov     x0, x0           ; utf
1512b4:  97ffffaa   bl      115c <strcpy@plt>

Sanitized reproduction

Emulator, userdebug, debug-signed lab APK. UI calls Bridge.nInit with the EditText. I pasted 40 As.

1F DEBUG  : ABI: 'arm64-v8a'
2F DEBUG  : pid: 3188, tid: 3188, name: lab.onload
3F DEBUG  : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
4F DEBUG  : backtrace:
5F DEBUG  :   #00 pc 00000000000012b8  /data/app/[REDACTED]/lib/arm64/libmangle.so
6F DEBUG  :       (Java_com_lab_onload_Bridge_nInit+0x38)

Frida on the export, length and prefix only:

 1/* trace_mangle.js — com.lab.onload only */
 2const p = Module.findExportByName('libmangle.so',
 3    'Java_com_lab_onload_Bridge_nInit');
 4Interceptor.attach(p, {
 5  onEnter(args) {
 6    const env = Java.vm.getEnv();
 7    const n = env.getStringUtfLength(args[2]);
 8    const utf = env.getStringUtfChars(args[2]);
 9    const s = utf.readCString();
10    const pre = s.slice(0, 4);
11    console.log('[nInit] utf.len=' + n + ' prefix=' + pre);
12  }
13});
1$ frida -U -f com.lab.onload -l trace_mangle.js --no-pause
2[nInit] utf.len=6  prefix=labtok
3[nInit] utf.len=40 prefix=AAAA

No full token. No eyJ / AKIA / cookie bodies in the log.

ASAN NDK rebuild of the same file (not the shipping .so):

1==3188==ERROR: AddressSanitizer: stack-buffer-overflow
2WRITE of size 41
3    #0 strcpy
4    #1 Java_com_lab_onload_Bridge_nInit mangle.c:41

What the two bind paths look like on disk

1nInit  →  export Java_com_lab_onload_Bridge_nInit           VA 0x1280
2nAdd   →  export Java_com_lab_onload_Bridge_nAdd__II|__JJ
3nTag   →  JNI_OnLoad → kTab[0].fnPtr                        VA 0x1380

If nm -D | grep Java_ is empty, switch to the RegisterNatives lab. If it is full of Java_* and a Java native is still missing, dump .rodata for leftover short names and xrefs from JNI_OnLoad. Do not assume one style per APK.

Commands appendix

1unzip -p onload.apk lib/arm64-v8a/libmangle.so > libmangle.so
2readelf -s libmangle.so | grep -E 'Java_|JNI_OnLoad'
3readelf -p .rodata libmangle.so | grep -E 'nTag|Bridge'
4llvm-objdump -d libmangle.so | less +/JNI_OnLoad
5frida -U -f com.lab.onload -l trace_mangle.js --no-pause
6adb logcat -b crash -d | tail -40