CWE-111: Direct Use of Unsafe JNI
Overview
The Java Native Interface hands data from a memory-safe runtime to code that has none. Everything the JVM guarantees - bounds-checked arrays, no dangling pointers, exceptions instead of undefined behaviour - stops at the boundary, and a native function that mishandles what it receives corrupts the JVM's own process.
Two properties of that boundary produce most findings, and neither is obvious from the Java side. Java string lengths are counted in UTF-16 code units while JNI hands the native side modified UTF-8 bytes, so a length check written in Java does not bound the buffer written in C. And a pending Java exception does not interrupt native code: the C function keeps running with whatever values it has, which turns a handled error into a memory-safety bug.
Relationship to Other CWEs
The entries below place CWE-111 against its two parents, which come from different MITRE views, and against the native weaknesses that usually follow from it:
- CWE-111 (this page) - crossing from Java's managed runtime into unmanaged native code without validating the boundary
- CWE-695 (Use of Low-Level Functionality) - ChildOf in Research Concepts (view 1000)
- CWE-245 (J2EE Bad Practices: Direct Management of Connections) - the other child of CWE-695 with a page here, and the same shape of defect with connection handling rather than native code as the thing reached for
- CWE-20 (Improper Input Validation) - ChildOf in Seven Pernicious Kingdoms (view 700), the legacy taxonomy. A reader triaging in Research Concepts will not find CWE-20 above this entry at all
- CWE-787 (Out-of-bounds Write) - the concrete failure once attacker-controlled data reaches the native side unvalidated
- CWE-125 (Out-of-bounds Read) - the other common one. Both pages cover the C/C++ mechanics in more depth, while this page covers the boundary itself
Risk
Critical: A memory-safety defect on the native side compromises the entire JVM process - code execution for whoever controls the overflowing input, or a crash that takes every thread with it. Nothing in the Java layer contains it: there is no bounds check to fail and no exception to catch, so the first sign is usually the process dying or the attacker already having control.
Common Vulnerable Patterns
Unvalidated data crossing into a fixed native buffer
public class UnsafeJNI {
// VULNERABLE - the string crosses into native code with no length or content check
public native void processData(String input);
static {
System.loadLibrary("nativelib");
}
public void handleUserInput(String userInput) {
processData(userInput);
}
}
// VULNERABLE - C implementation copying into a fixed buffer with no bound
JNIEXPORT void JNICALL Java_UnsafeJNI_processData
(JNIEnv *env, jobject obj, jstring input) {
const char *str = (*env)->GetStringUTFChars(env, input, NULL);
char buffer[100];
strcpy(buffer, str); // no bound - overflows for any input over 99 bytes
(*env)->ReleaseStringUTFChars(env, input, str);
}
Why this is vulnerable: strcpy copies until it finds a NUL, and the source
length is whatever the caller submitted. The Java side offers no protection
here - it cannot, because the buffer it would need to know about is declared in
C. This is a stack buffer overflow reachable from any caller of a public Java
method.
A Java-side length check that does not bound the native buffer
// VULNERABLE - looks validated; the bound is in the wrong unit
public void handleUserInput(String userInput) {
if (userInput.length() > 100) {
throw new IllegalArgumentException("Too long");
}
processData(userInput); // native side allocates 101 bytes
}
Why this is vulnerable: String.length() returns UTF-16 code units, and JNI
delivers modified UTF-8 bytes. A 100-code-unit string of non-ASCII text is 200
or 300 bytes, so a native buffer sized from the Java count is overflowed by
input that passed validation. The check reads as a fix and moves the overflow to
a character set nobody tested.
The ratio is bounded, and knowing the bound is what lets you size a buffer at
all: modified UTF-8 never spends more than three bytes per UTF-16 code unit,
so a length() limit of N needs 3N + 1 bytes. A character outside the Basic
Multilingual Plane is not an exception to that - it is a surrogate pair, which
length() already counts as two code units and modified UTF-8 encodes as two
three-byte sequences. It costs more per character and exactly the same per
code unit, which is the unit the check above is written in.
Ignoring pending exceptions and NULL returns
// VULNERABLE - JNI errors do not stop C execution
JNIEXPORT void JNICALL Java_UnsafeJNI_lookup
(JNIEnv *env, jobject obj, jstring key) {
const char *str = (*env)->GetStringUTFChars(env, key, NULL);
// If allocation failed, str is NULL and no exception is pending - the spec
// gives this function no THROWS clause, and HotSpot returns NULL without
// throwing, so ExceptionCheck does not see it. Only a NULL test catches
// this, and this line still runs without one.
size_t len = strlen(str);
jclass cls = (*env)->FindClass(env, "com/example/Missing");
// If the class is absent, cls is NULL and NoClassDefFoundError is pending -
// ThrowNew with a NULL class is undefined behaviour
(*env)->ThrowNew(env, cls, "not found");
}
Why this is vulnerable: Throwing a Java exception from JNI does not unwind
the C frame. ThrowNew and friends only record that an exception is pending;
every following statement executes normally, and the exception is delivered when
control returns to Java. Code written as though a throw returns will dereference
NULL, double-release, or complete an operation it believed it had aborted.
Secure Patterns
Bound the data in bytes, on the side that owns the buffer
import java.nio.charset.StandardCharsets;
public class SafeJNI {
private static final int MAX_INPUT_BYTES = 1000;
private native void processData(byte[] utf8Input);
static {
System.loadLibrary("nativelib");
}
// SECURE - the check is in the same unit the native side allocates in
public void handleUserInput(String userInput) {
if (userInput == null) {
throw new IllegalArgumentException("Input cannot be null");
}
byte[] utf8 = userInput.getBytes(StandardCharsets.UTF_8);
if (utf8.length > MAX_INPUT_BYTES) {
throw new IllegalArgumentException(
"Input exceeds " + MAX_INPUT_BYTES + " bytes");
}
processData(utf8);
}
}
Why this works: The bound is expressed in bytes, which is what the native
buffer is measured in, so the check and the allocation cannot disagree about
what "1000" means. Passing a byte[] rather than a String also sidesteps
modified UTF-8 entirely: the native side receives exactly these bytes, including
any embedded NUL, instead of JNI's encoding in which a U+0000 arrives as the
two-byte sequence 0xC0 0x80 and a C string helper sees a different length than
Java did.
Keep the native method private and expose only the validating wrapper. A
public native method is callable directly, and every check written in the
wrapper is then optional.
Native code that checks every JNI call and releases on every path
#include <jni.h>
#include <stdlib.h>
#include <string.h>
// SECURE - bounded copy, exception checks, and cleanup on all paths
JNIEXPORT void JNICALL Java_SafeJNI_processData
(JNIEnv *env, jobject obj, jbyteArray input) {
if (input == NULL) {
jclass ex = (*env)->FindClass(env, "java/lang/NullPointerException");
if (ex != NULL) {
(*env)->ThrowNew(env, ex, "Input is null");
}
return; // return explicitly: ThrowNew does not
}
const jsize length = (*env)->GetArrayLength(env, input);
if (length < 0 || length > 1000) {
jclass ex = (*env)->FindClass(env, "java/lang/IllegalArgumentException");
if (ex != NULL) {
(*env)->ThrowNew(env, ex, "Input too long");
}
return;
}
char *buffer = malloc((size_t) length + 1);
if (buffer == NULL) {
jclass ex = (*env)->FindClass(env, "java/lang/OutOfMemoryError");
if (ex != NULL) {
(*env)->ThrowNew(env, ex, "Allocation failed");
}
return;
}
// Copies exactly `length` bytes; throws ArrayIndexOutOfBoundsException
// rather than reading past the end if the bounds are wrong
(*env)->GetByteArrayRegion(env, input, 0, length, (jbyte *) buffer);
if ((*env)->ExceptionCheck(env)) {
free(buffer); // the pending exception did not return for us
return;
}
buffer[length] = '\0';
/* process buffer, length bytes */
free(buffer);
}
Why this works: Each control closes one of the failure modes above.
GetByteArrayRegion copies a caller-specified count into a buffer the caller
sized, so there is no dependence on a NUL terminator and no way to read past the
array - it raises ArrayIndexOutOfBoundsException instead. ExceptionCheck
after it is what turns a pending exception into an actual early return, and the
free before that return is why the error path does not leak. Every FindClass
result is tested before use, because passing a NULL class to ThrowNew is
undefined behaviour in the middle of error handling. And each ThrowNew is
followed by return, since recording a pending exception does not stop the
function.
Where a jstring must be used instead of a byte array, GetStringUTFRegion
into a sized buffer is the closest equivalent - but it is not a drop-in
substitute for GetByteArrayRegion, and copying the pattern above without
reading its contract overflows the buffer. Its len argument counts UTF-16
code units of the source, not destination bytes, and the spec says plainly
that "the resulting number modified UTF-8 encoding characters may be greater
than the given len argument". Size the destination with
GetStringUTFLength(), which returns the modified UTF-8 byte count, or with the
3N bound above. The spec also does not require the copy to be NUL-terminated,
so clear the buffer first if anything downstream calls strlen.
If you use GetStringUTFChars instead, test the result for NULL - it
returns NULL on allocation failure and, unlike FindClass or
GetByteArrayRegion, leaves no pending exception for ExceptionCheck to find -
and pair every successful call with ReleaseStringUTFChars on every path,
including the ones that throw.
Considerations
- Whether the native code is needed at all. This weakness only exists because there is a boundary. Java's own APIs cover most of what JNI is historically used for, and where a native library genuinely must be called, the Foreign Function & Memory API (final in Java 22, preview in 19-21) does it from Java with bounds-checked memory segments and no hand-written C. Removing the native layer removes the CWE. A rewrite is a bigger change than adding checks, so treat it as a roadmap item rather than the fix for this finding.
- Where to put the check when both sides can do it. Validate in Java for the error message and the fast rejection, and validate in C for the safety property. The native function is the one that owns the buffer, and it may be reachable from another caller, another binding, or a future refactor - a check that lives only in the Java wrapper is a convention, not a guarantee.
- The critical-access functions are a performance trade with a real cost.
GetPrimitiveArrayCriticalandGetStringCriticalmay return a direct pointer into the JVM's heap and may suspend garbage collection for the duration. Neither is guaranteed - the spec says a copy is made where a direct pointer is not possible. Between the acquire and the release, calling almost any other JNI function or blocking can deadlock the VM. Use them only where profiling shows the copy matters, keep the section to a few lines, and never let it call back into Java. -Xcheck:jnifinds what code review does not. It validates arguments, reference types and exception state on every JNI call and reports violations the normal runtime accepts silently. It is a diagnostic mode rather than a production setting, but a test suite that has never been run under it has not really tested the boundary.
Testing
The failure modes here are undefined behaviour, so a passing test proves less than usual - the same input can work a thousand times and corrupt memory on the next. Test with the sanitizers on.
- Build the native library with
-fsanitize=addressand run the full suite. An overflow that a normal build absorbs aborts with the offending frame. Without this, the buffer tests below can pass against the vulnerable code. A JNI library is loaded into a JVM that is not itself instrumented, so the runtime has to be preloaded (LD_PRELOADthe ASan runtime, or link it into the launcher) or it refuses to start; expect to disable the signal handler and the leak checker as well, since the JVM installs its ownSIGSEGVhandler and retains allocations by design. - Run the JVM with
-Xcheck:jniand assert the log is clean. Unreleased references, wrong reference types and calls made while an exception is pending are reported here and nowhere else. - Send input at, one byte over, and far over the byte limit - and send it as
non-ASCII. A 100-character string of three-byte characters is the case a
String.length()check waves through, so assert the byte-bound rejection with multibyte text specifically. - Send a string containing
U+0000and assert the native side handles the length it was given rather than stopping at a NUL. This is a test of the byte-array design, not a comparison in its favour: the byte array carries a real0x00, so a C string helper truncates there, which is why the code works from the length instead. Modified UTF-8 has the opposite property - aGetStringUTFCharsresult encodesU+0000as0xC0 0x80and never truncates, but then reports a length Java did not agree with. - Force each JNI error path - a null argument, an allocation failure via a memory-limited run - and assert the Java caller sees the intended exception type, not a crash and not a silent success.
- Run the error paths in a loop under a memory profiler and assert native heap usage returns to baseline. Cleanup that is missing only on the throw path is the standard defect, and it never shows up in a passing-input test.