skip to Main Content
Android Native robot and KMSEC Scarab mascot

[0x0 Unpacking JNI Binaries]: Introduction, reverse engineering JNI_OnLoad + RegisterNatives

 

TL;DR

  1. JNI Binaries are a foreign function calling interface in Android, bridges in C/C++ code.
  2. JNI Binaries host their own function resolution in the JNI_OnLoad + RegisterNatives call chain.
  3. Malware has been known to override this call chain to obfuscate code.

This post kicks off a blog series im writing up about reverse engineering JNI libraries. I feel that JNI libraries don’t receive enough love in assessments and afaik the best comerically and community available tools for automation pretty much do elaborate string matching without much direct insight into runtime behaviour or make use of novel technologies like symbolic execution. So to start things off, in this post I’m following the basic lifecylce of a JNI library and explaining how the JNI_OnLoad function is used, how malware can obfuscate itself using this call and we’ll also have a look at some natural variants of this function in the wild. Enjoy!

Introduction to Java Native Interface (JNI) on Android

Java Native Interface (JNI) is the crucial framework that enables Java code in the Android Virtual Machine to interact with applications and libraries written in C/C++. Introduced with Java 1.1, JNI was adopted by Android to access the underlying Linux kernel and system C/C++ libraries, and to allow developers to integrate custom native code. JNI is essentially a borrowed foreign function interface for Java that was adoped by the Android folks and it allows for (i)  Code Reusability: Integrate existing, mature C/C++ libraries (e.g., for cryptography, physics engines, or image processing), famously the Unity Engine, VR applications, banking apps that makes use of crypto will typically embed some JNI libraries.  And the developers don’t need to go through much headache porting this to android beyond just making the compiler work. (ii) Performance Optimization: Achieve significant speed gains in computationally intensive tasks by bypassing the overhead of the Java Garbage Collector (GC) and the AVM. (iii) Precise Memory Management: Gain explicit control over memory allocation and deallocation, crucial for low-latency or resource-constrained applications. (iv)  Access to Low-Level System Features: Directly interact with platform-specific hardware or system calls not exposed via standard Java APIs. (v) Obfuscation and Protection: Moving sensitive logic to native binaries can make it marginally more challenging for casual reverse engineers to analyze compared to pure Java bytecode –more on this later.

Okay all the marketing speak aside, JNI means we get to look for buffer overflows, use after free’s and all of our favourite C/C++ bugs in Android apps, which would traditionally be shielded quiet well by the Java’s safe and sound stage based virtual machine. We pretty much understand what JNI is, but we don’t know (i) how it works under the hood, (ii) how does a java function resolve to a JNI native function? Next sections handle that.


How Calls to JNI Binary Functions are Made (Static vs. Dynamic Registration)

The process of bridging Java and native code involves loading the library and resolving the method addresses and calling a few initialisation and de-init functions. There are two primary methods for resolving native functions: static registration (relying on naming conventions) this uses the implicit mechanisms in the JNI runtime to figure out how to run your natively defined code and dynamic registration (using JNI_OnLoad) which requires you to explicitly define what happens once your library touches the process memory space.

JNI_OnLoad is called by the runtime if defined in the binary, and because it may present a profound modification of execution flow and environment we must unpack how it works and take a look at some common examples collected from the wild so you know what to expect. The next few sections handle that.

Static Registration (The Default Process)

In this section we will look at the steps required in order to setup your own JNI_OnLoad call and we’re doing it with some reference code included in the android jni repo. First lets look at the code flow in a native lib when a JNI_OnLoad call is not defined.

(i) Declare a Java Native Method: You declare a method with the native keyword in Java. This is not the JNI_OnLoad call just yet, its our code the stuff you want to actually run in C/C++ land. The code snippet below we define an example method called “getNativeString()” which has a string return type. As you can see in the code it needs to be tagged with the “native” keyword in order for the JVM to understand that the JNI runtime must take over when its gotta figure out where this code is.

public class MyNativeClass{
    public native String getNativeString();
    // Statically load the native library
    static {
        System.loadLibrary("mynativelib");// Resolves to libmynativelib.so } }

(ii) Define a Corresponding Method in C/C++ (by Name): The native function must adhere to the strict JNI naming convention: Java_<package_name>_<class_name>_<method_name>. If you’re using Android Studio most of this is done for you when you select the JNI presets for your project. And for the most part when you’re unpacking android packages from the wild, you’ll see a similar naming convention, this should tip you off that you’re most probably dealing with an SDK built app – the dev wanted to rely on the common resolution mechanism. Here’s what a typical method looks like:

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_mynativeapp_MyNativeClass_getNativeString(
        JNIEnv* env,
        jobject /* this */){
    // ... implementation ...
}

Here we added some arguments which we didn’t have in the Java code definition, namely: JNIEnv *env, and jobject labelled /* this */ by an inline comment. What are these two mysterious additions? Why aren’t they included in the Java version of the code?

  • jobject  “this” argument, is a local reference to the calling class of this JNI method. In order to do nifty things like (i) inspect the calling class, check for either correctness or security reasons what the class path and essentially identity is of the calling class, maybe the native method injects crypto keys into the calling java class, you might want to be paranoid about who calls it then!   (ii) make calls to the calling class, maybe return info or modify the object in the java layer while executing native code among other things, this jobect is essentially the same as a “this” class attribute in a normal java class it gives you scope of the objects parent class which opens a lot of useful things related to the instantiating class constructor a common pattern in java we are recreating here. Important to note also its against API design to make copies of this pointer or try to store it and use it after the lifespan of the JNI method itself. 
  • JNIEnv *env, is a pointer to the JNI structure which contains a lot of methods that interact with the JVM in a per class instance, its the JNIs native API to allow interaction with the JVM ostensibly scoped under the permissions of the calling class, so you can essentially do things like (i) instantiate classes, (ii) modify class attributes. Same pro tip as before:  this pointer is not meant to live longer than the JNI method its injected into, if you want a process based lifespan then you should invest in a global reference (in later posts we will cover what these are).

(iii) Load the Native Library & Resolution: When System.loadLibrary("mynativelib") is called the runtime ads a little resolution step to figure out which canonical path to use and how to add on the right file extension. Alternative to this you could use System.load("mynativelib.so") which requires the dev to use a more explicit name including the file extension. The difference in convenience is slight, loadLibrary allows you to use platform agnostic naming. These two calls make use of dlopen to inject the library into the processes executable memory space (whatever memory is preconfigured for this according to the load commands in the ELF definition, memory space permissions enforced by the OS etc).

At this point we have our code injected, and once loaded into memory the runtime will handle calling your string method defined above. With the standard process out of the way, we need to walk through the JNI_OnLoad process, which may or may not trigger depending on whether your binary defines one. The following sections walk through JNI_Onload and how its related to RegisterNatives, after that we will look at some disassembly help figure out how to identify this in simple and perhaps more semantics devoid environments like obfuscated and stripped binaries.

Dynamic Registration using JNI_OnLoad (The Alternative Process)

When a native library exports a function named JNI_OnLoad, the entire resolution process changes significantly.  JNI_OnLoad is called before your actual code and it receives a pointer to the JVM instance (different to the JNIEnv pointer that normal JNI methods receive!).  Typically this is a point in the lifecycle that allows you to prepare pointers, data structures and classes the wider library might need so basic “init” type stuff. For instance for a video rendering library it might help to load up any textures that you would want here, maybe download images that might be needed or start up the threads that would do that. 

This function has a fixed signature and is automatically called by the ART/JVM (an example i used that worked was from the test samples from test/004-JniTest/jni_test.cc), lets take a look at the sample before i blab about it out of context:

// --- JNI_OnLoad Implementation ---
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
    printf("--- JNI_OnLoad Test Function Calls ---\n");
    g_JavaVM = vm;
    JNIEnv* env = NULL;
    jint result;

    result = vm->GetEnv((void**)&env, JNI_VERSION_1_6);
    print_result("GetEnv (JNI_VERSION_1_6)", result);

    if (result != JNI_OK || env == NULL) {
        return JNI_ERR;
    }

    // Verbose runtime checks
    printf("  -> **SKIPPING** DestroyJavaVM: Calling this would terminate the JVM.\n");
    result = vm->AttachCurrentThread(&env, NULL);
    print_result("AttachCurrentThread (Redundant)", result);
    result = vm->GetEnv((void**)&env, (jint)9999);
    print_result("GetEnv (JNI_VERSION_9999)", result);

    // Dynamic Registration for callmenative_fromonload
    jclass clazz = env->FindClass("com/kmsec/nativellibsec/MainActivity");
    if (clazz == NULL) return JNI_ERR;

    // Cache a global reference to demonstrate cleanup later in OnUnload
    g_cachedClass = (jclass)env->NewGlobalRef(clazz);

    JNINativeMethod methods[] = {
            {(char*)"callmenative_fromonload", (char*)"()V", (void*)native_logic_for_onload}
    };

    if (env->RegisterNatives(clazz, methods, 1) < 0) { <--- make sure the JVM knows about our methods
        printf("RegisterNatives failed.\n");
        return JNI_ERR;
    }

    printf("--- JNI_OnLoad Test & Registration Complete ---\n");
    return JNI_VERSION_1_6;
}

The example in the test code above is great because it shows some common patterns and things you would expect in a typical, sane non-malicious JNI_Onload call. Namely stuff like (i) grabbing a copy of the JNIEnv pointer, so it can make the (ii) RegisterNatives call which setups up the resolution of native to java methods. Another important thing to note is that the JNI_Onload function receives a pointer to the JavaVM struct, (as I’ve mentioned before, its very very hard to find a break down of how this looks in reverse engineering and there’s no reference material available for what offset means what – but you’re in luck we will dig into this before the end of the post!).

Here’s a code snippet of the JavaVM pointer definition i found, just to make sure i know what im  talking about (this is from the oracle docs btw) :

...
01905 
01906 struct JavaVM_ {
01907     const struct JNIInvokeInterface_ *functions;
01908 #ifdef __cplusplus
01909 
01910     jint DestroyJavaVM() {
01911         return functions->DestroyJavaVM(this);
01912     }
01913     jint AttachCurrentThread(void **penv, void *args) {
01914         return functions->AttachCurrentThread(this, penv, args);
01915     }
01916     jint DetachCurrentThread() {
01917         return functions->DetachCurrentThread(this);
01918     }
01919 
01920     jint GetEnv(void **penv, jint version) {
01921         return functions->GetEnv(this, penv, version);
01922     }
01923     jint AttachCurrentThreadAsDaemon(void **penv, void *args) {
01924         return functions->AttachCurrentThreadAsDaemon(this, penv, args);
01925     }
01926 #endif
01927 };

So basically it wraps whats called a JNIInvokeInterface (might be more interesting uses of this), and it populates with some methods that look like they’re pretty useful for handling VM threads and controlling the VM instance. We’re gonna need this info a bit later so lets make a listing of the JavaVM methods, in full:

Method Name Full C Signature OpenJDK Source Link (HotSpot)
DestroyJavaVM jint (*DestroyJavaVM)(JavaVM *vm); jni.cpp#L1957
AttachCurrentThread jint (*AttachCurrentThread)(JavaVM *vm, void **penv, void *args); jni.cpp#L1960
DetachCurrentThread jint (*DetachCurrentThread)(JavaVM *vm); jni.cpp#L1963
GetEnv jint (*GetEnv)(JavaVM *vm, void **penv, jint version); jni.cpp#L1967
AttachCurrentThreadAsDaemon jint (*AttachCurrentThreadAsDaemon)(JavaVM *vm, void **penv, void *args); jni.cpp#L1970

So we have a hand full of functions here (post edit note: I’ve linked the Oracle definition here, which is technically not correct, but i checked and the APIs are similarly defined here) , a really common one you’ll encounter, usually early in the JNI_OnLoad is GetEnv, because it provides a copy of the JNIEnv pointer which can allow you to instantiate classes and do a bunch of useful stuff we will talk about in a coming post. Okay so we get JNI_OnLoad we get what its arguments are and we even have a working example to try out. So What happens after the JNI_OnLoad?  Well then its the user defined function which will get executed potentially, and when thats done there is a de-initialisation call which is fired call JNI_OnUnLoad which is called. This function is not so commonly defined but may still appear, here’s a working example of one:

// --- JNI_OnUnload Implementation ---
/**
 * Note on Disassembly: The argument list for OnUnload is identical to OnLoad (JavaVM*, void*).
 * To differentiate them in a dump, look for specific function calls like DeleteGlobalRef
 * or resource releasing logic rather than FindClass or RegisterNatives.
 */
JNIEXPORT void JNICALL JNI_OnUnload(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    printf("\n--- JNI_OnUnload Called ---\n");

    if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) {
        // Even if GetEnv fails, we proceed with manual cleanup where possible
        g_JavaVM = NULL;
        return;
    }

    // (i) Global reference management
    // If you want to avoid leaking memory, you must destroy global references here.
    if (g_cachedClass != NULL) {
        env->DeleteGlobalRef(g_cachedClass);
        g_cachedClass = NULL;
        printf("  -> Global reference g_cachedClass destroyed.\n");
    }

    printf("Native library unloaded and JNI_OnUnload executed successfully.\n");

    g_JavaVM = NULL;
}
And as in the previous example we can see some common activities you would expect in a JNI_Unload call: (i) global reference management, if you want to avoid leaking memory you probably want to destroy global references (ii) releasing resources like shared memory, file pointers, network sockets etc.  Pay attention again to the argument list, its the same one as for JNI_Onload. This may be confusing and can cause quite a bit of ambiguity when you’re looking either of these in a disassembly dump. Although one hint that may differentiate them is whether they use the JavaVM pointer and whether they tend to reference things like DestroyJavaVM, DeleteGlobalRef etc over other JavaVM functions, you can pretty much guess its a Unload not an UnLoad. Its not some space age tech methodology, but it is something :) and it will probably work!
So the next section of the post digs into some light disassembly, we’re going to look at a couple examples of JNI libraries with JNIOnload functions and see if we can guess whats going on  (we won’t be absolutely clinical here, i just felt it was a good idea to have some concrete examples to check out before we work on more of a detailed RE methodology in later posts i this series)
Here’s one i found in an app i downloaded just the other day, it doesn’t something with a com/app/class (example i drummed up to protect the org who made this app) type of string (offset 0x00c1d3c), my guess is that its probably searching for the class (lines 0x because other functions this library might make use of it.
000c1ce0  uint64_t JNI_OnLoad(int64_t* arg1)

000c1ce0  ff0301d1   sub     sp, sp, #0x40
000c1ce4  fd7b01a9   stp     x29, x30, [sp, #0x10] {__saved_x29} {__saved_x30}
000c1ce8  f65702a9   stp     x22, x21, [sp, #0x20] {__saved_x22} {__saved_x21}
000c1cec  f44f03a9   stp     x20, x19, [sp, #0x30] {__saved_x20} {__saved_x19}
000c1cf0  fd430091   add     x29, sp, #0x10 {__saved_x29}
000c1cf4  55d03bd5   mrs     x21, tpidr_el0
...

000c1d34  f40340f9   ldr     x20, [sp {var_40}]
000c1d38  21fdfff0   adrp    x1, 0x68000
000c1d3c  21801a91   add     x1, x1, #0x6a0  {data_686a0, "com/app/class"}
000c1d40  880240f9   ldr     x8, [x20]
000c1d44  e00314aa   mov     x0, x20
000c1d48  165540f9   ldr     x22, [x8, #0xa8]
000c1d4c  081940f9   ldr     x8, [x8, #0x30]
000c1d50  00013fd6   blr     x8
...
000c1d7c  f44f43a9   ldp     x20, x19, [sp, #0x30] {__saved_x20} {__saved_x19}
000c1d80  f65742a9   ldp     x22, x21, [sp, #0x20] {__saved_x22} {__saved_x21}
000c1d84  fd7b41a9   ldp     x29, x30, [sp, #0x10] {__saved_x29} {__saved_x30}
000c1d88  ff030191   add     sp, sp, #0x40
000c1d8c  c0035fd6   ret     


We also see an interesting pattern (pointer offset + branch with link) that will become increasingly common, to harken back to what we covered earlier what you are seeing here the pattern of taking a pointer probably passed as an argument (offset 0x00c1cec register x20 takes in something pointing to the stack. This is our magic JNI environment structs and objects that get populated in by the JNI runtime.

Hope you enjoyed this post, stay tuned for the next one in this series, which will cover in depth reverse engineering walk throughs.


References and Reading

  1. https://www.usenix.org/system/files/usenixsecurity25-di-bartolomeo.pdf
  2. https://s3.eurecom.fr/docs/tops25_ruggia.pdf
  3. https://github.com/maddiestone/AndroidAppRE/blob/master/reversing_native_libs.md
  4. https://www.virusbulletin.com/virusbulletin/2019/01/vb2018-paper-unpacking-packed-unpacker-reversing-android-anti-analysis-native-library/

Keith is the founder of KMSecurity (Pty) Ltd. and a passionate security researcher with a storied career of helping clients all over the world become aware of the information security risks. Keith has worked for clients in Europe, the Americas and Asia and in that time gained experience assessing for clients from a plethora of industries and technologies. Keith’s experience renders him ready to tackle any application, network or organisation his clients need help with and is always eager to learn new environments. As a security researcher Keith has uncovered bugs in some prominent applications and services including Google Chrome Browser, various Google Services and components of the Mozilla web browser.

Back To Top