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

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

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:


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);

6. JNI registration flow
Static vs dynamic registration
Static registration
Declared via the
JNIEXPORTandJNICALLmacros. When the VM loads the.soand finds a function declared with these two macros, it links it to the corresponding native method.
Dynamic registration
The native method and the
.sofunction are bound manually throughRegisterNatives, so the VM can find the right method directly through this function mapping table.
The 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