~/posts/mobile/android-arm64-initializers-relocations-jni-timing.md

Android ARM64 initializers: relocations and JNI timing

Trace zero initializer slots through ELF relocations and loader metadata. A minimal NDK fixture separates file edits from constructor timing, ART class loading, JNI registration and instrumentation readiness.

date[31:24]
read[23:16]
9 min
cat[15:8]
Mobile

AI translation, not yet reviewed

Contents
  1. 0x00Three entry points, three phases
  2. 0x01Read zero slots together with relocations
  3. 0x02Test the assumptions with a small ELF
  4. 0x03Edit loader inputs, not display metadata
  5. 0x04The caller owns timing and JNI context
  6. 0x05Two observations and one readiness barrier
  7. 0x06Separate static checks from device results
  8. 0x07References

The useful observation window for an Android native library may close before Java calls its first native method. ELF constructors can already have changed global state, performed checks or started threads. Controlling that window requires separating image loading, ELF initialization and JNI registration, rather than attaching a hook after the relevant branch has run.

The example here is an ARM64 ELF with one constructor. Existing Android sample records are kept separate from a newly built local ELF fixture. The fixture checks layout, relocations and dynamic-table edits; its results are not Android device execution evidence.

Three entry points, three phases

The sample libnativestaticinit.so has these dependencies:

Sample entry points and responsibilities3 rows
Entry point Behavior What to observe
ELF constructor Calls time, srand, checkSUBinary and the logger Initialization timing and check output
JNI_OnLoad Finds a class and registers stringFromJNI JNI version returned; successful registration without exceptions
stringFromJNI Returns a Java string based on win() WIN! :) or No Win :(

The sample's checkSUBinary() checks /system/bin/su, /system/xbin/su, /sbin/su and /su/bin/su; win() tests rand() == 0x42. These deliberately observable functions are neither a complete device-state detector nor a cryptographic randomness design.

A plain dlopen() participates in ELF loading and constructor execution, but it is not the VM's System.loadLibrary(). JNI_OnLoad is an optional VM lifecycle callback. Renaming it to JNI_OnLoad0 provides an explicit entry point; a native dlopen() did not automatically perform JNI registration in the first place. See the JNI Invocation API for the callback contract.

Read zero slots together with relocations

Inspect sections, dynamic entries and relocations together, rather than interpreting the .init_array bytes alone:

ELF inspectionsh
llvm-readelf -SW libnativestaticinit.so
llvm-readelf -d libnativestaticinit.so
llvm-readelf -rW libnativestaticinit.so
llvm-objdump -s -j .init_array libnativestaticinit.so

One sample record places .init_array at image virtual address 0x1d28, with DT_INIT_ARRAYSZ equal to 8. The slot is zero in the file, but a relocation at that location has type R_AARCH64_RELATIVE and addend 0xa34. For that confirmed relocation type:

Recorded RELATIVE relocationtext
*(load_bias + 0x1d28) = load_bias + 0xa34

The left side identifies the runtime pointer slot; the right side is the function address written into it. 0x1d28 is not an unconverted file offset. Map a virtual address through the relevant PT_LOAD range before reading file bytes. The Arm ELF64 ABI specifies the relocation operation.

Zero bytes therefore do not establish the absence of a constructor. A disassembler's label expressed as a nearby symbol minus an offset is not proof of function ownership either. Follow the actual target's instructions and calls.

Test the assumptions with a small ELF

This local fixture increments a counter and exports a JNI_OnLoad0 stub with the JNI function prototype. The stub does not find classes, register native methods or create a VM:

init-fixture.cc
#include <jni.h>
static volatile unsigned calls;
__attribute__((constructor, visibility("default"), noinline))
void INIT0(void) { ++calls; }
__attribute__((visibility("default")))
unsigned read_constructor_calls(void) { return calls; }
JNIEXPORT jint JNI_OnLoad0(JavaVM *vm, void *reserved) {
    (void)vm; (void)reserved;
    return JNI_VERSION_1_6;
}

The build used NDK 29.0.14206865, Android clang 21.0.0 and target aarch64-linux-android31. Keep it separate from the existing caller record built with NDK 26.1.10909125; addresses and outputs belong to their respective artifacts. This is the equivalent shell command form; the actual compilation ran on a Windows host:

Build the layout fixturesh
clang --target=aarch64-linux-android31 -shared -fPIC -nostdlib \
  -Wl,-Bsymbolic-functions -Wl,--hash-style=gnu \
  -Wl,--build-id=none -Wl,--pack-dyn-relocs=none \
  -Wl,-soname,libinit_fixture.so init-fixture.c -o libinit_fixture.so
Measured results from the new fixture6 rows
Object Value
File size 3088 bytes
PT_DYNAMIC file offset / size 0x3e0 / 240 bytes
Initializer slot virtual address 0x83d8
Relative addend / INIT0 value 0x439c
JNI_OnLoad0 value 0x43bc
Initializer slot in the file Eight zero bytes

The actual INIT0 instructions are shown below, with instruction bytes in file order:

INIT0 · local fixture
0x439c49 00 00 90adrpx9, 0xc000
0x43a028 d1 44 b9ldrw8, [x9, #0x4d0]
0x43a408 05 00 11addw8, w8, #1
0x43a828 d1 04 b9strw8, [x9, #0x4d0]
0x43acc0 03 5f d6ret

Removing -Bsymbolic-functions produced a useful counterexample: the slot instead used R_AARCH64_ABS64, referring to dynamic symbol INIT0 with addend 0. An ARM64 initializer array does not imply RELATIVE relocations for every entry. Treating an addend as the function address is wrong for this ABS64 case and needs separate analysis for packed relocations or other layouts.

Edit loader inputs, not display metadata

In Android 14 bionic, soinfo::call_constructors() processes dependencies before this library's DT_INIT and DT_INIT_ARRAY. It ignores DT_PREINIT_ARRAY in a shared library. Changing section headers or clearing an on-disk slot therefore does not change every input used by the loader. See the fixed linker revision.

For the checked fixture with one initializer slot and no DT_INIT, remove the DT_INIT_ARRAY and DT_INIT_ARRAYSZ entries while retaining the slot and relocation. This is the tested dynamic-entry transformation, taking parsed, range-checked (tag, value) pairs rather than raw ELF bytes:

Dynamic-entry transformation · restricted fixturepython
def compact_dynamic(entries):
    end = next((i for i, (tag, value) in enumerate(entries) if tag == 0), None)
    if end is None:
        raise ValueError("Missing DT_NULL")
    live = entries[:end]
    if any(tag or value for tag, value in entries[end:]):
        raise ValueError("Nonzero data after DT_NULL")
    if sum(tag == 25 for tag, value in live) != 1 or \
       sum(tag == 27 for tag, value in live) != 1:
        raise ValueError("Expected one INIT_ARRAY pair")
    if any(tag == 12 for tag, value in live):
        raise ValueError("DT_INIT outside fixture scope")
    if next(value for tag, value in live if tag == 27) != 8:
        raise ValueError("Expected one 64-bit slot")
    kept = [entry for entry in live if entry[0] not in (25, 27)]
    return kept + [(0, 0)] * (len(entries) - len(kept))

Keep the remaining entries in order, compact them and zero-fill the unused capacity. Writing DT_NULL into the middle of a dynamic table can prematurely terminate traversal before useful entries. The file-writing checks additionally covered ELF64 little-endian encoding, AArch64, ET_DYN, program-header bounds, a unique dynamic segment, a single symbol-free RELATIVE relocation and an executable target range.

The patched file retained its size and every byte outside PT_DYNAMIC. A llvm-readelf readback showed that both initializer tags were gone while the relocation and all three exported symbols remained. The fixture exported INIT0 from source: this did not validate a general procedure for adding dynamic symbols to arbitrary existing binaries.

Adding a symbol to an existing library also involves .dynsym, strings, hash tables and potentially a changed layout. Pin tools such as LIEF and reparse their output. Older enum spellings such as ELF.SYMBOL_BINDINGS are not a promise about the current API. Likewise, the recorded addresses 0xa34, 0x954 and post-write 0x1954 come from different builds or stages, not a universal offset set.

The caller owns timing and JNI context

Manual execution needs the original initializer order and an explicit readiness barrier:

flowchart TD
  accTitle: Explicit initialization in a standalone caller
  accDescr: Map the edited target and establish the runtime, then wait for instrumentation before calling constructors, JNI registration and the target method.
  A["Map target and resolve entry points"] --> B["Create JavaVM and current-thread JNIEnv"]
  B --> C["Wait for instrumentation acknowledgement"]
  C --> D["Call INIT0 and other entries in original order"]
  D --> E["JNI_OnLoad0: check version and exceptions"]
  E --> F["Resolve class and method, then invoke"]

This is a control protocol for the target library, not a claim that the entire dependency graph is paused. Dependency constructors, DT_INIT, TLS and the runtime's own initialization have separate paths. Destructors can also depend on the original initialization state.

Embedding ART involves the platform runtime; it does not make arbitrary Android libraries desktop-compatible. AOSP's JniInvocation implementation loads a JNI provider and forwards the Invocation API. Library accessibility and internal interfaces still need to match the device build.

The option -Djava.class.path=/data/local/tmp/base.apk supplies a classpath, not an application environment. Manually invoking a function named JNI_OnLoad0 does not acquire the special ClassLoader context of System.loadLibrary(). A minimal caller should prefer a bridge class matching the registered name, without Activity lifecycle dependencies or a static initializer that reloads the original library.

Manual entry types · illustrative caller fragmentcpp
using InitFn = void (*)();
using OnLoadFn = jint (*)(JavaVM *, void *);

init0();
jint version = onload0(vm, nullptr);
if (version != JNI_VERSION_1_6 || env->ExceptionCheck()) {
    return -1;
}

This fragment assumes valid init0, onload0, vm and current-thread env values, and a sample contract requiring JNI 1.6. Check the actual initializer ABI, duplicate-call protection and failure cleanup separately. A JNIEnv belongs to its thread. Use GetStaticMethodID and CallStaticObjectMethod only for a static Java native method; instance methods need an appropriate object and API. Identical class names also do not imply identical class loaders.

Two observations and one readiness barrier

The sample's business result and constructor check provide separate observation points. Scope symbol lookup to the target module instead of accepting a same-named function from another library:

hook.js · sample-specific instrumentationjavascript
const targetModule = Process.getModuleByName("libnativestaticinit.patched.so");
function uniqueFunction(name) {
    const end = targetModule.base.add(targetModule.size);
    const matches = DebugSymbol.findFunctionsNamed(name).filter(address =>
        address.compare(targetModule.base) >= 0 && address.compare(end) < 0);
    if (matches.length !== 1) throw new Error("Ambiguous or missing symbol: " + name);
    return matches[0];
}
Interceptor.attach(uniqueFunction("_Z3winv"), {
    onLeave(value) { value.replace(1); }
});
Interceptor.attach(uniqueFunction("_Z13checkSUBinaryv"), {
    onLeave(value) { value.replace(0); }
});
console.log("HOOKS_READY");

This is instrumentation for a sample that retains suitable symbols; Frida was not run on the current host. HOOKS_READY is only a log message. The caller must receive an acknowledgement through an explicit control channel before releasing its wait and executing INIT0. Spawn mode with -f alone is not a cross-platform synchronization guarantee.

The existing sample record contains Native string: WIN! :) and no su binary, corresponding to the business result and initialization check. The success string alone could come from the original random branch; the other message could accurately describe the device without a hook. A useful comparison also records actual hits on both hooks, the unmodified input state and exactly one constructor call after the barrier opens.

Separate static checks from device results

Local work comprised two ELF builds, the dynamic-table edit, readback and boundary tests:

Offline ELF validationlog
PASS dynamic-table compaction: 1 valid and 7 rejected layouts
PASS ELF byte preservation outside PT_DYNAMIC
PASS ABS64 input rejected by RELATIVE-only validator
PASS load-bias arithmetic: 3 cases

The ABS64 build is an intentional out-of-scope input for the narrow validator. The seven rejected layouts cover a missing terminator, missing tags, duplicate tags, a non-single-slot size, additional DT_INIT and nonzero data after termination. VM creation, class loading, Frida injection and delayed execution on Android still require device validation.

Before applying the method to a real library4 steps
  1. 1

    Freeze the inputs

    Record original and output hashes, NDK and rewriting-tool versions. Keep separate files.

  2. 2

    Recover all entry points

    List dynamic tags, initializer slots and relocations. Preserve the order and dependencies of multiple constructors.

  3. 3

    Inspect the output

    Reparse program headers, dynamic symbols, hashes, relocations and dependencies. Check that targets remain in the intended executable mappings.

  4. 4

    Verify runtime behavior

    Use counters and a readiness barrier to establish timing, then check JNI registration, method calls and teardown. Rebuild the baseline from the original file.

The method makes early execution observable; it does not remove the code's dependencies. A caller's output becomes useful evidence only when the file edit, runtime context and recorded execution agree.

References

NORMAL~/posts/mobile/android-arm64-initializers-relocations-jni-timing.md§--
0%en