From Java to Native: How a .so's Functions Get Loaded, Step by Step

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

From Java to Native: How a .so’s Functions Get Loaded, Step by Step

A .so file — what can I do with it? That’s usually where native analysis starts. On the Java side, calling into a .so is straightforward; this article is about the native side of that loading, which is where the interesting work happens.

0. The load flow for a single .so

.so load flow

The Java layer calls into the .so layer, then the native loader takes over. The Java-side call itself isn’t the focus here.

1. dlopen and android_dlopen_ext()

“Dynamic Link” — dynamic loading. On Windows you dynamically load DLL files; on Linux the equivalent file is .so, and the function that loads it is dlopen.

The dlopen family

dlopen opens a new library and loads it into memory. Its main job is to load symbols from the library — symbols that weren’t known at compile time. This mechanism means you can add or remove a module from the system without recompiling.

The function prototype:

void *dlopen(const char *filename, int flag);

The first argument is the filename of the header’s owning .so. The second argument is a set of flags — for example RTLD_NOW and RTLD_LAZY (resolve symbols now vs. on demand), and RTLD_GLOBAL (lets libraries loaded later see this library’s symbols). The way it works: pass the handle returned by dlopen as the first argument to dlsym() to get the address of a symbol in the library.

android_dlopen_ext()

This is Android’s own extension of dlopen, and it adds a few capabilities.

The function prototype:

void* android_dlopen_ext(const char* path, int flag, const void* ext_data);

You can see it adds an ext_data parameter. Looking at the source, most of the added functionality is tied to flags in the second parameter:

typedef struct {
  /** A bitmask of `ANDROID_DLEXT_` enum values. */
  uint64_t flags;

  /** Used by `ANDROID_DLEXT_RESERVED_ADDRESS` and `ANDROID_DLEXT_RESERVED_ADDRESS_HINT`. */
  void*   _Nullable reserved_addr;
  /** Used by `ANDROID_DLEXT_RESERVED_ADDRESS` and `ANDROID_DLEXT_RESERVED_ADDRESS_HINT`. */
  size_t  reserved_size;

  /** Used by `ANDROID_DLEXT_WRITE_RELRO` and `ANDROID_DLEXT_USE_RELRO`. */
  int     relro_fd;

  /** Used by `ANDROID_DLEXT_USE_LIBRARY_FD`. */
  int     library_fd;
  /** Used by `ANDROID_DLEXT_USE_LIBRARY_FD_OFFSET` */
  off64_t library_fd_offset;

  /** Used by `ANDROID_DLEXT_USE_NAMESPACE`. */
  struct android_namespace_t* _Nullable library_namespace;
} android_dlextinfo;

What happens after dlopen

dlopen flow

2. do_dlopen

This walkthrough uses Android 4 source. The latest Android loader has more code, but the underlying principle is the same.

dlopen above is just an entry point; the real core logic lives in do_dlopen:

void* dlopen(const char* filename, int flags) {
  ScopedPthreadMutexLocker locker(&gDlMutex);
  soinfo* result = do_dlopen(filename, flags);
  if (result == NULL) {
    __bionic_format_dlerror("dlopen failed", linker_get_error_buffer());
    return NULL;
  }
  return result;
}

soinfo* do_dlopen(const char* name, int flags) {
    // check the incoming flag type
  if ((flags & ~(RTLD_NOW|RTLD_LAZY|RTLD_LOCAL|RTLD_GLOBAL)) != 0) {
    DL_ERR("invalid flags to dlopen: %x", flags);
    return NULL;
  }
    // set memory protection to read-write so the soinfo struct can be modified after find_library loads
  set_soinfo_pool_protection(PROT_READ | PROT_WRITE);
  soinfo* si = find_library(name); // returns the so info chain
  if (si != NULL) {
    si->CallConstructors(); // calls the constructor here
  }
    // no more write permission here
  set_soinfo_pool_protection(PROT_READ);
  return si;
}

The return type is soinfo*. An soinfo represents “the process’s loaded-so chain” — the set of .so files the process has already loaded. The returned value si is that chain. The two main steps here are find_library and CallConstructors, covered below.

3. find_library

find_library passes its argument on through a chain of further calls, as the comments show:

static soinfo *find_loaded_library(const char *name)
{
    soinfo *si;
    const char *bname;

    // TODO: don't use basename only for determining libraries
    // http://code.google.com/p/android/issues/detail?id=6670

    bname = strrchr(name, '/'); // split off the so filename
    bname = bname ? bname + 1 : name;

    for (si = solist; si != NULL; si = si->next) {// recursively search for the so filename
        if (!strcmp(bname, si->name)) {
            return si;
        }
    }
    return NULL;
}

static soinfo* find_library_internal(const char* name) {
  if (name == NULL) {
    return somain;  // return the shared library
  }

  soinfo* si = find_loaded_library(name);
  if (si != NULL) {
    if (si->flags & FLAG_LINKED) {// the former checks the flag field, the latter checks if it's linked
      return si; // if linked, load it directly
    }
    DL_ERR("OOPS: recursive link to \"%s\"", si->name);
      // error: recursive link
      // [recursive link: during dynamic library loading, if the same library is requested to load multiple times, a recursive link can happen. This is usually undesirable, since it causes circular dependencies or duplicate loads.]
    return NULL;
  }

  TRACE("[ '%s' has not been loaded yet.  Locating...]", name);
    // if not loaded yet, reload via load_library
  si = load_library(name); // load_library is introduced below
  if (si == NULL) { // if still null after reloading, return null
    return NULL;
  }

  // At this point we know that whatever is loaded @ base is a valid ELF
  // shared library whose segments are properly mapped in.
    // returns base, size and name
  TRACE("[ init_library base=0x%08x sz=0x%08x name='%s' ]",
        si->base, si->size, si->name);

    // via this function
  if (!soinfo_link_image(si)) {// this function parses the section info in the dynamic library
    munmap(reinterpret_cast<void*>(si->base), si->size);
    soinfo_free(si);
    return NULL;
  }

  return si;
}

static soinfo* find_library(const char* name) {
  soinfo* si = find_library_internal(name);
  if (si != NULL) {
    si->ref_count++;
  }
  return si;
}

load_library [to be continued]

Related topics: ELF file format, loading of file partitions.

4. call_constructors

Using the section info parsed by the image-linking function above, this function gets every .so name the shared library depends on, and once all dependencies are initialized, runs init_func and the init_array methods to initialize the library itself.

void soinfo::CallConstructors() {
if (constructors_called) {
 return;
}

// We set constructors_called before actually calling the constructors, otherwise it doesn't
// protect against recursive constructor calls. One simple example of constructor recursion
// is the libc debug malloc, which is implemented in libc_malloc_debug_leak.so:
// 1. The program depends on libc, so libc's constructor is called here.
// 2. The libc constructor calls dlopen() to load libc_malloc_debug_leak.so.
// 3. dlopen() calls the constructors on the newly created
//    soinfo for libc_malloc_debug_leak.so.
// 4. The debug .so depends on libc, so CallConstructors is
//    called again with the libc soinfo. If it doesn't trigger the early-
//    out above, the libc constructor will be called again (recursively!).
constructors_called = true;

if ((flags & FLAG_EXE) == 0 && preinit_array != NULL) {
 // The GNU dynamic linker silently ignores these, but we warn the developer.
 PRINT("\"%s\": ignoring %d-entry DT_PREINIT_ARRAY in shared library!",
       name, preinit_array_count);
}

    // ensure the library has been initialized/loaded
if (dynamic != NULL) {
 for (Elf32_Dyn* d = dynamic; d->d_tag != DT_NULL; ++d) {
   if (d->d_tag == DT_NEEDED) {
     const char* library_name = strtab + d->d_un.d_val;
     TRACE("\"%s\": calling constructors in DT_NEEDED \"%s\"", name, library_name);
     find_loaded_library(library_name)->CallConstructors();
   }
 }
}

TRACE("\"%s\": calling constructors", name);

    // finally execute the init functions
// DT_INIT should be called before DT_INIT_ARRAY if both are present.
CallFunction("DT_INIT", init_func);
CallArray("DT_INIT_ARRAY", init_array, init_array_count, false);
}

5. init and init_array

What they are

These two functions run automatically when the .so is loaded or unloaded, and they handle initialization — with init running before init_array. Because they run very early in the .so’s lifetime, hooking them can bypass key detection points.

And because they load so early and only run once, hooking them directly usually fails. From the flow above, we know both run inside call_constructors, so we can hook the relevant native functions to load them — by hooking during the android_dlopen_ext load process.

The key breakthrough

The critical step is about call_constructors. call_constructors is called when a shared library loads — it’s a native linker function stored in Android itself, specifically in /system/bin/linker64. Pull it off the device, decompile it, and search for the function:

call_constructors in the linker

Decompiled call_constructors

The decompiled result matches the source function we saw above:

__int64 __fastcall _dl__ZN6soinfo17call_constructorsEv(__int64 result)
{
  char v1; // w8
  __int64 v2; // x19
  const char *v3; // x2
  _QWORD *i; // x20
  __int64 v5; // x1
  __int64 v6; // x0
  __int64 v7; // x8
  char *v8; // x0
  __int64 v9; // x2
  __int64 v10; // x3
  char v11[16]; // [xsp+8h] [xbp-38h] BYREF
  __int64 v12; // [xsp+18h] [xbp-28h]
  __int128 v13; // [xsp+20h] [xbp-20h] BYREF
  char *v14; // [xsp+30h] [xbp-10h]

  if ( !(_dl_g_is_ldd | *(result + 248)) )
  {
    v1 = *(result + 48);
    v2 = result;
    *(result + 248) = 1;
    if ( (v1 & 4) == 0 && *(result + 136) && (_dl_g_ld_debug_verbosity & 0x80000000) == 0 )
    {
      if ( (*(result + 432) & 1) != 0 )
        v3 = *(result + 448);
      else
        v3 = (result + 433);
      _dl__Z10linker_logiPKcz(0xFFFFFFFFLL, "\"%s\": ignoring DT_PREINIT_ARRAY in shared library!", v3);
    }
    for ( i = *(v2 + 288); i; i = *i )
      _dl__ZN6soinfo17call_constructorsEv(i[1]);
    if ( (*(v2 + 48) & 0x10) == 0 )
    {
      _dl__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC2IDnEEPKc(v11, "calling constructors: ");
      if ( (*(v2 + 432) & 1) != 0 )
        v5 = *(v2 + 448);
      else
        v5 = v2 + 433;
      v6 = _dl__ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE6appendEPKc(v11, v5);
      v7 = *(v6 + 16);
      v13 = *v6;
      v14 = v7;
      *(v6 + 8) = 0LL;
      *(v6 + 16) = 0LL;
      *v6 = 0LL;
      if ( (v13 & 1) != 0 )
        v8 = v14;
      else
        v8 = &v13 + 1;
      _dl__Z18bionic_trace_beginPKc(v8);
      if ( (v13 & 1) != 0 )
        _dl__ZdlPv(v14);
      if ( (v11[0] & 1) != 0 )
        _dl__ZdlPv(v12);
    }
    if ( (*(v2 + 432) & 1) != 0 )
      v9 = *(v2 + 448);
    else
      v9 = v2 + 433;
    _dl__ZL13call_functionPKcPFviPPcS2_ES0_("DT_INIT", *(v2 + 184), v9);
    if ( (*(v2 + 432) & 1) != 0 )
      v10 = *(v2 + 448);
    else
      v10 = v2 + 433;
    result = _dl__ZL10call_arrayIPFviPPcS1_EEvPKcPT_mbS5_("DT_INIT_ARRAY", *(v2 + 152), *(v2 + 160), v10);
    if ( (*(v2 + 48) & 0x10) == 0 )
      return _dl__Z16bionic_trace_endv(result);
  }
  return result;
}

Hooking init / init_array

Hook android_dlopen_ext to locate the .so we want to hook, then hook call_constructors in linker64 to alter the init / init_array flow.

One thing to note is finding the offset — you can use tools like PIE or IDA, or just run this in a Linux environment:

$ readelf -d lib52pojie.so | grep INIT
 0x000000000000000c (INIT)               0x12edc
 0x0000000000000019 (INIT_ARRAY)         0x39b50
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
var hooked = false;
function hook_call_constructor(soName){
    console.log("hook_call_constructor ->",soName)
    var symbols = Process.getModuleByName("linker64").enumerateSymbols();
    var callConstructorAdd = null;// leave this address empty for now
    for(var index = 0;index < symbols.length;index++){
        const symbol = symbols[index];
        if(symbol.name.indexOf("__dl__ZN6soinfo17call_constructorsEv") != -1){
            callConstructorAdd = symbol.address;
        }
    }

    console.log("call_constructors address:"+ callConstructorAdd);

    var isHooked = false;
    Interceptor.attach(callConstructorAdd,{
        onEnter: function(agrs){
            if(!isHooked){
                hook_init(soName);
                hook_init_array(soName);
                isHooked = true
            }
        },
        onLeave: function(retval){

        }
    });
}

function hook_init(soName){
    if(!hooked){
        console.log("hook_init -> ",soName);
        var targetSoAddr = Module.findBaseAddress(soName);
        var init_proc_addr = targetSoAddr.add(0x12edc);
        Interceptor.replace(init_proc_addr,new NativeCallback(function(){
            console.log("init_proc hooked");

        },'void',[]));

    }
}

function hook_init_array(soName){
    if(!hooked){
        console.log("hook_init_array ->",soName);
        var targetSoAddr = Module.findBaseAddress(soName);
        var init_array = targetSoAddr.add(0x12F38);
        Interceptor.replace(init_array , new NativeCallback(function(){
            console.log("init_array hooked");
        },'void',[]));
        //hooked = true ;
    }
}

function main(){
    hook_dlopen();
  }

setImmediate(main);

init hook result

6. JNI registration flow

Static vs dynamic registration

Static registration

Declared via the JNIEXPORT and JNICALL macros. When the VM loads the .so and finds a function declared with these two macros, it links it to the corresponding native method.

Dynamic registration

The native method and the .so function are bound manually through RegisterNatives, so the VM can find the right method directly through this function mapping table.

The flow:

JNI registration flow

The function RegisterNatives(JNIEnv *env, jclass clazz, const JNINativeMethod *methods, jint nmethods) takes: the Java environment, the Java class descriptor, the set of methods to register, and the number of methods to register.

The “set of methods to register” is a struct named JNINativeMethod:

typedef struct {
    const char* name;         // native method name
    const char* signature;    // method signature, e.g. ()Ljava/lang/String;
    void*       fnPtr;        // function pointer
} JNINativeMethod;

The second field — the signature — needs attention. It’s a string made of a pair of parentheses plus a few signature symbols: inside the parentheses go the parameter signatures (nothing if there are no parameters), outside goes the return signature.

Signature symbol C/C++ java
V void void
Z jboolean boolean
I jint int
J jlong long
D jdouble double
F jfloat float
B jbyte byte
C jchar char
S jshort short
[Z jbooleanArray boolean[]
[I jintArray int[]
[J jlongArray long[]
[D jdoubleArray double[]
[F jfloatArray float[]
[B jbyteArray byte[]
[C jcharArray char[]
[S jshortArray short[]
L+full package name+class name jobject class

Example: the Java function String getText(int a, byte[] b) maps to (I[B)Ljava/lang/String;.

#include <jni.h>
#include <string>

std::string stringFromJNI(
        JNIEnv* env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    //env->FindClass()

    return reinterpret_cast<const char *>(env->NewStringUTF(hello.c_str()));
}

std::string myFunc(JNIEnv *env, jobject thiz, jint i_param) {
    // TODO: implement myFunc()

    std::string retValue="input value is :===> ";
    std::string temp;

     temp=std::to_string(i_param);
    retValue+=temp;
    return reinterpret_cast<const char *>(env->NewStringUTF(retValue.c_str()));
}

JNINativeMethod methods ={
        {"stringFromJNI","()Ljava/lang/String;",(void*)stringFromJNI},
        {"myFunc","(I)Ljava/lang/String;",(void*)myFunc}//

};

jint JNI_OnLoad(JavaVM* vm,void* reserved){
        JNIEnv *env=NULL;
        if(vm->GetEnv((void**)&env,JNI_VERSION_1_6)!=JNI_OK){
            return JNI_ERR;
        }

        jclass clazz = env->FindClass("com/example/myapplicationndk/MainActivity");
        if(clazz==NULL){
            return  JNI_ERR;
        }
    jint iMethod=sizeof(methods)/ sizeof(methods[0]); // count the methods

    jint result=env->RegisterNatives(clazz,methods,iMethod);

    if(result<0){
        return JNI_ERR;
    }

    return JNI_VERSION_1_6;
}

You can see that during dynamic loading, the key method in the flow is RegisterNatives — its parameters carry a lot of sensitive info, like method names.

Inspecting dynamic registration with a hook

function find_RegisterNatives(params) {
    // enumerate all symbols (functions, variables, etc.) in libart.so
    let symbols = Module.enumerateSymbolsSync("libart.so");
    let addrRegisterNatives = null; // stores the address of the RegisterNatives method

    // iterate over all symbols to find the RegisterNatives method
    for (let i = 0; i < symbols.length; i++) {
        let symbol = symbols[i]; // the current symbol

        // check whether the symbol name matches the characteristics of RegisterNatives
        if (symbol.name.indexOf("art") >= 0 && // RegisterNatives is part of the ART (Android Runtime) environment
                symbol.name.indexOf("JNI") >= 0 &&  // RegisterNatives is part of JNI (Java Native Interface)
                symbol.name.indexOf("RegisterNatives") >= 0 && // check the symbol name contains "RegisterNatives"
                symbol.name.indexOf("CheckJNI") < 0) { // CheckJNI is a tool for debugging and validating JNI calls. If not filtered, there are two RegisterNatives; the CheckJNI one is usually disabled, so filter it out
            addrRegisterNatives = symbol.address; // save the method address
            console.log("RegisterNatives is at ", symbol.address, symbol.name); // print address and name
            hook_RegisterNatives(addrRegisterNatives); // call the hook function
        }
    }
}

function hook_RegisterNatives(addrRegisterNatives) {
    // make sure the provided address isn't null
    if (addrRegisterNatives != null) {
        // use Frida's Interceptor to hook the function at the given address
        Interceptor.attach(addrRegisterNatives, {
            // runs when the function is called
            onEnter: function (args) {
                // print the number of registered methods
                console.log("[RegisterNatives] method_count:", args[3]);

                // get the Java class and print its name
                let java_class = args[1];
                let class_name = Java.vm.tryGetEnv().getClassName(java_class);

                let methods_ptr = ptr(args[2]); // get the method array pointer
                let method_count = parseInt(args[3]); // get the method count

                // iterate over all methods
                                // a JNI method consists of three parts: method name pointer, method signature pointer, and method function pointer. Each pointer occupies Process.pointerSize bytes in memory (on 32-bit systems a pointer is 4 bytes, on 64-bit 8 bytes). For compatibility, use Process.pointerSize uniformly — the system adapts it to the architecture automatically.
                for (let i = 0; i < method_count; i++) {
                    // read the method name, signature, and function pointer
                    let name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));// read the method name pointer. This is the first part of each method struct, so read it from the starting address.
                    let sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));// read the method signature pointer. This is the second part of the struct, so offset by one pointer size.
                    let fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));// read the method function pointer. This is the third part of the struct, so offset by two pointer sizes (Process.pointerSize * 2).

                    // convert the pointer contents to strings
                    let name = Memory.readCString(name_ptr);
                    let sig = Memory.readCString(sig_ptr);

                    // get the debug symbol of the method
                    let symbol = DebugSymbol.fromAddress(fnPtr_ptr);

                    // print the info of each registered method
                    console.log("[RegisterNatives] java_class:", class_name, "name:", name, "sig:", sig, "fnPtr:", fnPtr_ptr,  " fnOffset:", symbol, " callee:", DebugSymbol.fromAddress(this.returnAddress));
                }
            }
        });
    }
}

setImmediate(find_RegisterNatives); // run find_RegisterNatives immediately

Inspecting static registration with a hook

function hook_dlsym() {
  var dlsymAddr = Module.findExportByName("libdl.so", "dlsym");
  Interceptor.attach(dlsymAddr, {
    onEnter: function(args) {
      this.args1 = args[1];
    },
    onLeave: function(retval) {
      var module = Process.findModuleByAddress(retval);
      if (module === null) return;
      console.log(this.args1.readCString(), module.name, retval, retval.sub(module.base));
    }
  });
}

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch