This replaces the survey version of the same filename. Target is com.lab.ndk.notes / libnotes.so, a debug APK I signed. Goal: walk the .so the way I do on a real sample, with dumps, not a tooling list.
Lab layout
1com.lab.ndk.notes
2 NotesNative.java
3 lib/arm64-v8a/libnotes.so
1package com.lab.ndk.notes;
2
3public final class NotesNative {
4 static { System.loadLibrary("notes"); }
5
6 public static native void nInit(String token);
7 public static native int nSum(byte[] buf);
8}
1/* notes.c — NDK r26, ANDROID_ABI=arm64-v8a, -O0 */
2#include <jni.h>
3#include <string.h>
4
5static jint nSum(JNIEnv *env, jclass cls, jbyteArray buf) {
6 if (!buf) return 0;
7 jsize n = (*env)->GetArrayLength(env, buf);
8 jbyte *p = (*env)->GetByteArrayElements(env, buf, NULL);
9 jint s = 0;
10 for (jsize i = 0; i < n; i++) s += (jint)p[i];
11 (*env)->ReleaseByteArrayElements(env, buf, p, JNI_ABORT);
12 return s;
13}
14
15static const JNINativeMethod kTab[] = {
16 {"nSum", "([B)I", (void *)nSum},
17};
18
19jint JNI_OnLoad(JavaVM *vm, void *reserved) {
20 JNIEnv *env = NULL;
21 if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK)
22 return JNI_ERR;
23 jclass cls = (*env)->FindClass(env, "com/lab/ndk/notes/NotesNative");
24 if (!cls) return JNI_ERR;
25 (*env)->RegisterNatives(env, cls, kTab, 1);
26 return JNI_VERSION_1_6;
27}
28
29JNIEXPORT void JNICALL
30Java_com_lab_ndk_notes_NotesNative_nInit(JNIEnv *env, jclass cls, jstring token) {
31 char buf[32];
32 const char *u = (*env)->GetStringUTFChars(env, token, NULL);
33 if (!u) return;
34 strcpy(buf, u); /* lab bug */
35 (*env)->ReleaseStringUTFChars(env, token, u);
36 (void)buf[0];
37}
Mixed bind: nInit is a Java_* export, nSum is RegisterNatives. Same pattern as the 2019 mangling lab; this session is the ELF / objdump pass.
APK → ELF facts
1unzip -l notes.apk | grep libnotes
2# 19856 2025-01-04 lib/arm64-v8a/libnotes.so
3
4unzip -p notes.apk lib/arm64-v8a/libnotes.so > libnotes.so
5file libnotes.so
6# ELF 64-bit LSB shared object, ARM aarch64, dynamically linked, not stripped
1$ readelf -h libnotes.so | egrep 'Class|Machine|Type|Entry'
2 Class: ELF64
3 Type: DYN (Shared object file)
4 Machine: AArch64
5 Entry point address: 0x10d0
6
7$ readelf -d libnotes.so | egrep 'NEEDED|SONAME|FLAGS_1'
8 0x0000000000000001 (NEEDED) Shared library: [liblog.so]
9 0x0000000000000001 (NEEDED) Shared library: [libm.so]
10 0x0000000000000001 (NEEDED) Shared library: [libdl.so]
11 0x0000000000000001 (NEEDED) Shared library: [libc.so]
12 0x000000000000000e (SONAME) Library soname: [libnotes.so]
13 0x000000006ffffffb (FLAGS_1) Flags: NOW
14
15$ readelf -s libnotes.so | grep -E 'Java_|JNI_OnLoad|nSum|strcpy'
16 8: 00000000000011e0 96 FUNC GLOBAL DEFAULT 12 JNI_OnLoad
17 9: 0000000000001280 108 FUNC GLOBAL DEFAULT 12 Java_com_lab_ndk_notes_NotesNative_nInit
18 14: 0000000000001180 80 FUNC LOCAL DEFAULT 12 nSum
19 : 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcpy
NOW is Full RELRO-ish for the DSO (DT_FLAGS_1 NOW). nSum is LOCAL — nm -D will not show it. strcpy is UND, so the planted copy is a PLT call, easy to xref.
1$ readelf -p .rodata libnotes.so
2 [ 00] com/lab/ndk/notes/NotesNative
3 [ 20] nSum
4 [ 25] ([B)I
5
6$ readelf -x .data libnotes.so | head
7Hex dump of section '.data':
8 0x00023000 00200000 00000000 25200000 00000000 . ......% ......
9 0x00023010 80110000 00000000 ........
Three pointers: name "nSum", sig "([B)I", fn 0x1180. That is the JNINativeMethod row. File VAs; runtime add the load bias.
llvm-objdump -d: JNI_OnLoad
1llvm-objdump -d --no-show-raw-insn libnotes.so
100000000000011e0 <JNI_OnLoad>:
2 11e0: stp x29, x30, [sp, #-32]!
3 11e4: mov x29, sp
4 11e8: stp x19, x20, [sp, #16]
5 11ec: mov x19, x0 ; JavaVM*
6 11f0: ldr x8, [x19]
7 11f4: ldr x8, [x8, #48] ; GetEnv, slot 6, #0x30
8 11f8: mov x0, x19
9 11fc: add x1, sp, #16 ; JNIEnv**
10 1200: mov w2, #0x6
11 1204: movk w2, #0x1, lsl #16 ; JNI_VERSION_1_6 = 0x00010006
12 1208: blr x8
13 120c: cbnz w0, 1274 ; JNI_ERR
14 1210: ldr x0, [sp, #16] ; JNIEnv*
15 1214: ldr x8, [x0]
16 1218: ldr x8, [x8, #48] ; FindClass, slot 6
17 121c: adrp x1, 0x2000
18 1220: add x1, x1, #0 ; "com/lab/ndk/notes/NotesNative"
19 1224: blr x8
20 1228: cbz x0, 1274
21 122c: ldr x8, [sp, #16]
22 1230: ldr x9, [x8]
23 1234: ldr x9, [x9, #1720] ; RegisterNatives #0x6b8 = 1720
24 1238: mov x1, x0 ; jclass
25 123c: adrp x2, 0x23000
26 1240: add x2, x2, #0 ; kTab
27 1244: mov w3, #1
28 1248: mov x0, x8
29 124c: blr x9
30 1250: mov w0, #0x6
31 1254: movk w0, #0x1, lsl #16 ; JNI_VERSION_1_6
32 1258: ldp x19, x20, [sp, #16]
33 125c: ldp x29, x30, [sp], #32
34 1260: ret
#48 is GetEnv on JavaVM and FindClass on JNIEnv — same slot number, different tables. I keep both cheat-sheets. #1720 is RegisterNatives (slot 215 × 8). If Ghidra names it FUN_xxx I rename from the offset, not from a guessed string.
nInit export
10000000000001280 <Java_com_lab_ndk_notes_NotesNative_nInit>:
2 1280: stp x29, x30, [sp, #-64]!
3 1284: mov x29, sp
4 1288: stp x19, x20, [sp, #16]
5 128c: mov x19, x0 ; JNIEnv*
6 1290: mov x20, x2 ; jstring
7 1294: ldr x8, [x19]
8 1298: ldr x8, [x8, #1352] ; GetStringUTFChars #0x548 = 1352
9 129c: mov x0, x19
10 12a0: mov x1, x20
11 12a4: mov x2, xzr
12 12a8: blr x8
13 12ac: mov x1, x0 ; utf
14 12b0: add x0, sp, #32 ; char buf[32]
15 12b4: bl 10d0 <strcpy@plt>
16 12b8: ldr x8, [x19]
17 12bc: ldr x8, [x8, #1360] ; ReleaseStringUTFChars #0x550
18 12c0: mov x0, x19
19 12c4: mov x1, x20
20 12c8: mov x2, x1 ; (see note)
21 12cc: blr x8
strcpy@plt with destination sp+32 and a 64-byte frame: 32 bytes of buf, then saved regs. A 40-byte UTF string walks off the slot. I do not treat the ReleaseStringUTFChars operand mix-up in the comment as gospel — -O0 clang can reload the utf pointer into x2 from a stack spill; I confirm in gdb if I need the exact register.
nSum (hidden)
10000000000001180 <nSum>:
2 1180: stp x29, x30, [sp, #-48]!
3 1184: mov x29, sp
4 1188: mov x19, x0
5 118c: mov x20, x2 ; jbyteArray
6 1190: ldr x8, [x19]
7 1194: ldr x8, [x8, #1368] ; GetArrayLength slot 171, #0x558
8 1198: mov x0, x19
9 119c: mov x1, x20
10 11a0: blr x8 ; w0 = jsize
11 11a4: mov w21, w0
12 11a8: ldr x8, [x19]
13 11ac: ldr x8, [x8, #1472] ; GetByteArrayElements slot 184
14 11b0: mov x0, x19
15 11b1: mov x1, x20
16 11b4: mov x2, xzr
17 11b8: blr x8
18; w21 times add of signed bytes, then ReleaseByteArrayElements JNI_ABORT=2
No Java_*nSum* symbol. Mapping is table-only. GetArrayLength then GetByteArrayElements is the normal byte[] path; JNI_ABORT means the native did not write back.
Load bias on device
1adb shell cat /proc/$(adb shell pidof com.lab.ndk.notes)/maps | grep libnotes
16f3a1c1000-6f3a1c6000 r-xp 00000000 ... /data/app/[REDACTED]/lib/arm64/libnotes.so
26f3a1d5000-6f3a1d6000 r--p 00004000 ...
36f3a1d6000-6f3a1d7000 rw-p 00005000 ...
Bias 0x6f3a1c1000. JNI_OnLoad runtime VA = 0x6f3a1c1000 + 0x11e0. I redact the app path. Maps lines are enough to turn file offsets into later tombstone PCs.
Sanitized crash
EditText → nInit, 40 As.
1F DEBUG : ABI: 'arm64-v8a'
2F DEBUG : pid: 5012, tid: 5012, name: lab.ndk.notes
3F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
4F DEBUG : backtrace:
5F DEBUG : #00 pc 00000000000012b4 libnotes.so (Java_com_lab_ndk_notes_NotesNative_nInit+0x34)
6F DEBUG : #01 pc 0000000000[REDACTED] libart.so (art::JNI<...>+...)
PC 0x12b4 is the bl strcpy@plt. ASAN rebuild of the same notes.c:
1==5012==ERROR: AddressSanitizer: stack-buffer-overflow
2WRITE of size 41
3 #0 strcpy
4 #1 Java_com_lab_ndk_notes_NotesNative_nInit notes.c:37
Frida, both bind paths, redacted:
1/* notes_trace.js — com.lab.ndk.notes only */
2const base = Module.getBaseAddress('libnotes.so');
3Interceptor.attach(base.add(0x1280), {
4 onEnter(args) {
5 const env = Java.vm.getEnv();
6 const n = env.getStringUtfLength(args[2]);
7 const p = env.getStringUtfChars(args[2]).readCString();
8 console.log('[nInit] utf.len=' + n + ' prefix=' + p.slice(0, 4));
9 }
10});
11Interceptor.attach(base.add(0x1180), {
12 onEnter(args) {
13 const env = Java.vm.getEnv();
14 const n = env.getArrayLength(args[2]);
15 console.log('[nSum] byte[].len=' + n);
16 }
17});
1$ frida -U -f com.lab.ndk.notes -l notes_trace.js --no-pause
2[nInit] utf.len=6 prefix=labtok
3[nInit] utf.len=40 prefix=AAAA
4[nSum] byte[].len=4
No token body. nSum logs array length, not contents.
What I file
| Java | Bind | VA |
|---|---|---|
nInit (Ljava/lang/String;)V | export Java_com_lab_ndk_notes_NotesNative_nInit | 0x1280 |
nSum ([B)I | JNI_OnLoad → kTab[0] | 0x1180 |
Bug class: GetStringUTFChars → strcpy into 32 bytes. Repro: 40-byte Java string, SIGSEGV at nInit+0x34. Fix: GetStringUTFLength + bound, or do not use a stack slot.
Commands appendix
1unzip -p notes.apk lib/arm64-v8a/libnotes.so > libnotes.so
2readelf -h -d -s libnotes.so | less
3readelf -p .rodata libnotes.so
4llvm-objdump -d libnotes.so | less +/JNI_OnLoad
5frida -U -f com.lab.ndk.notes -l notes_trace.js --no-pause
6adb logcat -b crash -d | tail -40