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 Type | JNI Type | Description |
---|---|---|
boolean | jboolean | 8-bit unsigned |
byte | jbyte | 8-bit signed |
char | jchar | 16-bit unsigned |
short | jshort | 16-bit signed |
int | jint | 32-bit signed |
long | jlong | 64-bit signed |
float | jfloat | 32-bit IEEE 754 |
double | jdouble | 64-bit IEEE 754 |
String | jstring | Reference to Java string |
Object | jobject | Reference to any Java object |
Class | jclass | Reference to Java class object |
int[] | jintArray | Reference to Java int array |
Object[] | jobjectArray | Reference 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:
Signature | Java Type |
---|---|
Z | boolean |
B | byte |
C | char |
S | short |
I | int |
J | long |
F | float |
D | double |
V | void (return only) |
Ljava/lang/String; | String |
[I | int[] |
[[I | int[][] |
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