YinkoShield

Knowledge Center / engineering · 2025·03

Defensive JNI for low-end Android — what runs in production at 12,000 hardware configurations

Field notes from running native security code across the most fragmented Android device population in payments. Defensive JNI patterns, the Semgrep rules behind them, 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 for performance-critical or obfuscation-sensitive operations. JNI is powerful: it lets you do things in C/C++ that Java alone cannot do efficiently. But it comes with zero safety nets.

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, leaks, or — worse — security vulnerabilities.

We use JNI to implement low-level security functions (anti-tampering, runtime integrity, signing) in native code for speed and obfuscation. To make that survive at production scale across thousands of device configurations, we adopted a defensive programming mindset:

  • Assume nothing will go as expected. Every JNI call might fail or throw, especially on low-end devices in unpredictable states.
  • Clean up after ourselves religiously. Memory allocated in C and references created via JNI are not freed by the garbage collector.
  • 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 references 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. Semgrep scans code for patterns and catches common JNI mistakes before they reach a user’s device.

The rules cover:

  • Missing exception handling. Any JNI call that can throw — FindClass, GetMethodID, NewObject — must be followed by an ExceptionCheck or ExceptionClear. The rule flags any that are not.
  • Null-return checks. Functions like GetMethodID or NewObject return NULL if something went wrong (and typically throw too). Using a null reference without checking causes a crash.
  • Resource leaks. NewGlobalRef without a corresponding DeleteGlobalRef. Local references created in loops without cleanup. Native allocations (malloc, new) that are never freed.
  • Tight-loop overhead. FindClass and GetMethodID called 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 execution evidence

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.

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 execution evidence defensible at scale — and what makes the difference, for our customers, between financial inclusion and exclusion.