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.

Java to JNI to .so
Figure 1. Java.perform sits on the wrapper; Interceptor sits on the JNI export.

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;)Ilibtrace.so!Java_com_lab_jni_trace_NativeBridge_nInit
  • Dual log: Java len=36 prefix=lab_ matches JNI utf.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, no AKIA

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