Skip to content

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:

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. GetPrimitiveArrayCritical and GetStringCritical may 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:jni finds 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=address and 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_PRELOAD the 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 own SIGSEGV handler and retains allocations by design.
  • Run the JVM with -Xcheck:jni and 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+0000 and 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 real 0x00, so a C string helper truncates there, which is why the code works from the length instead. Modified UTF-8 has the opposite property - a GetStringUTFChars result encodes U+0000 as 0xC0 0x80 and 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.

Additional Resources