I wanted one screen that proves the Java native wrapper and the ARM64 JNI function see the same argument. Two hooks, one call: Java.perform on the wrapper, Interceptor.attach on the export. Package is com.lab.jni.trace only.
Lab APK
1com.lab.jni.trace
2 NativeBridge.java
3 MainActivity.java
4 lib/arm64-v8a/libtrace.so
1package com.lab.jni.trace;
2
3public final class NativeBridge {
4 static { System.loadLibrary("trace"); }
5
6 public static native int nInit(String token);
7 public static native int nAdd(int a, int b);
8}
1/* trace.c — lab NDK, no network */
2#include <jni.h>
3#include <string.h>
4
5JNIEXPORT jint JNICALL
6Java_com_lab_jni_trace_NativeBridge_nInit(JNIEnv *env, jclass cls, jstring token) {
7 if (!token) return -1;
8 const char *u = (*env)->GetStringUTFChars(env, token, NULL);
9 if (!u) return -1;
10 jint n = (jint)strlen(u);
11 (*env)->ReleaseStringUTFChars(env, token, u);
12 return n;
13}
14
15JNIEXPORT jint JNICALL
16Java_com_lab_jni_trace_NativeBridge_nAdd(JNIEnv *env, jclass cls, jint a, jint b) {
17 return a + b;
18}
1$ readelf -s libtrace.so | grep Java_
2 10: 00000000000011a0 92 FUNC GLOBAL DEFAULT 12 Java_com_lab_jni_trace_NativeBridge_nInit
3 11: 0000000000001200 20 FUNC GLOBAL DEFAULT 12 Java_com_lab_jni_trace_NativeBridge_nAdd
Instance natives would put jobject this in args[1] and the first Java arg in args[2]. These two are static, so args[1] is jclass.
Script: both sides of the boundary
1/* dual_trace.js — lab package com.lab.jni.trace only */
2'use strict';
3
4function previewJava(s) {
5 if (s === null) return 'null';
6 const n = s.length();
7 const pre = n >= 4 ? s.substring(0, 4) : s;
8 return 'len=' + n + ' prefix=' + pre;
9}
10
11function previewUtf(env, jstr) {
12 if (jstr.isNull()) return 'null';
13 const n = env.getStringUtfLength(jstr);
14 const p = env.getStringUtfChars(jstr);
15 const s = p.readCString();
16 env.releaseStringUtfChars(jstr, p);
17 const pre = s.slice(0, 4);
18 return 'utf.len=' + n + ' prefix=' + pre;
19}
20
21Java.perform(function () {
22 const B = Java.use('com.lab.jni.trace.NativeBridge');
23
24 B.nInit.implementation = function (token) {
25 console.log('[java] nInit ' + previewJava(token));
26 const rc = this.nInit(token);
27 console.log('[java] nInit rc=' + rc);
28 return rc;
29 };
30
31 B.nAdd.overload('int', 'int').implementation = function (a, b) {
32 console.log('[java] nAdd a=' + a + ' b=' + b);
33 const rc = this.nAdd(a, b);
34 console.log('[java] nAdd rc=' + rc);
35 return rc;
36 };
37});
38
39const nInit = Module.findExportByName(
40 'libtrace.so', 'Java_com_lab_jni_trace_NativeBridge_nInit');
41const nAdd = Module.findExportByName(
42 'libtrace.so', 'Java_com_lab_jni_trace_NativeBridge_nAdd');
43
44Interceptor.attach(nInit, {
45 onEnter(args) {
46 this.env = Java.vm.getEnv();
47 this.jstr = args[2];
48 console.log('[jni ] nInit ' + previewUtf(this.env, this.jstr));
49 },
50 onLeave(rc) {
51 console.log('[jni ] nInit rc=' + rc.toInt32());
52 }
53});
54
55Interceptor.attach(nAdd, {
56 onEnter(args) {
57 /* static: x0=JNIEnv*, x1=jclass, w2=a, w3=b — Frida args[] is pointer-sized */
58 const a = args[2].toInt32();
59 const b = args[3].toInt32();
60 console.log('[jni ] nAdd a=' + a + ' b=' + b);
61 },
62 onLeave(rc) {
63 console.log('[jni ] nAdd rc=' + rc.toInt32());
64 }
65});
Spawn, do not attach to a random process:
1frida -U -f com.lab.jni.trace -l dual_trace.js --no-pause
If Module.findExportByName returns null, the library is not loaded yet. Wrap the Interceptor.attach in Interceptor.attach(Module.findExportByName('libdl.so','android_dlopen_ext') …) or wait on System.loadLibrary. In this lab the static block runs before MainActivity.onCreate, and -f stops early enough that the export exists by the time the script’s Java.perform callback fires. If it does not:
1function hookWhenLoaded() {
2 const p = Module.findExportByName('libtrace.so',
3 'Java_com_lab_jni_trace_NativeBridge_nInit');
4 if (p) {
5 /* attach as above */
6 return;
7 }
8 setTimeout(hookWhenLoaded, 50);
9}
10hookWhenLoaded();
Console (redacted)
I typed lab_ + 32 As into the lab EditText (Java length 36) and tapped Init, then Add with 3 and 9.
1Spawned `com.lab.jni.trace`. Resuming main thread...
2[Pixel-4::com.lab.jni.trace ]->
3[java] nInit len=36 prefix=lab_
4[jni ] nInit utf.len=36 prefix=lab_
5[jni ] nInit rc=36
6[java] nInit rc=36
7[java] nAdd a=3 b=9
8[jni ] nAdd a=3 b=9
9[jni ] nAdd rc=12
10[java] nAdd rc=12
Order is stable here: Java wrapper onEnter → JNI onEnter → JNI onLeave → Java wrapper returns. That is ART calling the .so on the same thread. If a sample posts to a native worker, JNI onEnter can land on a different tid; log Process.getcurrentThreadId() on both sides before concluding they disagree.
When the two sides disagree
They are not always the same string.
1// MainActivity — second button, lab only
2NativeBridge.nInit("lab_\uD83D\uDCA1"); // 'lab_' + U+1F4A1
1[java] nInit len=6 prefix=lab_
2[jni ] nInit utf.len=8 prefix=lab_
3[jni ] nInit rc=8
4[java] nInit rc=8
Java String.length() counts UTF-16 code units: 'l','a','b','_', high surrogate, low surrogate → 6. GetStringUTFLength counts modified UTF-8: the supplementary character becomes 4 bytes (ed a0 bd ed b2 a1 in CESU-8 / modified UTF-8, not the 4-byte UTF-8 f0 9f 92 a1). strlen of that buffer is 8. The native rc=8 is the C length, not the Java length. A hook that treats those two numbers as a mismatch bug is wrong.
I still only print len and a 4-char prefix. The code-unit / modified-UTF-8 gap is visible without dumping the body.
ARM64 at nInit (why Interceptor args look like that)
1; Java_com_lab_jni_trace_NativeBridge_nInit @ 0x11a0
211a0: a9be7bfd stp x29, x30, [sp, #-0x20]!
311a4: 910003fd mov x29, sp
411a8: a90153f3 stp x19, x20, [sp, #0x10]
511ac: aa0003f3 mov x19, x0 ; JNIEnv*
611b0: aa0203f4 mov x20, x2 ; jstring token
711b4: b4000140 cbz x0, 11dc
811b8: f9400268 ldr x8, [x19]
911bc: f942a508 ldr x8, [x8, #0x548] ; GetStringUTFChars
1011c0: aa1303e0 mov x0, x19
1111c4: aa1403e1 mov x1, x20
1211c8: d2800002 mov x2, #0
1311cc: d63f0100 blr x8
AAPCS64: x0 JNIEnv, x1 jclass, x2 first declared arg. Frida args[2] is that jstring. nAdd puts the two jints in w2/w3; args[2].toInt32() is the right width. Reading them as pointers is how people log a=0x3 and then waste a session.
Tombstone I keep for the lab (unrelated planted bug)
A third native, not hooked above, copies UTF into 16 bytes so the write-up has a crash that is a crash.
1JNIEXPORT void JNICALL
2Java_com_lab_jni_trace_NativeBridge_nCopy(JNIEnv *env, jclass cls, jstring s) {
3 char buf[16];
4 const char *u = (*env)->GetStringUTFChars(env, s, NULL);
5 strcpy(buf, u);
6 (*env)->ReleaseStringUTFChars(env, s, u);
7}
40-byte Java string:
1F DEBUG : signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x[REDACTED]
2F DEBUG : #00 pc 0000000000001248 libtrace.so (Java_com_lab_jni_trace_NativeBridge_nCopy+0x28)
ASAN rebuild:
1==4201==ERROR: AddressSanitizer: stack-buffer-overflow
2WRITE of size 41
3 #1 Java_com_lab_jni_trace_NativeBridge_nCopy trace.c:33
What I file
- Map:
NativeBridge.nInit (Ljava/lang/String;)I→libtrace.so!Java_com_lab_jni_trace_NativeBridge_nInit - Dual log: Java
len=36 prefix=lab_matches JNIutf.len=36 prefix=lab_on ASCII - Known delta: supplementary characters, Java length ≠ modified-UTF-8 length
- Redaction: prefix 4 + length. No token body, no
eyJ, noAKIA
Production: drop even the prefix if the first bytes look like a key id. Length alone is enough to prove the call happened.
Commands appendix
1unzip -p trace.apk lib/arm64-v8a/libtrace.so > libtrace.so
2readelf -s libtrace.so | grep Java_
3frida -U -f com.lab.jni.trace -l dual_trace.js --no-pause
4adb logcat -b crash -d | tail -30