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

Android ARM64 初始化器:重定位与调用时序

从全零初始化槽追到 ELF 重定位和动态表,用最小 NDK 夹具验证修改边界,并区分手动构造函数调用、ART 类加载、JNI 注册与插桩就绪时序。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
移动端

AI 翻译,尚未人工审核

目录
  1. 0x00三个入口属于不同阶段
  2. 0x01零值槽位要结合重定位读取
  3. 0x02用小型 ELF 检查假设
  4. 0x03修改动态表而不是展示信息
  5. 0x04调用器负责时序和 JNI 上下文
  6. 0x05两个观察点加一道就绪屏障
  7. 0x06把静态验证与设备结果分开
  8. 0x07参考资料

Android 原生库的分析窗口,常常在 Java 第一次调用 native 方法之前就关闭了:ELF 构造函数已经运行,检查分支、全局状态和线程都可能完成初始化。要控制这个时间点,必须分清映像装载、ELF 初始化与 JNI 注册,而不是只在目标函数上补一个晚到的 hook。

下面讨论 ARM64 ELF 的单构造函数场景。已有 Android 样本记录与本地新构建的最小 ELF 分开标注:后者用于核对文件布局、重定位和动态表修改,没有作为 Android 设备执行结果。

三个入口属于不同阶段

示例库 libnativestaticinit.so 的关键依赖如下:

样本入口与职责3 行
入口 行为 应观察的结果
ELF 构造函数 调用 time、srand、checkSUBinary 与日志函数 初始化发生的时间与检查日志
JNI_OnLoad 查找类并注册 stringFromJNI 返回 JNI 版本;注册成功且无异常
stringFromJNI 根据 win() 结果返回 Java 字符串 WIN! :) 或 No Win :(

样本的 checkSUBinary() 检查 /system/bin/su、/system/xbin/su、/sbin/su、/su/bin/su;win() 使用 rand() == 0x42。它们是便于观察的示例逻辑,不代表完整设备状态检测,也不是密码学随机性设计。

普通 dlopen() 会涉及 ELF 装载与构造函数,但不等价于虚拟机的 System.loadLibrary()。JNI_OnLoad 是 VM 装载协议中的可选回调;改名为 JNI_OnLoad0 只是建立显式调用入口,单独的原生 dlopen() 本来就不会自动承担 JNI 注册。回调签名与返回值见 JNI Invocation API。

零值槽位要结合重定位读取

先联合检查节表、动态表和重定位,而不是只看 .init_array 的十六进制内容:

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

一份样本记录中,.init_array 位于映像虚拟地址 0x1d28,DT_INIT_ARRAYSZ 为 8;文件中的槽位全零,但同一位置有 R_AARCH64_RELATIVE,addend 为 0xa34。对这条已确认类型的重定位:

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

左边是指针槽的运行时地址,右边是写入槽位的函数地址。0x1d28 不是未经换算的文件偏移;虚拟地址到文件位置应经 PT_LOAD 的范围映射。相应重定位语义可对照 Arm ELF64 ABI。

因此,零字节没有证明构造函数缺席。分析器显示的“邻近符号减去某个偏移”,也不构成函数归属证据;应跟进真实目标的指令和调用关系。

用小型 ELF 检查假设

下面的本地夹具只增加计数,并导出一个保留 JNI 函数原型的 JNI_OnLoad0 桩。这个桩不查类、不注册 native 方法,也没有创建 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;
}

构建环境是 NDK 29.0.14206865、Android clang 21.0.0,目标 aarch64-linux-android31。与已有调用器记录使用的 NDK 26.1.10909125 分开,不合并地址或输出。以下是等价的 shell 命令形式;实际构建在 Windows 主机完成:

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
新夹具的实际文件结果6 行
对象 值
文件大小 3088 字节
PT_DYNAMIC 文件偏移 / 大小 0x3e0 / 240 字节
初始化槽虚拟地址 0x83d8
相对重定位 addend / INIT0 值 0x439c
JNI_OnLoad0 值 0x43bc
初始化槽文件内容 8 个零字节

INIT0 的指令来自实际构建,指令字节按文件顺序展示:

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

一个有用的反例来自去掉 -Bsymbolic-functions 的构建:同一个槽改为 R_AARCH64_ABS64,引用动态符号 INIT0,addend 为 0。可见,“ARM64 的初始化数组”并不天然等于“每项都是 RELATIVE”;对 ABS64、打包重定位或其他布局,直接把 addend 当作函数地址会读错。

修改动态表而不是展示信息

Android 14 bionic 的 soinfo::call_constructors() 先处理依赖库,再调用本库的 DT_INIT 和 DT_INIT_ARRAY。共享库的 DT_PREINIT_ARRAY 则被忽略。这个顺序说明,只修改节头或清空文件槽位,并没有改掉装载器真正使用的全部输入。见 固定版本 linker 实现。

对已确认只有一个初始化槽、没有 DT_INIT 的夹具,可以移除 DT_INIT_ARRAY 与 DT_INIT_ARRAYSZ,保留槽位及重定位。下面是实际测试中的动态项转换函数;输入是已解析并验证范围的 (tag, value) 序列,不是完整 ELF 解析器:

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

保留动态项顺序、压紧有效项,再将剩余容量填零。不要直接在动态表中间把某个标签写成 DT_NULL,否则后续仍有用的条目可能被提前截断。写入端还检查 ELF64 小端、AArch64、ET_DYN、程序头边界、唯一动态段、单个无符号索引的 RELATIVE 重定位及可执行目标范围。

修改结果保持总文件大小不变,PT_DYNAMIC 以外字节完全一致;llvm-readelf 回读确认两项初始化标签消失,原重定位和三个导出符号仍在。夹具从源码就导出了 INIT0,这没有验证给任意现成二进制新增动态符号的完整流程。

对于现成库,新增符号还涉及 .dynsym、字符串与哈希表以及可能发生的布局调整。使用 LIEF 等工具时固定工具版本,写出后重新解析;较早脚本的 ELF.SYMBOL_BINDINGS 等枚举形式不应直接当作当前 API。原记录中的 0xa34、0x954 与写出后 0x1954 也属于不同构建或阶段,不是一组通用偏移。

调用器负责时序和 JNI 上下文

手动恢复调用,需要保留原初始化顺序,并建立明确的就绪屏障:

flowchart TD
  accTitle: 独立调用器的显式初始化顺序
  accDescr: 映射修改后的目标库并建立运行时后,等待插桩确认,再按顺序调用构造入口、JNI 注册与目标方法。
  A["映射目标库并解析入口"] --> B["建立 JavaVM 与目标线程 JNIEnv"]
  B --> C["等待插桩就绪确认"]
  C --> D["按原顺序调用 INIT0 等入口"]
  D --> E["JNI_OnLoad0:检查版本和异常"]
  E --> F["查类、查方法并调用"]

图描述目标库的控制协议,不声称整个依赖图都被暂停。其他库构造函数、DT_INIT、TLS 和运行时自身的初始化另有路径;退出时的析构函数也可能依赖原始初始化状态。

ART 嵌入涉及平台运行时,不是把任意 Android 库搬到桌面执行。AOSP 的 JniInvocation 实现负责装入 JNI provider 并转发 Invocation API,但设备上的库可访问性和内部接口仍需与构建匹配。

-Djava.class.path=/data/local/tmp/base.apk 只是类路径输入。手动调用一个名为 JNI_OnLoad0 的函数,不会自动获得 System.loadLibrary() 路径中的特殊 ClassLoader 上下文。对最小调用器,优先提供与注册名称完全一致、不依赖 Activity 生命周期、且不会在静态初始化中再次加载原库的桥接类。

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

片段假定 init0、onload0、vm 和当前线程的 env 已正确取得,且样本约定 JNI 1.6。真实初始化器的 ABI、重复调用保护与失败清理需要单独处理。JNIEnv 属于线程;Java 方法若是静态 native,才配合 GetStaticMethodID 与 CallStaticObjectMethod。实例方法需要有效对象和对应接口,类名相同也不代表类加载器相同。

两个观察点加一道就绪屏障

示例的业务返回与构造函数检查是两个独立观察点。脚本可限定模块范围,避免按名称取得其他库的同名函数:

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

这是针对保留符号的示例库的插桩代码,没有在当前主机运行 Frida。HOOKS_READY 本身只是日志;调用器必须通过明确的控制通道收到确认后,才释放等待并执行 INIT0。不要将 -f 启动模式本身当作所有平台上的同步保证。

已有样本记录包含 Native string: WIN! :) 与 no su binary,分别对应业务输出和初始化检查。单独出现成功字符串仍可能来自原随机分支;no su binary 也可能是设备真实状态。有效对照还应记录两个 hook 的实际命中、未调整时的输入状态,以及构造函数是否恰好在放行后调用一次。

把静态验证与设备结果分开

本地完成的是两个 ELF 构建、动态表修改、回读与边界测试,输出如下:

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

其中 ABS64 是有意提供的超出模型范围输入;七个拒绝布局覆盖缺失结束项、缺标签、重复标签、非单槽、额外 DT_INIT 和结束项后非零数据。Android 上的 VM 创建、类加载、Frida 注入与实际延后执行仍待设备验证。

迁移到真实库前的核对顺序4 步
  1. 1

    固定输入

    记录原始与输出文件摘要、NDK 和改写工具版本;保留两个独立文件。

  2. 2

    恢复全部入口

    列出动态标签、各初始化槽及重定位,保留多个构造函数的次序和依赖关系。

  3. 3

    检查写出文件

    重读程序头、动态符号、哈希、重定位和依赖;确认地址仍落在预期可执行映射。

  4. 4

    验证运行时

    用计数与就绪屏障检查执行时机,再验证 JNI 注册、方法调用及退出清理;最后从原始文件重建基线。

这条路线的价值是让很早执行的代码进入可观察的时序,而不是抹掉它的依赖。只有把文件层修改、运行时上下文和实际命中记录连在一起,调用器输出才适合作为后续分析证据。

参考资料

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