Dynamic Linker: Intercepting Android Native Libraries at Runtime

Jul 25, 2026 By Nizam Abdullah (@morre)
Table of contents

When reverse engineering or instrumenting Android applications, a common hurdle is executing hooks before a native library runs its initial setup logic (such as .init_array or JNI_OnLoad). By the time standard Frida attach points fire, key initialization routines have often already finished.

To gain full control prior to code execution, we need to inspect how the Android dynamic linker (linker and linker64) handles .so files under the hood. Inspired by research on hooking native libraries prior to DT_INIT, this post traces the execution chain from Java’s System.loadLibrary down to the low-level linker structures where memory space is allocated.

Entry Point: Calling Load Library Method

In typical Android development, native binary files are brought into memory using standard framework calls in Kotlin or Java:

// kotlin
class NativeLibWrapper {
    external fun method(param: Int): Int

    companion object {
        init {
            System.loadLibrary("native-lib")
        }
    }
}

// java
public class NativeLibWrapper {
    static {
        System.loadLibrary("native-lib");
    }

    public native int method(int param);
}

To uncover what happens after System.loadLibrary() is triggered, we can trace the source code via the official Android Code Search.

Starting in java.lang.System:

// libcore/ojluni/src/main/java/java/lang/System.java
@CallerSensitive
public static void loadLibrary(String libname) {
    Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}

System.loadLibrary acts as a thin wrapper around Runtime.getRuntime().loadLibrary0, automatically forwarding information about the calling class.

Traversing Runtime to Native Execution

Inside java.lang.Runtime, overloaded methods resolve the target library path before handing execution off to C/C++ layer bindings:

// libcore/ojluni/src/main/java/java/lang/Runtime.java
private synchronized void loadLibrary0(ClassLoader loader, Class<?> callerClass, String libname) {
    String libraryName = libname;

    if (loader != null && !(loader instanceof BootClassLoader)) {
        String filename = loader.findLibrary(libraryName);
        ...
        String error = nativeLoad(filename, loader, callerClass);
        if (error != null) {
            throw new UnsatisfiedLinkError(error);
        }
        return;
    }
    ...
    String filename = System.mapLibraryName(libraryName);
    String error = nativeLoad(filename, loader, callerClass);
    if (error != null) {
        throw new UnsatisfiedLinkError(error);
    }
}

At this stage, nativeLoad is declared without a body using the native keyword:

private static native String nativeLoad(String filename, ClassLoader loader, Class<?> caller);

To bridge this Java method with underlying C++ implementations, Android uses explicit JNI registration during runtime startup:

// libcore/ojluni/src/main/native/Runtime.c
static JNINativeMethod gMethods[] = {
  ...
  NATIVE_METHOD(Runtime, nativeLoad,
                "(Ljava/lang/String;Ljava/lang/ClassLoader;Ljava/lang/Class;)"
                    "Ljava/lang/String;"),
};

JNIEXPORT jstring JNICALL
Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename,
                   jobject javaLoader, jclass caller)
{
    return JVM_NativeLoad(env, javaFilename, javaLoader, caller);
}

Runtime_nativeLoad delegates directly to JVM_NativeLoad, moving execution out of libcore and straight into the Android Runtime (ART) engine.

Crossing into the ART Engine

Inside OpenjdkJvm.cc, ART delegates loading to the global JavaVMExt instance:

// art/openjdkjvm/OpenjdkJvm.cc
JNIEXPORT jstring JVM_NativeLoad(JNIEnv* env,
                                 jstring javaFilename,
                                 jobject javaLoader,
                                 jclass caller) {
    ScopedUtfChars filename(env, javaFilename);
    ...
    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;
        }
    }
    ...
}

Before diving deeper into C++ source files, we can verify our path dynamically. Checking Android.bp reveals that this logic compiles into libopenjdkjvm.so:

art_cc_library {
    name: "libopenjdkjvm",
    ...
}

We can attach a simple Frida script to intercept JVM_NativeLoad:

const JVM_NativeLoad = Module.getExportByName("libopenjdkjvm.so", "JVM_NativeLoad");

Interceptor.attach(JVM_NativeLoad, {
    onEnter: function (args) {
      var env = Java.vm.getEnv();
      var ptr = env.getStringUtfChars(args[1], null);
      var filename = ptr.readCString();

      console.log(`JVM_NativeLoad called:
          env=${args[0]}
          filename=${filename}
          loader=${args[2]}
          caller=${args[3]}
      `);
    },
  });

The runtime log confirms our static analysis is accurate:

log-1.png

Now we follow JavaVMExt::LoadNativeLibrary in art/runtime/jni/java_vm_ext.cc, which calls android::OpenNativeLibrary from libnativeloader:

// art/libnativeloader/native_loader.cpp
void* OpenNativeLibrary(...) {
    ...
    const android_dlextinfo dlextinfo = {
        .flags = ANDROID_DLEXT_USE_NAMESPACE,
        .library_namespace = ns.value().ToRawAndroidNamespace(),
    };
    void* handle = android_dlopen_ext(path, RTLD_NOW, &dlextinfo);
    ...
    return handle;
}

Take a note at that dlextinfo->flags initialized value, it will used later as part of memory reservation logic.

android_dlopen_ext serves as the entry gate into Bionic’s dynamic linker implementation:

android-dlopen-ext-trace.png

Inside bionic/linker/linker.cpp, do_dlopen() invokes find_library(), which eventually hands off execution to find_libraries():

// bionic/linker/linker.cpp
bool find_libraries(...) {
    ...
    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;
        }
    }
    ...
}

Because standard app calls do not pass custom address reservation flags (confirming on our previous dlextinfo->flags initialization), address_space points to an uninitialized default_params struct.

Also we can observe this using Frida by targeting LoadTask::load which will return all nulled default value of address_space_params.

const LoadTask_load = findModuleFromLinker(["LoadTask", "load", "address_space_params"]);

Interceptor.attach(LoadTask_load, {
  onEnter: function (args) {
    const address_space = args[1];
    if (address_space.isNull()) return;

    const start_addr = address_space.readPointer();
    const reserved_size = address_space.add(Process.pointerSize).readU64();
    const must_use_address = address_space.add(Process.pointerSize * 2).readU8() !== 0;

    console.log(`LoadTask::load address_space parameters:
      start_addr      = ${start_addr}
      reserved_size   = ${reserved_size}
      must_use_addr   = ${must_use_address}
    `);
  },
});

log-2.png

Inside LoadTask::load, parsing and allocation are offloaded to ElfReader:

bool load(address_space_params* address_space) {
    ElfReader& elf_reader = get_elf_reader();
    if (!elf_reader.Load(address_space)) {
      return false;
    }

    si_->base = elf_reader.load_start();
    si_->size = elf_reader.load_size();
    si_->load_bias = elf_reader.load_bias();
    ...
    return true;
}

Memory Reservation

When ElfReader::Load() runs, it calls ReserveAddressSpace() to compute required memory boundaries based on program header entries (PT_LOAD segments):

// bionic/linker/linker_phdr.cpp
bool ElfReader::ReserveAddressSpace(address_space_params* address_space) {
  ElfW(Addr) min_vaddr;
  load_size_ = phdr_table_get_load_size(phdr_table_, phdr_num_, &min_vaddr);
  ...
  start = ReserveWithAlignmentPadding(load_size_, kLibraryAlignment, start_alignment, &gap_start_, &gap_size_);

  load_start_ = start;
  load_bias_ = reinterpret_cast<uint8_t*>(start) - reinterpret_cast<uint8_t*>(min_vaddr);
  return true;
}

phdr_table_get_load_size iterates through all PT_LOAD entries to calculate the distance between the lowest virtual address (min_vaddr) and highest virtual address (max_vaddr), aligning everything to page size.

size_t phdr_table_get_load_size(const ElfPhdr* phdr_table, size_t phdr_count,
                                ElfAddr* out_min_vaddr) {
  ElfAddr min_vaddr = UINTPTR_MAX;
  ElfAddr max_vaddr = 0;

  bool found_pt_load = false;
  for (size_t i = 0; i < phdr_count; ++i) {
    const ElfPhdr* phdr = &phdr_table[i];

    if (phdr->p_type != PT_LOAD) {
      continue;
    }
    found_pt_load = true;

    if (phdr->p_vaddr < min_vaddr) {
      min_vaddr = phdr->p_vaddr;
    }

    if (phdr->p_vaddr + phdr->p_memsz > max_vaddr) {
      max_vaddr = phdr->p_vaddr + phdr->p_memsz;
    }
  }
  if (!found_pt_load) {
    min_vaddr = 0;
  }

  min_vaddr = page_align_down(min_vaddr);
  max_vaddr = page_align_up(max_vaddr);

  if (out_min_vaddr != nullptr) {
    *out_min_vaddr = min_vaddr;
  }
  return max_vaddr - min_vaddr;
}

Once mmap assigns memory via ReserveWithAlignmentPadding, load_start_ receives the base virtual address.

For modern Position Independent Executables (PIE) and shared objects (.so), min_vaddr is almost always 0x0.

Elf file type is DYN (Shared object file)
Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz   MemSiz    Flg   Align
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x1b786c  0x1b786c  R E   0x4000
  LOAD           0x1b9f30 0x00000000001bdf30 0x00000000001bdf30 0x0f798   0x1b160   RW    0x4000
  ...

Consequently, the load_bias_ calculation result:

$$ \text{load_bias_} = \text{start_} - 0 = \text{start_} $$

This makes load_bias_ equivalent to the base address where the library is mapped in process memory.

Capturing Base Addresses Early

After segments are mapped into memory, find_libraries() runs initialization hooks on every loaded library:

for (auto&& task : load_tasks) {
    soinfo* si = task->get_soinfo();
    if (!si->is_linked() && !si->prelink_image(dlext_use_relro)) {
        return false;
    }
}

In soinfo::prelink_image, the linker parses ELF dynamic tables using phdr_table_get_dynamic_section, passing load_bias directly:

bool soinfo::prelink_image(bool dlext_use_relro) {
  ...
  phdr_table_get_dynamic_section(phdr, phnum, load_bias, &dynamic, &dynamic_flags);
  ...
}

Because phdr_table_get_dynamic_section executes right after memory mapping and prior to constructors or JNI_OnLoad running, it serves as an ideal intercept target for instrumentation frameworks.

log-3.png

Combining a do_dlopen hook with a nested hook on phdr_table_get_dynamic_section gives us a clean window to log and manipulate native module memory before any target code executes:

log-4.png

Implementation: Early Native Hook Script

Below is a complete Frida script designed to extract target library base addresses the instant memory mapping finishes:

function log(msg) {
  console.log(`[LinkerHook] ${msg}`);
}

const targets = ["do_dlopen", "phdr_table_get_dynamic_section"];
const addresses = {};

// Scan linker symbols
Process.enumerateModules().forEach((module) => {
  if (module.name.includes("linker")) {
    module.enumerateSymbols().forEach((symbol) => {
      targets.forEach((target) => {
        if (symbol.name.includes(target)) {
          addresses[target] = symbol.address;
        }
      });
    });
  }
});

function hookEarlyLibraryLoad() {
  if (!addresses["do_dlopen"] || !addresses["phdr_table_get_dynamic_section"]) {
    log("Failed to resolve required linker symbols.");
    return;
  }

  Interceptor.attach(addresses["do_dlopen"], {
    onEnter: function (args) {
      if (args[0].isNull()) return;

      const path = args[0].readCString();
      const libraryName = path.split("/").pop();

      // Intercept dynamic section parsing to capture base address
      this.nested = Interceptor.attach(addresses["phdr_table_get_dynamic_section"], {
        onEnter: function (args) {
          const loadBias = args[2]; // load_bias parameter
          log(`Library Mapped -> Name: ${libraryName} | Base Address: ${loadBias}`);
        },
      });
    },

    onLeave: function () {
      if (this.nested) {
        this.nested.detach();
        this.nested = null;
      }
    },
  });
}

setImmediate(hookEarlyLibraryLoad);

References

Below are recipes to write this article.