Dock12/Java Native Interface and its hidden threats

Created by Luca Famà Mon, 13 Mar 2023 10:53:00 +0100 Modified Mon, 13 Mar 2023 10:53:00 +0100

Even though during the last years many programming languages became very popoular (Go, Rust, Python, etc..) Java is still one of the most commonly used programming language especially for enterprises in the field of e-commerce, finance, banking and app development.

Java is a general-purpose, portable and object-oriented programming language which is relatively easy to use, and due to its implementation it’s also considered a secure and safe language. This is due to the fact that a Java program is compiled to an intermediate language (the Java bytecode) which is then interpreted and executed within a virtual machine (the JVM - Java Virtual Machine).

The image below shows a schematic representation of the JVM architecture (taken from Wikipedia): jvm.png

The JVM architecture impies several security features that come “for free” when developing Java applications, among which:

  • in Java we don’t have direct access to memory (unlike we have in C/C++ using pointers)
  • Java automatically manages memory allocation using a garbage collector
  • each thread running in the JVM has its own stack and registers, so there is an implicit isolation between them
  • the generated bytecode is verified before running, to prevent unsafe operations
  • Java it’s a strongly and statically typed language, so every variable must be declared with the corresponding data type, reducing the risks of runtime errors
  • it includes a security manager (however deprecated in Java 17) that specifies which actions are safe or unsafe for the application

One of the major benefits of a Java application is that it’s platform independent: it can run on any JVM, regardless the underlying hardware architecture.

However, sometimes Java applications need to use code that is natevely complied for specific hardware (with the pitfall of breaking the portability just mentioned) because maybe your application need to communicate directly with the hardware. This code is usually implemented in C/C++ or assembly. Native code is also significantly used within the Java Development Kit (JDK): for example the classes under java.util.zip are just wrappers that invoke th popular Zlib C library.

That’s why the Java JDK provides the Java Native Interface (JNI) which is basically a framework that facilitates the usage of native code in your Java application.

Using the native keyword we can refer to a method that it’s implemented externally in a separate native shared library.

The following example shows a basic usage of the JNI:

public class MyJniApp {
	static {
		System.loadlibrary("my-native-lib");
	}
	
	private native void hello(String str);
	
	public static void main(String[] args){
		new MyJniApp().hello("Hello World");
	}
}

And this is the C code that implements the hello native method:

#include <jni.h>        // JNI header provided by JDK
#include <stdio.h>      
#include "MyJniApp.h" // Generated

// Implementation of the native method hello()
JNIEXPORT void JNICALL Java_MyJniApp_hello(JNIEnv *env, jobject thisObj, jstring str)
{
    printf("%s\n", (*env)->GetStringUTFChars(env, str, 0));
    return;
}

Notice that in the Java program, we had to load our compiled library (my-native-lib) by using the System.loadlibrary() method. Then we are able to use it just like any other Java method.

When a JVM passes control to a native method, it will also pass:

  • an interface pointer that is used to invoke JNI API functions from the native method
  • the reference of the class instance that called the native method
  • the parameters used by the method itself

Let’s take a look at another example:

class IntArray {
	/* declare a native method */
	private native int sumArray(int arr[]);
	public static void main(String args[]) {
		IntArray p = new IntArray();
		int arr[] = new int [10];
		for (int i = 0; i < 10; i++) arr[i] = i;
		/* call the native method */
		int sum = p.sumArray(arr);
		System.out.println("sum = " + sum);
	}
	static {
		/* load the library that implements the native method */
		System.loadLibrary("IntArray");
	}
}

Below the C code that implements the sumArray native method:

#include <jni.h>
#include "IntArray.h"
JNIEXPORT jint JNICALL Java_IntArray_sumArray(JNIEnv *env, _jobject *self, _jobject *arr)
/* env is an interface pointer through which a JNI API
function can be called.
self is the reference to the object on which the method is invoked.
arr is the reference to the array. */
{
	jsize len = (*env)->GetArrayLength(env, arr);
	int i, sum = 0;
	jint *body = (*env)->GetIntArrayElements(env, arr, 0);
	for (i=0; i<len; i++) {
		sum += body[i];
	}
	(*env)->ReleaseIntArrayElements(env,arr,body,0);
	return sum;
}

As we can see, in the C code we can call the JNI APIs (i.e. GetArrayLength, GetIntArrayElements and ReleaseIntArrayElements) by using the env pointer which points to a location that contains a pointer to a function table.

These APIs allow our native method to invoke Java methods, create/modify/inspect Java objects, throw Java exceptions and so on.

How improper JNI usage affects security

As we saw earlier, Java includes many security features that make the language safer compared to other low-level languages (like C/C++).

But if our application needs JNI to communicate with native libraries, we need to carefully understand the security risks and implications.

For example let’s say our Java application needs a native method (implemented in C) which simply echos the string back to the user console:

class Echo {
    public native void runEcho();

    static {
        System.loadLibrary("echo");
    }

    public static void main(String[] args) {
        new Echo().runEcho();
    }
}

And let’s assume the native C code is implemented as follow:

#include <jni.h>
#include "Echo.h" //the java class above compiled with javah
#include <stdio.h>

JNIEXPORT void JNICALL Java_Echo_runEcho(JNIEnv *env, jobject obj){
    char buf[64];
    gets(buf);
    printf(buf);
}

The C program is vulnerable to stack overflow, because it uses the gets function (which reads a line from the standard input) without performing any bounds checking on its input.

So, even though Java is (theoretically) immune to memory issues like buffer overflows, using JNI in such a way has the effect of nullify the JVM memory protection features, because the actual overflow will happen outside the JVM itself (and the JVM protections doesn’t extend to code written in other languages).

The previous one was a really simple example, but in real scenarios might be harder to detect an improper usage of JNI.

For example, let’s analyze the following native method:

JNIEXPORT void JNICALL Java_Myclass_mycopy(JNIEnv *env, jobject obj, jbyteArray jarr){
    char buffer[512];
		
    if((*env)->GetArrayLength(env, arr) > 512){
		JNU_ThrowArrayIndexOutOfBoundsException(env, 0);
    }
        
    // Get a pointer to the Java array
    jbyte *arr = (*env)->GetBytesArrayElements(env, jarr, NULL);
    // Copy the Java array to a local buffer
    strcpy(buffer, arr);
    (*env)->ReleaseByteArrayElements(env, jarr, carr, 0);
}

This method tries to copy the Java bytes array passed as argument to the native method, to a local buffer of size 512. Before the actual copy, the program checks if the array size is greater then 512 (which could cause a buffer overflow) and if it’s the case, it throws an exception (JNU_ThrowArrayIndexOutOfBoundsException). It looks fine right?

Unfortunately no, because Java and the JNI handles exceptions differently.

In Java, if an exception is thrown, the JVM immediately transfers the control to the nearest try/catch statement that matches the exception type. Instead, an exception raised through the JNI, does not block the native method execution and the exception is handle by the JVM only when the native method terminate. So the previous code snippet is still vulnerable to buffer overflow (the strcpy function will be executed in any case) and it might not be trivial to detect such vulnerabilities. The easy fix could be to immediately return the control to the JVM (using a return statement) just after the exception is thrown.

This is just one of several bug patterns that can be introduced when using JNI in a improper way. Others can be:

  • native methods are supposed to manipulate the Java references via the JNI API, but they can also directly (read/write) access these referecing, potentially resulting in JVM data leak and corruption
  • native methods have access to the JNI API interface pointers, so they could overwrite entries in the function table and bypass any check in the original funciton
  • violating access control rules (i.e. C/C++ code could potentially read private fields of a Java Object)
  • buffer or heap overflows
  • race conditions in file accesses
  • bypassing the security manager

Conclusions

JNI is a useful framework that allows Java programs to run native code and lets native code to use Java objects in the same way Java code uses them. But this comes at a price: if not properly managed, JNI native methods can bypass the JVM protections, introducing critical vulnerabilities in our application. Threat modeling our Java application is absolutely crucial to identify and understand potential threats and to keep our program safe.

If you want to learn more about JNI and its security risks, I reccomend you the following papers (where I mostly got the information to write this article):