How Android Loads a Native SO: A Walk Through the Source
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.
How Android Loads a Native SO: A Walk Through the Source
Dynamic vs. static linking
In Android, .so files are shared libraries. They come in two flavors: dynamic libraries (dynamic .so) and static libraries (static .a). Android most commonly uses dynamic .so files; static libraries are usually integrated into the final application at compile time rather than loaded directly. So the .so linking you usually see is dynamic.
-
Dynamic linking picks the right SO from the packaged APK according to the target architecture (
lib/armeabi-v7a/,lib/arm64-v8a/,lib/x86/,lib/x86_64/), and it’s done from the Java layer via JNI.Java code uses the static
System.loadLibrary("libsofile")to load a shared library:static { System.loadLibrary("libsofile"); // load libsofile.so }Or by dynamically loading an SO from a path:
String soPath = "/data/data/com.example.libsofile/libsofile.so"; System.load(soPath); -
In Android, a static library (
.a) is linked into the final executable, not loaded at runtime. When compiling with the NDK, the static library is packed into the APK’s code section.
To explore the real SO loading process, we start from System.load(sopath) and dig into the AOSP source.
Digging through the AOSP source
System.load(sopath) is where it all begins. Let’s trace the whole SO loading process.
System.load(sopath)
@CallerSensitive
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
Reflection.getCallerClass() uses reflection to get a reference to the calling class — it returns the caller of load0. From here it goes straight into load0.
load0(Class<?> fromClass, String filename)

//libcore/ojluni/src/main/java/java/lang/Runtime.java
synchronized void load0(Class<?> fromClass, String filename) {
File file = new File(filename);
if (!(file.isAbsolute())) {
throw new UnsatisfiedLinkError(
"Expecting an absolute path of the library: " + filename);
}
if (filename == null) {
throw new NullPointerException("filename == null");
}
if (Flags.readOnlyDynamicCodeLoad()) {
if (!file.toPath().getFileSystem().isReadOnly() && file.canWrite()) {
if (VMRuntime.getSdkVersion() >= VersionCodes.VANILLA_ICE_CREAM) {
System.logW("Attempt to load writable file: " + filename
+ ". This will throw on a future Android version");
}
}
}
String error = nativeLoad(filename, fromClass.getClassLoader(), fromClass);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
}
This validates the SO being loaded, then moves on to the nativeLoad function.
nativeLoad(filename, fromClass.getClassLoader(), fromClass)

This is a native function, so we switch to the C side. The JNI name for it is Runtime_nativeLoad.
Runtime_nativeLoad
JNIEXPORT jstring JNICALL
Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename,
jobject javaLoader, jclass caller)
{
return JVM_NativeLoad(env, javaFilename, javaLoader, caller);
}
A straight pass-through to JVM_NativeLoad.
JVM_NativeLoad(env, javaFilename, javaLoader, caller)
JNIEXPORT jstring JVM_NativeLoad(JNIEnv* env,
jstring javaFilename,
jobject javaLoader,
jclass caller) {
ScopedUtfChars filename(env, javaFilename);
if (filename.c_str() == nullptr) {
return nullptr;
}
std::string error_msg;
{
art::JavaVMExt* vm = art::Runtime::Current()->GetJavaVM();
bool success = vm->LoadNativeLibrary(env,
filename.c_str(),
javaLoader,
caller,
&error_msg);
if (success) {
return nullptr;
}
}
// Don't let a pending exception from JNI_OnLoad cause a CheckJNI issue with NewStringUTF.
env->ExceptionClear();
return env->NewStringUTF(error_msg.c_str());
}
Same story — just follow it down to vm->LoadNativeLibrary.

vm->LoadNativeLibrary(env, filename.c_str(), javaLoader, caller, &error_msg)
Most of the functions along this path are intermediate — they just relay down to the key function. JavaVMExt::LoadNativeLibrary is the one worth paying attention to: it’s where the real dlopen happens.

ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
if (class_linker->IsBootClassLoader(loader)) {
loader = nullptr;
class_loader = nullptr;
}
if (caller_class != nullptr) {
ObjPtr<mirror::Class> caller = soa.Decode<mirror::Class>(caller_class);
ObjPtr<mirror::DexCache> dex_cache = caller->GetDexCache();
if (dex_cache != nullptr) {
caller_location = dex_cache->GetLocation()->ToModifiedUtf8();
}
}
First, the linker decodes the ClassLoader and Caller Class info, and checks whether the loader is the BootClassLoader. The SO loading process also leans on the linker to validate the SO’s structure — the linked location is the SO’s header, checked to confirm the file structure is correct.

It also checks whether this SO was already loaded, then finally starts loading the shared library (dlopen):

Locks::mutator_lock_->AssertNotHeld(self);
const char* path_str = path.empty() ? nullptr : path.c_str();
bool needs_native_bridge = false;
char* nativeloader_error_msg = nullptr;
void* handle = android::OpenNativeLibrary(
env,
runtime_->GetTargetSdkVersion(),
path_str,
class_loader,
(caller_location.empty() ? nullptr : caller_location.c_str()),
library_path.get(),
&needs_native_bridge,
&nativeloader_error_msg);
VLOG(jni) << "[Call to dlopen(\"" << path << "\", RTLD_NOW) returned " << handle << "]";
if (handle == nullptr) {
*error_msg = nativeloader_error_msg;
android::NativeLoaderFreeErrorMessage(nativeloader_error_msg);
VLOG(jni) << "dlopen(\"" << path << "\", RTLD_NOW) failed: " << *error_msg;
return false;
OpenNativeLibrary

Here we find the familiar android_dlopen_ext(path, RTLD_NOW, &dlextinfo) — the function everyone hooks.
//art/libnativeloader/native_loader.cpp
void* OpenNativeLibrary(JNIEnv* env,
int32_t target_sdk_version,
const char* path,
jobject class_loader,
const char* caller_location,
jstring library_path_j,
bool* needs_native_bridge,
char** error_msg) {
#if defined(ART_TARGET_ANDROID)
if (class_loader == nullptr) {
// class_loader is null only for the boot class loader (see
// IsBootClassLoader call in JavaVMExt::LoadNativeLibrary), i.e. the caller
// is in the boot classpath.
*needs_native_bridge = false;
if (caller_location != nullptr) {
std::optional<NativeLoaderNamespace> ns = FindApexNamespace(caller_location);
if (ns.has_value()) {
const android_dlextinfo dlextinfo = {
.flags = ANDROID_DLEXT_USE_NAMESPACE,
.library_namespace = ns.value().ToRawAndroidNamespace(),
};
void* handle = android_dlopen_ext(path, RTLD_NOW, &dlextinfo);
char* dlerror_msg = handle == nullptr ? strdup(dlerror()) : nullptr;
ALOGD("Load %s using APEX ns %s for caller %s: %s",
path,
ns.value().name().c_str(),
caller_location,
dlerror_msg == nullptr ? "ok" : dlerror_msg);
if (dlerror_msg != nullptr) {
*error_msg = dlerror_msg;
}
return handle;
}
}
On Android 12, android_dlopen_ext returns directly to __loader_android_dlopen_ext; on other versions it may reach mock->mock_dlopen_ext (which simulates dlopen and routes to different functions via flags and macros).
We’ll pin ourselves to Android 12 here.
__loader_android_dlopen_ext
void* __loader_android_dlopen_ext(const char* filename,
int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
return dlopen_ext(filename, flags, extinfo, caller_addr);
}
Straight return into the next function.
dlopen_ext
//bionic/linker/dlfcn.cpp
static void* dlopen_ext(const char* filename,
int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
ScopedPthreadMutexLocker locker(&g_dl_mutex);
g_linker_logger.ResetState();
void* result = do_dlopen(filename, flags, extinfo, caller_addr);
if (result == nullptr) {
__bionic_format_dlerror("dlopen failed", linker_get_error_buffer());
return nullptr;
}
return result;
}
Onward to do_dlopen(filename, flags, extinfo, caller_addr).
do_dlopen(filename, flags, extinfo, caller_addr)
This function piles on a lot of parameter validation:

Extensive checks of extinfo and the SO file’s attributes:

It also translates and resolves the path:

ProtectedDataGuard guard;
soinfo* si = find_library(ns, translated_name, flags, extinfo, caller);
loading_trace.End();
This is the most important part of do_dlopen — it initializes soinfo, and from here the SO’s .init_proc function is called, then the .init_array functions, and finally JNI_OnLoad. Many people use this point to judge whether a detection hook lands before or after JNI_OnLoad.
Onward to find_library(ns, translated_name, flags, extinfo, caller).
find_library(ns, translated_name, flags, extinfo, caller)

static soinfo* find_library(android_namespace_t* ns,
const char* name, int rtld_flags,
const android_dlextinfo* extinfo,
soinfo* needed_by) {
soinfo* si = nullptr;
if (name == nullptr) {
si = solist_get_somain();
} else if (!find_libraries(ns,
needed_by,
&name,
1,
&si,
nullptr,
0,
rtld_flags,
extinfo,
false /* add_as_children */)) {
if (si != nullptr) {
soinfo_unload(si);
}
return nullptr;
}
si->increment_ref_count();
return si;
}
Straight into find_libraries.
find_libraries
This is the last stage of the SO loading flow:

bool find_libraries(android_namespace_t* ns,
soinfo* start_with,
const char* const library_names[],
size_t library_names_count,
soinfo* soinfos[],
std::vector<soinfo*>* ld_preloads,
size_t ld_preloads_count,
int rtld_flags,
const android_dlextinfo* extinfo,
bool add_as_children,
std::vector<android_namespace_t*>* namespaces) {
// Step 0: prepare.
std::unordered_map<const soinfo*, ElfReader> readers_map;
LoadTaskList load_tasks;
for (size_t i = 0; i < library_names_count; ++i) {
const char* name = library_names[i];
load_tasks.push_back(LoadTask::create(name, start_with, ns, &readers_map));
}
// If soinfos array is null allocate one on stack.
// The array is needed in case of failure; for example
// when library_names[] = {libone.so, libtwo.so} and libone.so
// is loaded correctly but libtwo.so failed for some reason.
// In this case libone.so should be unloaded on return.
// See also implementation of failure_guard below.
if (soinfos == nullptr) {
size_t soinfos_size = sizeof(soinfo*)*library_names_count;
soinfos = reinterpret_cast<soinfo**>(alloca(soinfos_size));
memset(soinfos, 0, soinfos_size);
}
// list of libraries to link - see step 2.
size_t soinfos_count = 0;
auto scope_guard = android::base::make_scope_guard([&]() {
for (LoadTask* t : load_tasks) {
LoadTask::deleter(t);
}
});
ZipArchiveCache zip_archive_cache;
soinfo_list_t new_global_group_members;
// Step 1: expand the list of load_tasks to include
// all DT_NEEDED libraries (do not load them just yet)
for (size_t i = 0; i<load_tasks.size(); ++i) {
LoadTask* task = load_tasks[i];
soinfo* needed_by = task->get_needed_by();
bool is_dt_needed = needed_by != nullptr && (needed_by != start_with || add_as_children);
task->set_extinfo(is_dt_needed ? nullptr : extinfo);
task->set_dt_needed(is_dt_needed);
// Note: start from the namespace that is stored in the LoadTask. This namespace
// is different from the current namespace when the LoadTask is for a transitive
// dependency and the lib that created the LoadTask is not found in the
// current namespace but in one of the linked namespaces.
android_namespace_t* start_ns = const_cast<android_namespace_t*>(task->get_start_from());
LD_LOG(kLogDlopen, "find_library_internal(ns=%s@%p): task=%s, is_dt_needed=%d",
start_ns->get_name(), start_ns, task->get_name(), is_dt_needed);
if (!find_library_internal(start_ns, task, &zip_archive_cache, &load_tasks, rtld_flags)) {
return false;
}
soinfo* si = task->get_soinfo();
if (is_dt_needed) {
needed_by->add_child(si);
}
// When ld_preloads is not null, the first
// ld_preloads_count libs are in fact ld_preloads.
bool is_ld_preload = false;
if (ld_preloads != nullptr && soinfos_count < ld_preloads_count) {
ld_preloads->push_back(si);
is_ld_preload = true;
}
if (soinfos_count < library_names_count) {
soinfos[soinfos_count++] = si;
}
// Add the new global group members to all initial namespaces. Do this secondary namespace setup
// at the same time that libraries are added to their primary namespace so that the order of
// global group members is the same in the every namespace. Only add a library to a namespace
// once, even if it appears multiple times in the dependency graph.
if (is_ld_preload || (si->get_dt_flags_1() & DF_1_GLOBAL) != 0) {
if (!si->is_linked() && namespaces != nullptr && !new_global_group_members.contains(si)) {
new_global_group_members.push_back(si);
for (auto linked_ns : *namespaces) {
if (si->get_primary_namespace() != linked_ns) {
linked_ns->add_soinfo(si);
si->add_secondary_namespace(linked_ns);
}
}
}
}
}
// Step 2: Load libraries in random order (see b/24047022)
LoadTaskList load_list;
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
auto pred = [&](const LoadTask* t) {
return t->get_soinfo() == si;
};
if (!si->is_linked() &&
std::find_if(load_list.begin(), load_list.end(), pred) == load_list.end() ) {
load_list.push_back(task);
}
}
bool reserved_address_recursive = false;
if (extinfo) {
reserved_address_recursive = extinfo->flags & ANDROID_DLEXT_RESERVED_ADDRESS_RECURSIVE;
}
if (!reserved_address_recursive) {
// Shuffle the load order in the normal case, but not if we are loading all
// the libraries to a reserved address range.
shuffle(&load_list);
}
// Set up address space parameters.
address_space_params extinfo_params, default_params;
size_t relro_fd_offset = 0;
if (extinfo) {
if (extinfo->flags & ANDROID_DLEXT_RESERVED_ADDRESS) {
extinfo_params.start_addr = extinfo->reserved_addr;
extinfo_params.reserved_size = extinfo->reserved_size;
extinfo_params.must_use_address = true;
} else if (extinfo->flags & ANDROID_DLEXT_RESERVED_ADDRESS_HINT) {
extinfo_params.start_addr = extinfo->reserved_addr;
extinfo_params.reserved_size = extinfo->reserved_size;
}
}
for (auto&& task : load_list) {
address_space_params* address_space =
(reserved_address_recursive || !task->is_dt_needed()) ? &extinfo_params : &default_params;
if (!task->load(address_space)) {
return false;
}
}
// The WebView loader uses RELRO sharing in order to promote page sharing of the large RELRO
// segment, as it's full of C++ vtables. Because MTE globals, by default, applies random tags to
// each global variable, the RELRO segment is polluted and unique for each process. In order to
// allow sharing, but still provide some protection, we use deterministic global tagging schemes
// for DSOs that are loaded through android_dlopen_ext, such as those loaded by WebView.
bool dlext_use_relro =
extinfo && extinfo->flags & (ANDROID_DLEXT_WRITE_RELRO | ANDROID_DLEXT_USE_RELRO);
// Step 3: pre-link all DT_NEEDED libraries in breadth first order.
bool any_memtag_stack = false;
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
if (!si->is_linked() && !si->prelink_image(dlext_use_relro)) {
return false;
}
// si->memtag_stack() needs to be called after si->prelink_image() which populates
// the dynamic section.
if (si->memtag_stack()) {
any_memtag_stack = true;
LD_LOG(kLogDlopen,
"... load_library requesting stack MTE for: realpath=\"%s\", soname=\"%s\"",
si->get_realpath(), si->get_soname());
}
register_soinfo_tls(si);
}
if (any_memtag_stack) {
if (auto* cb = __libc_shared_globals()->memtag_stack_dlopen_callback) {
cb();
} else {
// find_library is used by the initial linking step, so we communicate that we
// want memtag_stack enabled to __libc_init_mte.
__libc_shared_globals()->initial_memtag_stack_abi = true;
}
}
// Step 4: Construct the global group. DF_1_GLOBAL bit is force set for LD_PRELOADed libs because
// they must be added to the global group. Note: The DF_1_GLOBAL bit for a library is normally set
// in step 3.
if (ld_preloads != nullptr) {
for (auto&& si : *ld_preloads) {
si->set_dt_flags_1(si->get_dt_flags_1() | DF_1_GLOBAL);
}
}
// Step 5: Collect roots of local_groups.
// Whenever needed_by->si link crosses a namespace boundary it forms its own local_group.
// Here we collect new roots to link them separately later on. Note that we need to avoid
// collecting duplicates. Also the order is important. They need to be linked in the same
// BFS order we link individual libraries.
std::vector<soinfo*> local_group_roots;
if (start_with != nullptr && add_as_children) {
local_group_roots.push_back(start_with);
} else {
CHECK(soinfos_count == 1);
local_group_roots.push_back(soinfos[0]);
}
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
soinfo* needed_by = task->get_needed_by();
bool is_dt_needed = needed_by != nullptr && (needed_by != start_with || add_as_children);
android_namespace_t* needed_by_ns =
is_dt_needed ? needed_by->get_primary_namespace() : ns;
if (!si->is_linked() && si->get_primary_namespace() != needed_by_ns) {
auto it = std::find(local_group_roots.begin(), local_group_roots.end(), si);
LD_LOG(kLogDlopen,
"Crossing namespace boundary (si=%s@%p, si_ns=%s@%p, needed_by=%s@%p, ns=%s@%p, needed_by_ns=%s@%p) adding to local_group_roots: %s",
si->get_realpath(),
si,
si->get_primary_namespace()->get_name(),
si->get_primary_namespace(),
needed_by == nullptr ? "(nullptr)" : needed_by->get_realpath(),
needed_by,
ns->get_name(),
ns,
needed_by_ns->get_name(),
needed_by_ns,
it == local_group_roots.end() ? "yes" : "no");
if (it == local_group_roots.end()) {
local_group_roots.push_back(si);
}
}
}
// Step 6: Link all local groups
for (auto root : local_group_roots) {
soinfo_list_t local_group;
android_namespace_t* local_group_ns = root->get_primary_namespace();
walk_dependencies_tree(root,
[&] (soinfo* si) {
if (local_group_ns->is_accessible(si)) {
local_group.push_back(si);
return kWalkContinue;
} else {
return kWalkSkip;
}
});
soinfo_list_t global_group = local_group_ns->get_global_group();
SymbolLookupList lookup_list(global_group, local_group);
soinfo* local_group_root = local_group.front();
bool linked = local_group.visit([&](soinfo* si) {
// Even though local group may contain accessible soinfos from other namespaces
// we should avoid linking them (because if they are not linked -> they
// are in the local_group_roots and will be linked later).
if (!si->is_linked() && si->get_primary_namespace() == local_group_ns) {
const android_dlextinfo* link_extinfo = nullptr;
if (si == soinfos[0] || reserved_address_recursive) {
// Only forward extinfo for the first library unless the recursive
// flag is set.
link_extinfo = extinfo;
}
if (__libc_shared_globals()->load_hook) {
__libc_shared_globals()->load_hook(si->load_bias, si->phdr, si->phnum);
}
lookup_list.set_dt_symbolic_lib(si->has_DT_SYMBOLIC ? si : nullptr);
if (!si->link_image(lookup_list, local_group_root, link_extinfo, &relro_fd_offset) ||
!get_cfi_shadow()->AfterLoad(si, solist_get_head())) {
return false;
}
}
return true;
});
if (!linked) {
return false;
}
}
// Step 7: Mark all load_tasks as linked and increment refcounts
// for references between load_groups (at this point it does not matter if
// referenced load_groups were loaded by previous dlopen or as part of this
// one on step 6)
if (start_with != nullptr && add_as_children) {
start_with->set_linked();
}
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
si->set_linked();
}
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
soinfo* needed_by = task->get_needed_by();
if (needed_by != nullptr &&
needed_by != start_with &&
needed_by->get_local_group_root() != si->get_local_group_root()) {
si->increment_ref_count();
}
}
return true;
}
That’s a long chunk of code. The AOSP source annotates it step by step — loading and parsing the SO, one stage at a time.
Step 0: prepare load tasks
For example, in Step 0:
for (size_t i = 0; i < library_names_count; ++i) {
const char* name = library_names[i];
load_tasks.push_back(LoadTask::create(name, start_with, ns, &readers_map));
}
// If soinfos array is null allocate one on stack.
// The array is needed in case of failure; for example
// when library_names[] = {libone.so, libtwo.so} and libone.so
// is loaded correctly but libtwo.so failed for some reason.
// In this case libone.so should be unloaded on return.
// See also implementation of failure_guard below.
if (soinfos == nullptr) {
size_t soinfos_size = sizeof(soinfo*)*library_names_count;
soinfos = reinterpret_cast<soinfo**>(alloca(soinfos_size));
memset(soinfos, 0, soinfos_size);
}
The code stores the SO to load into an array, and conditionally allocates space for soinfo* struct pointers if soinfos is empty.
Step 1: resolve dependencies
for (size_t i = 0; i<load_tasks.size(); ++i) {
LoadTask* task = load_tasks[i];
soinfo* needed_by = task->get_needed_by();
bool is_dt_needed = needed_by != nullptr && (needed_by != start_with || add_as_children);
task->set_extinfo(is_dt_needed ? nullptr : extinfo);
task->set_dt_needed(is_dt_needed);
// Note: start from the namespace that is stored in the LoadTask. This namespace
// is different from the current namespace when the LoadTask is for a transitive
// dependency and the lib that created the LoadTask is not found in the
// current namespace but in one of the linked namespaces.
android_namespace_t* start_ns = const_cast<android_namespace_t*>(task->get_start_from());
LD_LOG(kLogDlopen, "find_library_internal(ns=%s@%p): task=%s, is_dt_needed=%d",
start_ns->get_name(), start_ns, task->get_name(), is_dt_needed);
if (!find_library_internal(start_ns, task, &zip_archive_cache, &load_tasks, rtld_flags)) {
return false;
}
soinfo* si = task->get_soinfo();
if (is_dt_needed) {
needed_by->add_child(si);
}
task->get_needed_by() reuses the SO array from the previous step to detect dependency relationships. That’s because the import and export tables you see in IDA for an SO are all realized through SO-to-SO dependencies. Anyone who’s done NDK development also knows that cross-SO calls go through dlopen.
Calls between SOs:
extern "C" JNIEXPORT jstring JNICALL
Java_com_chen_javaandso_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */,
jstring path) {
std::string hello = "Hello from C++";
const char* cpath = env->GetStringUTFChars(path, nullptr); // converts the Java string to a C char*
if (cpath == nullptr) {
// handle the case where GetStringUTFChars returns nullptr
return nullptr;
}
void* soinfo = dlopen(cpath, RTLD_NOW); // get the handle of the SO at the given path
if (soinfo == nullptr) {
// handle the dlopen failure case
__android_log_print(ANDROID_LOG_ERROR, "JNI", "Failed to load library: %s", dlerror());
env->ReleaseStringUTFChars(path, cpath);
return nullptr;
}
// since we look up the function by path, create a function pointer to get the handle, then call it
void (*def)(char*) = reinterpret_cast<void (*)(char*)>(dlsym(soinfo, "_Z7seconedv")); // look up the function via the handle; since there's no extern "C", the name gets mangled
if (def == nullptr) {
// handle the case where dlsym can't find the symbol
__android_log_print(ANDROID_LOG_ERROR, "JNI", "Failed to find symbol: %s", dlerror());
dlclose(soinfo);
env->ReleaseStringUTFChars(path, cpath);
return nullptr;
}
def(nullptr);
dlclose(soinfo); // close the dynamic library handle
env->ReleaseStringUTFChars(path, cpath); // release the JNI string
return env->NewStringUTF(hello.c_str());
}
Step 2: random order
In Step 2, the Android dynamic linker loads SO files in a random order (to avoid the vulnerabilities of a fixed order). If extinfo requires ANDROID_DLEXT_RESERVED_ADDRESS_RECURSIVE, no randomization happens. It sets up address-space parameters like reserved_addr and reserved_size.
Step 3: pre-link
bool any_memtag_stack = false;
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
if (!si->is_linked() && !si->prelink_image(dlext_use_relro)) {
return false;
}
// si->memtag_stack() needs to be called after si->prelink_image() which populates
// the dynamic section.
if (si->memtag_stack()) {
any_memtag_stack = true;
LD_LOG(kLogDlopen,
"... load_library requesting stack MTE for: realpath=\"%s\", soname=\"%s\"",
si->get_realpath(), si->get_soname());
}
register_soinfo_tls(si);
}
This is where pre-linking happens — and the spot mentioned earlier where the linker parses the ELF file structure: si->prelink_image(dlext_use_relro).
1. Validate the ELF header to confirm it's a valid ELF file.
2. Read the dynamic section and extract the symbol table, string table, etc.
3. Initialize .got.plt, .rel.dyn and other relocation tables for later symbol resolution.
4. Parse DT_NEEDED (dependent libraries), preparing for the subsequent link_image() symbol resolution.
This is the detailed handling of the ELF file structure — only a complete, intact SO can be loaded and linked.
register_soinfo_tls(si);
It also moves soinfo into TLS.
Step 4: global symbol resolution
Here it processes the symbol tables parsed in Step 3. Libraries with the DF_1_GLOBAL flag are added to the global symbol resolution list. LD_PRELOAD-loaded libraries get DF_1_GLOBAL forced so they can influence every loaded library.
Step 5 — Step 7
Steps 5 through 7 are the fine-grained details — special handling for SOs that cross anonymous namespaces, and recursive processing of dependency libraries, so functions can be used across dependent SOs. In short: iterate over load_tasks, processing the attributes and info of each prepared SO.
At this point the entire SO has been parsed and processed.
Summary
Let’s recap the whole SO loading process. Whether it’s Frida detection or other protections, it all lives inside the SO — which matters a lot for both app protection and breaking it.
We enter via System.load():
@CallerSensitive
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
This eventually calls Runtime.load0(), then the nativeLoad() function:
Runtime_nativeLoad → vm->LoadNativeLibrary
Inside JavaVMExt::LoadNativeLibrary, it finally calls dlopen to do the real SO load:
vm->LoadNativeLibrary(env, filename.c_str(), javaLoader, caller, &error_msg);
On Android 12 and above, it calls android_dlopen_ext, returning __loader_android_dlopen_ext:
void* __loader_android_dlopen_ext(const char* filename,
int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
return dlopen_ext(filename, flags, extinfo, caller_addr);
}
Which finally calls dlopen_ext():
static void* dlopen_ext(const char* filename,
int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
ScopedPthreadMutexLocker locker(&g_dl_mutex);
void* result = do_dlopen(filename, flags, extinfo, caller_addr);
return result;
}
do_dlopen(filename, flags, extinfo, caller_addr) — inside it, find_library() does the real load:
soinfo* si = find_library(ns, translated_name, flags, extinfo, caller);
This is where soinfo gets assigned, and from here the SO’s .init_proc function is called, then the .init_array functions, and finally JNI_OnLoad. It ends at find_libraries for the final processing.
Here’s the execution-flow diagram (including the flow under an emulator):

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