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 hugesize_twhen 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. PreferGetByteArrayRegion(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)
- Draw the Kotlin byte-array → JNI pin/copy → native parser → returned-object trust boundaries.
- Add an intentionally vulnerable branch that copies the declared length before checking it.
- Capture the ASan finding using only the included synthetic input.
- Implement the contract in
native_parser.cpp; do not special-case the crashing sample. - Add
0,4095,4096,4097, truncation, trailing-byte, signedness, and aliasing tests. - Keep validation in both Kotlin (UX) and C++ (security boundary).
- In an owned app, use
GetByteArrayRegionor a bounded critical section, translate native errors into sealed Kotlin results, and never log payload bytes. - 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
jintbecome a hugesize_tafter 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.