Knowledge Center / engineering · 2025·03
Defensive JNI for low-end Android — patterns and Semgrep rules behind production-scale hardening
Field notes from running native security code across a fragmented Android device population in payments. Defensive JNI patterns, the Semgrep rules behind them (open-sourced at Yinkoshield/jni-hardening-with-semgrep), and why crash-proofing matters in constrained markets.
The low-end Android device challenge
Most of the devices we run on are not the ones you test against in CI. Limited memory, slow processors, costly data connections, vendor-specific behaviour, and platform-API drift. On a 1 GB RAM phone, a small leak can crash the app after enough usage. On a 2G/3G uplink, every JNI roundtrip that could be amortised has to be.
Reliability on these devices is not a nice-to-have. For our customers, an app that crashes on a low-end phone is an app the bottom-third of their customer base cannot use. It is the difference between financial inclusion and exclusion.
JNI — powerful tool, potential pitfalls
Many mobile apps — including YinkoShield’s runtime — use JNI to call
native C/C++ code where the JVM is not the right tool: performance-
critical hot paths, primitives that need access to syscalls and
process-level facilities the managed runtime does not expose
(prctl, mmap, ptrace-state introspection), and code paths that
benefit from holding key handles outside the Java heap. JNI is
powerful — and it comes with zero safety nets [1, 2]. The Oracle JNI
Specification [1] and the Android NDK JNI Tips guide [2] are the
authoritative references.
When you write JNI code, you step outside Android’s managed memory and into manual memory management, raw pointers, and explicit error handling. Mistakes there cause crashes (CWE-476 [4]), leaks (CWE-401 [5]), or — worse — security vulnerabilities.
We use JNI to implement low-level security functions (anti-tampering,
runtime integrity, signing-call dispatch) in native code primarily for
the access path: hardware-backed signing keys are reached via the
Android Keystore via the AndroidKeyStore JCA provider (which routes
through the Keymint / Keymaster HAL via the keystore2 system
service — apps do not directly call the HAL), and the call path
benefits from being adjacent
to the syscall layer rather than indirected through the JVM. Native
code is also harder to introspect than dex/ART; that is a side effect,
not the rationale. To make this survive at production scale across the
fragmented Android device population we ship to, we adopted a
defensive programming mindset:
- Assume nothing will go as expected. Every JNI call that crosses the boundary can fail or throw, especially on low-end devices in unpredictable states.
- Distinguish local from global references. Local references are
released automatically when the JNI frame returns (or the thread
detaches), but the local-reference table has a limited capacity
(512 entries in Android ART; the JNI specification minimum is 16) — long-running native loops without explicit
DeleteLocalRefexhaust it. Global references created withNewGlobalRefand weak globals require explicitDeleteGlobalRef/DeleteWeakGlobalRef; the garbage collector does not reclaim them. - Avoid doing too much in one go. JNI calls have overhead. Calling into Java in a tight loop, or vice versa, is slow and memory-hungry. We batch operations and cache class / method handles to minimise frequency.
This approach is the difference between a runtime that runs on a mid-range phone in a controlled-network market and a runtime that runs on a 1 GB phone in a 2G/3G market. We chose the second.
Semgrep rules — defensive JNI as static analysis
To help the broader Android community, we open-sourced a set of Semgrep rules that encode our defensive JNI best practices. The repository is at github.com/Yinkoshield/jni-hardening-with-semgrep [6]. Semgrep [7] scans code for patterns and catches common JNI mistakes before they reach a user’s device.
Semgrep is pattern-based and AST-local; it is strong at intra-function
JNI hygiene and weaker at cross-translation-unit ownership tracking.
For the deeper pointer-aliasing and memory-leak cases that escape
Semgrep, we pair it with clang-tidy and Clang Static Analyzer; the
two cover different failure shapes.
The Semgrep rules cover:
- Missing exception handling. Any JNI call that can pend an
exception — including
FindClass,GetMethodID,GetStaticMethodID,GetFieldID, theCall<Type>Methodfamily,NewObject,NewStringUTF,GetStringUTFChars,GetByteArrayElements,GetPrimitiveArrayCritical— must be followed byExceptionCheck/ExceptionOccurredand anExceptionClearwhere the caller intends to recover. Calling further JNI functions while an exception is pending is undefined except for the small set of “exception-safe” functions in the JNI Specification [1]. - Null-return checks. Functions like
FindClass,GetMethodID,NewObject,NewStringUTF, and the array-element accessors returnNULLon failure (and typically throw too). Using a null reference without checking causes a crash (CWE-476 [4]). - Resource leaks.
NewGlobalRefwithout a correspondingDeleteGlobalRef; long-running JNI calls that accumulate local references withoutDeleteLocalRef; intra-functionmalloc/newwithout a pairedfree/delete(CWE-401 [5]). - Tight-loop overhead.
FindClassandGetMethodIDcalled repeatedly for the same class instead of cached before the loop. On a low-end device this is the difference between a smooth run and a stuttering one.
Why this matters for the witness layer
The defensive JNI patterns are how the Trusted Runtime Primitive survives in production across the device population we serve. Every signed event the runtime produces depends on the runtime not crashing mid-action, not leaking memory across hours of use, and not exhausting the JNI call budget on a slow device.
Reliability is the substrate beneath the cryptography. Without it, the strongest signature in the world is signed by a runtime that has already crashed.
Execution Evidence Infrastructure (EEI) — the device-identity infrastructure layer for banking and payments — has a hard prerequisite: a runtime that doesn’t crash on the device it actually runs on. Defensive JNI is how that prerequisite is met on the long tail of low-end Android.
Conclusion — defensive coding as a discipline
Defensive JNI is built on a few principles every developer working across fragmented device populations should internalise:
- Treat every native interaction as a potential failure mode.
- Manage memory and references with the same care as cryptographic material.
- Optimise for the slowest device you ship to, not the median.
- Make the failure modes observable in code, not at runtime.
By following these patterns, apps stay reliable on devices with severe hardware limitations. That reliability is what makes EEI defensible at scale — and what makes the difference, for our customers, between financial inclusion and exclusion.
External references
[1] Oracle. Java Native Interface Specification. docs.oracle.com/en/java/javase/21/docs/specs/jni. Cited 2025-03-15.
[2] Android Developers. JNI tips. developer.android.com/training/articles/perf-jni. Cited 2025-03-15.
[3] Android Developers. Android NDK Stable APIs. developer.android.com/ndk/guides/stable_apis. Cited 2025-03-15.
[4] MITRE. CWE-476: NULL Pointer Dereference. cwe.mitre.org/data/definitions/476.html. Cited 2025-03-15.
[5] MITRE. CWE-401: Missing Release of Memory after Effective Lifetime. cwe.mitre.org/data/definitions/401.html. Cited 2025-03-15.
[6] Yinkoshield. jni-hardening-with-semgrep. github.com/Yinkoshield/jni-hardening-with-semgrep. Cited 2025-03-15.
[7] Semgrep. Pattern-based static analysis for JNI. semgrep.dev. Cited 2025-03-15.