Reversing JNI Libraries: Understanding the Foundation

JNI is everywhere nowadays! From being used for transpilation to sophisticated malware attempting to stay hidden, as more developers turn to native code for speed & obfuscation, and as more threats leverage JNI for evasion, understanding how to reverse engineer these libraries has become essential. I’ll walk you through everything you need to know about handling them :3

What We’ll Cover

In this first part, we’re going to establish the groundwork you’ll need before diving into actual reversing techniques. We’ll explore how the JVM works under the hood, what JNI actually is, and how Java communicates with native code.

Lib Loading

So when you call System.loadLibrary("owo") in your Java code, the JVM does some magic behind the scenes! It maps this to the actual library file using platform specific naming. libowo.so on Linux, owo.dll on Windows, or libowo.dylib on macOS. The JVM then searches through all the directories in java.library.path to find your library.

But here’s where it gets interesting for us! If the library has a JNI_OnLoad function exported, the JVM calls it immediately after loading.

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    // malware especially tend to init here
    return JNI_VERSION_1_8;
}

Also, each ClassLoader maintains its own list of loaded libraries, and the JVM keeps separate symbol namespaces for each one. This means you can actually load multiple versions of the same library without conflicts.

Method Resolution

JNI method resolution has two main approaches, and both are important when you’re trying to figure out whats happening:

Static Resolution

The JVM looks for native methods using a somewhat predictable naming scheme: Java_package_Class_methodName. So if you have a native void purr() method in class cat.aprl.meow, the JVM will search for:

JNIEXPORT void JNICALL Java_cat_aprl_meow_purr(JNIEnv *env, jobject obj)

But wait! If you’re dealing with C++ code, watch out for name mangling. The actual symbol might look like _Z15Java_cat_aprl_meow_purrP7_JNIEnvP7_jclass instead of the nice clean Java_cat_aprl_meow_purr you’d expect.

Dynamic Registration

Here’s where things get interesting! More sophisticated code (especially malware/transpilers) uses RegisterNatives() to bind methods dynamically, bypassing the VM’s naming convention.

JNINativeMethod methods[] = {
    {"eviluwu", "()V", (void*)&evil_function_colon_three}
};
(*env)->RegisterNatives(env, clazz, methods, 1);

This is way harder to reverse since the connection between Java method names and native functions only exists at runtime. Making static analysis a lot more annoying, it’s common with malware and transpilers such as JNIC

The JNI Bridge

The JNI bridge has two main components: JavaVM (the actual VM instance) and JNIEnv (your thread local gateway to Java).

JNIEnv

The JNIEnv pointer is thread local storage. You cannot share it between threads. In C, it’s a pointer to a function table. In C++, it’s a class that wraps those function calls.

Every single interaction with Java goes through this interface:

// All your JNI calls kinda look like this
jstring str = (*env)->NewStringUTF(env, "Hawk tuah!");
jclass clazz = (*env)->FindClass(env, "cat/aprl/aeaeaeae");

the JVM creates a registry for each Java to native transition, mapping local references to Java objects and making sure they don’t get garbage collected until your native method returns.

The Memory Model

This is very important for understanding JNI code, Java objects live in the managed heap, whilst your native code operates in the native heap. You never get direct pointers to Java objects. Everything goes through the JNIEnv interface. This is actually a security feature, but it also makes reverse engineering a bit more interesting!

Thread Attachment

If native code needs to call back to Java from a different thread, it has to attach that thread first using AttachCurrentThread():

JavaVM *jvm; // We got this earlier with GetJavaVM()
JNIEnv *env;
(*jvm)->AttachCurrentThread(jvm, (void**)&env, NULL);
// Now env is valid for this thread
(*jvm)->DetachCurrentThread(jvm); // Clean up

You’ll see this pattern a lot, especially in transpilation mainly because it needs to constantly go back and forth with the VM.

JNI Data Types & Signatures

Alright, let’s talk about how Java types map to JNI types, because this is pretttyyyy crucial when you’re trying to understand what’s going on in native.

Type Mappings

Here’s how Java types translate to JNI:

Java TypeJNI TypeDescription
booleanjboolean8-bit unsigned
bytejbyte8-bit signed
charjchar16-bit unsigned
shortjshort16-bit signed
intjint32-bit signed
longjlong64-bit signed
floatjfloat32-bit IEEE 754
doublejdouble64-bit IEEE 754
StringjstringReference to Java string
ObjectjobjectReference to any Java object
ClassjclassReference to Java class object
int[]jintArrayReference to Java int array
Object[]jobjectArrayReference to Java object array

Method Signatures

This is where it gets a bit more interesting.!? JNI uses a specific encoding for method signatures that looks absolutely unreadable at first glance. although they’re actually pretttyyy easy to understand, Here’s their pattern:

(parameter_types)return_type

The signature characters you’ll see everywhere:

SignatureJava Type
Zboolean
Bbyte
Cchar
Sshort
Iint
Jlong
Ffloat
Ddouble
Vvoid (return only)
Ljava/lang/String;String
[Iint[]
[[Iint[][]

Real Examples

Let’s look at some actual method signatures you’ll encounter:

// void hawkTuah()
"()V"

// int calculate(int a, int b)
"(II)I" 

// String meowLoudly(String input, boolean flag)
"(Ljava/lang/String;Z)Ljava/lang/String;"

// void meowArray(int[] numbers)
"([I)V"

// Object[][] getMatrix()
"()[[Ljava/lang/Object;"

Notice how class names use forward slashes instead of dots, and they’re wrapped in L...;. Arrays get [ prefixes for each dimension.

Where You’ll See This

You’ll encounter these signatures most commonly in:

// finding methods dynamically
jmethodID methodID = (*env)->GetMethodID(env, clazz, "meowLoudly", 
                                       "(Ljava/lang/String;Z)Ljava/lang/String;");

// dynamic native method registration  
JNINativeMethod methods[] = {
    {"transRights", "(I)V", (void*)&trans_Rights},
    {"ummMeow", "([BZ)Ljava/lang/String;", (void*)&umm_Meow}
};

When you’re reversing, signatures are pretty important for understanding what parameters a function expects and what it returns. Malware often tries to obfuscate the method names, but the signatures usually give away exactly what data types it’s working with, isn’t completely unique to native either.

Reference Management

Alright, this is where JNI gets a bit weird and where a lot of people mess up. JNI has it’s own reference system that you need to understand when you’re reversing.

Local vs Global References

By default, every Java object you get from JNI functions is a local reference. These are automatically cleaned up when your native method returns, but here’s the thing, you’re limited to about 16 local references at a time.

// These are all local references
jstring str = (*env)->NewStringUTF(env, "meow");
jclass clazz = (*env)->FindClass(env, "cat/aprl/aeaeaeae");
jobject obj = (*env)->NewObject(env, clazz, constructor);
// The VM will clean these up when the method exits

But what if you want to keep a reference around longer? That’s when a global references should be used:

// Convert local to global reference
jclass localClazz = (*env)->FindClass(env, "cat/aprl/meow");
jclass globalClazz = (*env)->NewGlobalRef(env, localClazz);

// Clean up the local reference immediately
(*env)->DeleteLocalRef(env, localClazz);

// Now globalClazz can be used across method calls and threads
(*env)->DeleteGlobalRef(env, globalClazz);

The 16 Reference Limit

This is where things get interesting. The JVM only gives you 16 local reference slots. If you create more without cleaning up, you’ll crash the VM. You’ll often see code like the following:

// Processing a large array, need to clean up as we go
for (int i = 0; i < huge_array_length; i++) {
    jobject item = (*env)->GetObjectArrayElement(env, array, i);
    
    // Doing thingys uhhh :3
    
    // Clean up immediately to avoid hitting the limit
    (*env)->DeleteLocalRef(env, item);
}

Or sometimes you’ll see something like this instead

// Request more local reference slots upfront
(*env)->EnsureLocalCapacity(env, 50);
// Now we can safely create 50 local references

Wrapping Up

And that’s the foundation! we’ve covered the fundamentals of the VM that we’ll need when reversing JNI libraries.

In Part 2, we’ll start actually reversing stuff, whether it’s transpilation, minecraft cheats with native obfuscation or malware, until then try poking around with stuff yourself!

Thanks for reading, and I’ll see you in Part 2! :3