Lab 04 — Kotlin/JNI Native Boundary: Break, Fix, Fuzz, Defend

Language: Kotlin + C++ | Tools: clang++, ASan/UBSan, libFuzzer, Make | Difficulty: 4/5

Pairs with the Phase 04 WARMUP Chapter 12 (JNI and the native boundary) and the HITCHHIKERS-GUIDE ("Native (JNI) Campaign"). Builds on Phase 01.

Why This Lab Exists (Purpose & Goal)

Managed Kotlin/Java code on Android is memory-safe — the runtime checks bounds and manages lifetimes. But apps reach for JNI (the Java Native Interface) to call C/C++ for performance (codecs, crypto, parsers), and that native code carries all of Phase 01's memory-corruption hazards. The most security-critical native code is media/file parsers — the source of Android's worst remote bugs (Stagefright). The goal of this lab is to make you secure the trust boundary at the JNI seam: the exact point where a length or buffer crosses from safe Kotlin into unsafe C++ and must not be blindly trusted.

The Concept, In the Weeds

The vulnerable design trusts three things it must not: the Java array length, signed header fields, and the caller-supplied output capacity. The security contract the fixed native core must enforce:

magic == "JN"
payload_length <= 4096
payload_length == input_length - header_length      (exact consumption)
output_capacity >= payload_length
no pointer arithmetic before the fixed header is present

The JNI-specific traps that make this harder than a plain C parser:

  • Signedness across the boundary. A jint (signed) that goes negative becomes a huge size_t when converted — the Phase 01 integer trap, now at the language seam. You must convert and check before using a length.
  • Concurrency / array pinning. If native code pins a Java array (GetPrimitiveArrayCritical) and a concurrent caller mutates it, the bytes you validated are not the bytes you copy — a TOCTOU race. Prefer GetByteArrayRegion (a copy) or a bounded critical section.
  • Ignored JNI exceptions. If a JNI call throws and native code keeps going, you operate on invalid state. Errors must be translated into sealed Kotlin results, never swallowed.
  • Defense at the right layer. Kotlin-side validation improves UX, but native validation is the security boundary — the attacker can reach the native function directly.

Why This Matters for Protecting the Company

A memory-corruption bug in a shipped native library is the most dangerous kind of mobile bug: it can be remote (a malicious media file, a crafted server response) and yields code execution on the user's device. These libraries are often third-party (a vendored codec) and built for multiple ABIs. When you can secure the JNI boundary — validate every length and buffer natively, handle signedness and concurrency, fuzz the native core under sanitizers across every ABI, and harden the build — you protect the mobile clients of the company's services against the highest-severity mobile class. You also learn to triage a JNI crash honestly — assessing reachability without overstating impact — which is exactly what a mobile security team needs.

Run

make test
make fuzz-smoke              # deterministic corpus driver
# On a Linux/macOS workstation, run real coverage-guided fuzzing with sanitizers:
clang++ -std=c++20 -fsanitize=fuzzer,address,undefined native_parser.cpp fuzz_target.cpp -o fuzz
./fuzz corpus/

Build, Break, Fix, Defend (Order)

  1. Draw the Kotlin byte-array → JNI pin/copy → native parser → returned-object trust boundaries.
  2. Add an intentionally vulnerable branch that copies the declared length before checking it.
  3. Capture the ASan finding using only the included synthetic input.
  4. Implement the contract in native_parser.cpp; do not special-case the crashing sample.
  5. Add 0, 4095, 4096, 4097, truncation, trailing-byte, signedness, and aliasing tests.
  6. Keep validation in both Kotlin (UX) and C++ (security boundary).
  7. In an owned app, use GetByteArrayRegion or a bounded critical section, translate native errors into sealed Kotlin results, and never log payload bytes.
  8. Ship the native library with stack protector, RELRO, NX, PIE, CFI where supported, and stripped release symbols retained separately for crash triage.

Review Questions (answer them)

  • Can a concurrent caller mutate the array while native code holds it?
  • Does a jint become a huge size_t after a negative-value conversion?
  • Does JNI throw an exception native code ignores?
  • Can an attacker force excessive allocation before the 4096-byte limit?
  • Are all ABI variants built and sanitizer-tested?

Validation — What You Should Be Able to Do Now

  • Identify the JNI seam as a trust boundary and validate every length/buffer natively, not just in Kotlin.
  • Handle the JNI-specific traps: signedness conversion, array-pin TOCTOU, ignored exceptions.
  • Fuzz a native parser under sanitizers, minimize a crash, fix the invariant, and add a regression corpus across ABIs.
  • Triage a JNI crash through reachability/controllability without overstating impact.

The Broader Perspective

This lab is where Phase 01's systems-level memory safety meets Phase 04's platform model: a memory-safe language does not make an app memory-safe if it calls into unsafe native code, and the language boundary is exactly where validation must be re-asserted. That principle — re-validate at every trust boundary, never assume the other side checked — is universal: it is the same reason a backend must authorize even though the app validated (Phase 04 Chapter 10), the same reason a downstream service can't trust an upstream's parsing (Phase 02 smuggling). Boundaries are where security lives; the JNI seam is one of the sharpest.

Interview Angle

  • "Investigate a native media-parser crash on Android." — Reproduce under ASan; root-cause the missing bounds/integer/lifetime check at the JNI/native boundary; assess reachability (can an attacker deliver this input?) and controllability without overstating; fix the invariant natively; regression-fuzz across ABIs.
  • "Kotlin is memory-safe — why is the app not?" — The native libraries it calls via JNI are C/C++ with full memory-corruption risk; signedness and trusted lengths at the boundary are where it breaks.

References

  • Phase 04 WARMUP Chapter 12; Phase 01 WARMUP (memory-defect families, sanitizers, fuzzing).
  • "Stagefright" (Joshua Drake); Android NDK / JNI documentation; AddressSanitizer docs.