前言
Android 的 Native 层以 ELF(Executable and Linkable Format)作为其可执行文件和共享库的标准格式。.so 文件(Shared Object)是 Android NDK 编译的核心产物,也是逆向分析 Native Hook、SO 加固、符号恢复等技术的基本分析单元。
本文以 Android 15 的 AOSP bionic/linker 源码为参照,从二进制层面解析 Android .so 文件的 ELF 结构,覆盖 ELF Header、Program Headers、Section Headers、PLT/GOT、动态链接流程,并提供完整 Python 解析器。
参考:
bionic/linker/linker.cpp,bionic/libc/kernel/uapi/linux/elf.h
一、Android 中 ELF 的特殊性
1.1 ET_DYN 统一化
传统 Linux 系统中,可执行文件是 ET_EXEC 类型,共享库是 ET_DYN 类型。自 Android 5.0 (API 21) 起,所有可执行文件和共享库全部使用 ET_DYN,配合 PIE (Position-Independent Executable) 实现 ASLR 地址随机化:
$ readelf -h /system/bin/app_process64 | grep Type |
两者 ELF type 相同,区别仅在于有无 INTERP 段(可执行文件有,指定动态链接器路径)。
1.2 支持架构
| e_machine | 值 | 架构 | ABI 目录 |
|---|---|---|---|
| EM_ARM | 40 | ARMv7 (32-bit) | armeabi-v7a |
| EM_AARCH64 | 183 | ARM64 (64-bit) | arm64-v8a |
| EM_386 | 3 | x86 (32-bit) | x86 |
| EM_X86_64 | 62 | x86-64 (64-bit) | x86_64 |
| EM_RISCV | 243 | RISC-V 64 | riscv64 |
本文以 ARM64 (EM_AARCH64=183) 的 64 位 ELF 为主要示例。
二、ELF 文件两种视图
ELF 文件有两个组织视图,服务于不同阶段:
Linking View Execution View |
- Linking View(链接视图): 编译/链接阶段使用 Section Headers 组织代码和数据
- Execution View(执行视图): loader (linker) 在运行时按 Program Headers 中的 Segment 描述将文件映射到内存
三、ELF Header (Ehdr) 逐字段解析
3.1 完整结构定义
// bionic/libc/kernel/uapi/linux/elf.h |
3.2 真实 hex dump 分析
以下是一个真实 libnative.so(ARM64)的 ELF header hex dump:
Offset Hex Decode |
3.3 e_ident 详解
e_ident[16] 的前 16 字节是 ELF 的”指纹”:
| 字节 | 字段 | 常见值 | 含义 |
|---|---|---|---|
| 0-3 | EI_MAG0..3 | 7F 45 4C 46 | \x7FELF |
| 4 | EI_CLASS | 01=32bit, 02=64bit | 地址宽度 |
| 5 | EI_DATA | 01=小端, 02=大端 | 字节序 |
| 6 | EI_VERSION | 01 | ELF 版本 |
| 7 | EI_OSABI | 00=SystemV, 03=Linux | 目标 OS ABI |
| 8-15 | EI_PAD | 全0 | 填充 |
四、Program Headers — 执行视图
Program Header Table 描述 linker 加载 SO 时如何将文件内容映射到内存。每个 Program Header 56 字节(64-bit ELF)。
4.1 Elf64_Phdr 结构
typedef struct elf64_phdr { |
p_filesz vs p_memsz: 如果 p_memsz > p_filesz,多出的部分由 linker 填充为零(如 .bss 段)。
4.2 常见 Segment 类型
| p_type | 值 | 说明 |
|---|---|---|
| PT_NULL | 0 | 未使用 |
| PT_LOAD | 1 | 可加载段(核心)— linker 将此段映射到内存 |
| PT_DYNAMIC | 2 | .dynamic 段 — 动态链接信息 |
| PT_INTERP | 3 | 解释器路径(.interp)— 指向 linker 路径 |
| PT_NOTE | 4 | 辅助信息(如 .note.gnu.build-id) |
| PT_PHDR | 6 | Program Header Table 自身的位置和大小 |
| PT_GNU_RELRO | 0x6474E552 | RELRO 段 — 只读重定位区域 |
| PT_GNU_STACK | 0x6474E551 | 栈可执行性 — p_flags 中的 PF_X 控制 NX bit |
| PT_GNU_EH_FRAME | 0x6474E550 | 异常处理框架(.eh_frame_hdr) |
4.3 Segment 权限标志 (p_flags)
| 标志 | 值 | 含义 |
|---|---|---|
| PF_X | 1 | 可执行 |
| PF_W | 2 | 可写 |
| PF_R | 4 | 可读 |
常见的组合:
R_X(5):.text段 — 可读+可执行R__(4):.rodata段 — 只读RW_(6):.data段 — 可读+可写RW_(6):.bss段 — 可读+可写,文件大小=0,内存大小>0
4.4 真实的 Program Headers 实例
注意: 以下示例来自某个可执行文件(
ET_DYNPIE),因为它含有INTERP段指定/system/bin/linker64。普通.so共享库 不包含INTERP段——只有可执行文件才需要通过INTERP指定动态链接器的路径。
$ readelf -l /system/bin/app_process64 |
这里有两个 LOAD segment:
- 第一个 LOAD (R_E): 映射代码段 (
.text,.rodata,.plt等只读/可执行节) - 第二个 LOAD (RW_): 映射数据段 (
.data,.bss,.got,.dynamic等可读写节)
linker 使用 mmap 将这两个 LOAD segment 映射到进程地址空间,页对齐由 p_align(通常 0x1000 = 4KB)保证。
五、Section Headers — 链接视图
Section 是连接视图的粒度单位,包含编译器和链接器需要的信息。对于已 strip 的 release SO,section headers 可能被删除,但 program headers 始终存在(linker 只读 program headers)。
5.1 Elf64_Shdr 结构
typedef struct elf64_shdr { |
5.2 核心 Section 清单
| Section | sh_type | 作用 |
|---|---|---|
.text |
SHT_PROGBITS | 可执行代码 |
.rodata |
SHT_PROGBITS | 只读数据(字符串常量、虚表) |
.data |
SHT_PROGBITS | 已初始化的全局变量 |
.bss |
SHT_NOBITS | 未初始化的全局变量(文件占 0 字节) |
.plt |
SHT_PROGBITS | Procedure Linkage Table — 延迟绑定桩代码 |
.got |
SHT_PROGBITS | Global Offset Table — 全局数据地址表 |
.got.plt |
SHT_PROGBITS | GOT 中用于 PLT 的条目 |
.dynsym |
SHT_DYNSYM | 动态符号表 — 导入/导出符号 |
.dynstr |
SHT_STRTAB | 动态符号的字符串表 |
.hash / .gnu.hash |
SHT_HASH | 符号哈希表 — 加速符号查找 |
.rela.dyn |
SHT_RELA | 非 PLT 重定位表(如全局变量地址) |
.rela.plt |
SHT_RELA | PLT 重定位表(函数调用延迟绑定) |
.dynamic |
SHT_DYNAMIC | 动态链接信息数组 |
.interp |
SHT_PROGBITS | 动态链接器路径 |
.init_array |
SHT_INIT_ARRAY | 初始化函数指针数组(构造函数) |
.fini_array |
SHT_FINI_ARRAY | 终止化函数指针数组(析构函数) |
.note.gnu.build-id |
SHT_NOTE | 构建唯一 ID |
.shstrtab |
SHT_STRTAB | Section 名称字符串表 |
.symtab |
SHT_SYMTAB | 完整符号表(release SO 通常 strip 掉此表) |
.strtab |
SHT_STRTAB | 完整字符串表(同上,通常 strip 掉) |
5.3 .dynsym 符号结构
typedef struct elf64_sym { |
st_info 编码规则:
- 低 4 位: 符号类型 (STT_NOTYPE=0, STT_OBJECT=1, STT_FUNC=2…)
- 高 4 位: 绑定属性 (STB_LOCAL=0, STB_GLOBAL=1, STB_WEAK=2…)
st_other 可见性:
| 可见性 | 值 | 含义 |
|---|---|---|
| STV_DEFAULT | 0 | 默认可见 |
| STV_HIDDEN | 2 | 隐藏,外部不可引用 |
| STV_PROTECTED | 3 | 保护(始终在自身 SO 内解析) |
六、PLT/GOT 延迟绑定机制
这是理解 Native Hook(如 Xposed、Frida)底层原理的关键。
6.1 为什么需要 PLT/GOT?
动态链接时,.so 并不知道其所调用的外部函数(如 libc.so 中的 open、malloc)在内存中的实际地址。GOT 存储这些地址,PLT 提供间接跳转。
6.2 首次调用流程
调用者代码: |
6.3 FULL RELRO vs Partial RELRO
Android 6.0+ 平台二进制默认开启 FULL RELRO,Android 8.0+ 应用也开始普遍采用 FULL RELRO(-Wl,-z,relro -Wl,-z,now):
| 模式 | 绑定时机 | GOT 可写性 | 安全性 |
|---|---|---|---|
| Partial RELRO | 延迟绑定(首次调用时) | GOT 可写 | 可被 GOT Hook |
| FULL RELRO | 加载时立即全部解析 | 加载后 GOT 只读 | GOT Hook 不可行 |
FULL RELRO 下,所有 PLT 重定位在 linker 加载 SO 时就全部解析完成,.got.plt 被 mprotect 为只读。这阻止了基于 GOT 覆写的 Hook,但 Frida/Xposed 仍可通过 inline hook(直接修改目标函数的前几条指令)绕过。
可以通过以下命令检查:
$ readelf -d libnative.so | grep BIND_NOW |
七、.dynamic Section — 动态链接信息
.dynamic 是 tag-value 对数组,linker 据此获取 SO 的所有动态链接信息:
typedef struct elf64_dyn { |
核心 DT_ 标签
| d_tag | 值 | d_un 含义 |
|---|---|---|
| DT_NEEDED | 1 | 依赖的 SO 名称(d_val 是 .dynstr 偏移) |
| DT_SONAME | 14 | 本 SO 的名称 |
| DT_INIT | 12 | 初始化函数地址 |
| DT_FINI | 13 | 终止化函数地址 |
| DT_INIT_ARRAY | 25 | .init_array 地址 |
| DT_FINI_ARRAY | 26 | .fini_array 地址 |
| DT_HASH | 4 | SYSV hash 表地址 |
| DT_GNU_HASH | 0x6FFFFEF5 | GNU hash 表地址 |
| DT_STRTAB | 5 | .dynstr 地址 |
| DT_SYMTAB | 6 | .dynsym 地址 |
| DT_STRSZ | 10 | .dynstr 大小 |
| DT_SYMENT | 11 | sizeof(Elf64_Sym) |
| DT_RELA | 7 | .rela.dyn 地址 |
| DT_RELASZ | 8 | .rela.dyn 大小 |
| DT_RELAENT | 9 | sizeof(Elf64_Rela) |
| DT_JMPREL | 23 | .rela.plt 地址 |
| DT_PLTRELSZ | 2 | .rela.plt 大小 |
| DT_PLTGOT | 3 | .got.plt 地址 |
| DT_BIND_NOW | 24 | 立即绑定标记 (FULL RELRO) |
| DT_FLAGS_1 | 0x6FFFFFFB | 标志位 (DF_1_NOW=1) |
| DT_RELR | 0x6FFFE000 | Android 特有的压缩重定位表 |
| DT_ANDROID_RELRSZ | 0x6FFFE003 | RELR 表大小 |
八、ARM64 重定位类型详解
重定位(relocation)是动态链接的核心步骤:当 SO 被加载到内存时,其代码和数据中的地址引用是相对于编译时基址的,linker 必须根据运行时加载地址修正这些引用。
8.1 重定位表项结构
ARM64 使用 Elf64_Rela 结构(带显式加数 addend),每条 24 字节:
typedef struct elf64_rela { |
与 32-bit x86 使用的 Elf32_Rel(隐式加数,存储在目标地址处)不同,ARM64 的 RELA 格式在 r_addend 字段中显式存储加数,无需从目标地址读取。
8.2 ARM64 核心重定位类型
ARM64 (AArch64) 的重定位类型定义在 bionic/libc/kernel/uapi/linux/elf.h:
// 需在 AOSP bionic/libc/kernel/uapi/asm-arm64/asm/elf.h 中验证 |
| 重定位类型 | 值 | 应用场景 | 公式 | 说明 |
|---|---|---|---|---|
| R_AARCH64_NONE | 0 | 空重定位 | 无 | 占位,linker 跳过 |
| R_AARCH64_ABS64 | 257 | .rela.dyn 中的数据指针 |
S + A |
直接将符号绝对地址写入目标位置。用于全局变量指针、C++ vtable 等需要 64 位绝对地址的场景 |
| R_AARCH64_COPY | 1024 | .rela.dyn 中的复制重定位 |
(内存拷贝) | 将符号定义从依赖 SO 的只读段复制到当前 SO 的可写段(.bss)。触发条件:引用 SO 有只读数据段,被引用符号在主程序中定义 |
| R_AARCH64_GLOB_DAT | 1025 | .rela.dyn 中的 GOT 条目 |
S + A |
为全局符号在 GOT 中设置地址。与 JUMP_SLOT 不同,GLOB_DAT 用于数据引用或被取地址的函数,在加载时立即解析,即使 Partial RELRO 也不例外 |
| R_AARCH64_JUMP_SLOT | 1026 | .rela.plt 中的 PLT GOT 条目 |
S + A |
为 PLT 调用的函数设置 GOT 地址。Partial RELRO 下延迟解析(首次调用时由 _dl_runtime_resolve 填充),FULL RELRO 下在加载时立即解析 |
| R_AARCH64_RELATIVE | 1027 | .rela.dyn 中的相对重定位 |
Base + A |
修正指向 SO 内部地址的指针。Base 是 SO 的加载基址(load_bias),A 是编译时的偏移量加数。不需要符号查找,性能最高 |
| R_AARCH64_TLS_TPREL | 1030 | TLS (Thread-Local Storage) | S + A - TLS_TP |
修正 TLS 变量偏移,linker 配合 __tls_get_addr 处理 |
| R_AARCH64_IRELATIVE | 1032 | IFUNC (间接函数) | Base + resolver() |
调用 resolver 函数,将其返回值写入目标位置。用于多版本函数选择(如根据 CPU 特性选择 memcpy 实现) |
8.3 R_AARCH64_RELATIVE 详解
这是 Android ELF 中数量最多的重定位类型,也是 RELR 压缩重定位的作用目标。
公式: *(Base + r_offset) = Base + r_addend |
实例:SO 编译时设定 .data 段中某全局指针指向 .rodata 中的字符串,编译产生的 offset = 0x2000,加载时 Base = 0x7000000000:
r_offset = 0x3000 (.data 段中的全局变量位置) |
该字符串的运行时地址被正确写入全局变量中。
8.4 R_AARCH64_JUMP_SLOT 与 R_AARCH64_GLOB_DAT 的区别
两种重定位都操作 GOT 条目,但行为有重要差异:
| 维度 | JUMP_SLOT (1026) | GLOB_DAT (1025) |
|---|---|---|
| 所在表 | .rela.plt |
.rela.dyn |
| 触发时机 | Partial RELRO: 延迟(首次调用) | 始终立即解析 |
| 用途 | 函数调用 (BL func@plt) |
数据引用 / 取函数地址 (&func) |
| lookup 策略 | 广度优先查找 .dynsym | 同左 |
| FULL RELRO 行为 | 加载时解析,之后 GOT 只读 | 同左 |
关键:C 代码中
func()产生 JUMP_SLOT,&func取函数地址产生 GLOB_DAT。这也是为什么即使 FULL RELRO 下.rela.dyn和.rela.plt都被处理,但两者的物理存储位置和语义仍然不同。
8.5 linker 如何处理各类重定位(简化流程)
// 需在 AOSP bionic/linker/linker_relocs.h 中验证具体逻辑 |
linker 遍历 .rela.dyn 和 .rela.plt 数组,对每条 Elf64_Rela:
for each relocation entry in (.rela.dyn + .rela.plt): |
九、符号版本化详解
符号版本化(Symbol Versioning)是 GNU ELF 扩展,允许同一共享库提供同一符号的多个版本。Android 的 Bionic linker 完整支持此机制(在 bionic/linker/linker_gnu_hash.cpp 中处理),这是解决 “dlopen failed: cannot locate symbol” 错误的常见原因。
9.1 涉及的 Section
符号版本化涉及三个 section(均为 GNU 扩展):
| Section | sh_type | 作用 |
|---|---|---|
.gnu.version |
SHT_GNU_versym | 为 .dynsym 中的每个符号分配一个版本索引。每个条目 2 字节(Elf64_Half),第 i 个条目对应 dynsym[i] 的版本 |
.gnu.version_r |
SHT_GNU_verneed | 版本需求表:记录本 SO 需要的来自依赖 SO 的版本定义。结构为链表,每个依赖 SO 对应一个 Elf64_Verneed 条目 |
.gnu.version_d |
SHT_GNU_verdef | 版本定义表:记录本 SO 自己导出的版本化符号。结构为链表,每个版本对应一个 Elf64_Verdef 条目。通常出现在 libc.so、libm.so 等系统库中 |
9.2 特殊版本索引常量
在 .gnu.version 中,每个条目的值含义如下:
| 常量 | 值 | 含义 |
|---|---|---|
| VER_NDX_LOCAL | 0 | 符号仅本地可见,不参与符号查找。linker 在 lookup 时会跳过这些符号 |
| VER_NDX_GLOBAL | 1 | 符号为全局版本,无特定版本约束。所有 SO 均可引用 |
| 2+ | 具体版本索引 | 该索引指向 .gnu.version_r 或 .gnu.version_d 中的版本条目 |
9.3 .gnu.version_r (Verneed) 结构
当 SO A 引用 SO B 中某个带版本的符号时,A 的 .gnu.version_r 记录此需求:
typedef struct { |
9.4 版本匹配规则
linker 在符号查找时的版本匹配逻辑(bionic/linker/linker_gnu_hash.cpp 中实现,函数为 check_symbol_version):
给定: 符号 S 来自 .dynsym |
9.5 版本不匹配导致的常见错误
// dlopen 时的典型错误信息: |
这通常表示 libfoo.so 引用了某个需要特定版本标记的符号,但当前 Android 系统的 libc.so(或其他依赖库)没有导出该版本的符号。
常见原因:
- NDK 版本不匹配:用 NDK r25 编译的 SO 放到旧版 Android 系统上,缺少新版本符号
- 平台私有符号引用:SO 试图引用
LIBC_PRIVATE版本符号,但应用 namespace 不允许访问 - 缺少 Verneed 条目:编译时链接了某个依赖,但运行时该依赖不存在或版本不对
排查方法:
# 查看 SO 需要的版本 |
十、Android Linker 特有机制
10.1 Namespace 隔离(Android 7.0+)
Android 7.0 引入 linker namespace 机制,限制应用 .so 可以 link 哪些系统库:
/system/lib64/ (平台 SO,只有 NDK 公开 API 可被应用访问) |
linker 维护多个 namespace(default, sphal, vndk 等),每个 namespace 有独立的搜索路径和链接规则。应用只能链接到 NDK 公开 API 列表中的符号。
10.2 dlopen 流程
void *handle = dlopen("libfoo.so", RTLD_NOW); |
10.3 DT_RELR — Android 的压缩重定位
Android 10+ 引入 RELR 相对重定位格式,将重定位表体积压缩 5-10 倍。传统的 .rela.dyn 每条重定位 24 字节 (ARM64),而 RELR 使用位图编码多个连续重定位,极大减小了 SO 体积。
$ readelf -d libnative.so | grep RELR |
十一、linker 的符号解析 (lookup) 算法
linker 的符号解析是动态链接最核心的操作。每次调用 dlsym() 或 PLT 延迟绑定时,linker 都需要在已加载的 SO 图中搜索符号。Android Bionic linker 的实现位于 bionic/linker/linker.cpp(主要函数:soinfo::lookup_symbol 及相关辅助函数)。
11.1 查找入口
linker 提供两个主要入口:
| 入口 | 触发来源 | 搜索范围 |
|---|---|---|
dlsym(handle, "sym") |
应用调用 dlsym / dlsym(RTLD_DEFAULT, ...) |
handle 指定的 SO 及其依赖树;RTLD_DEFAULT 则搜索全局作用域 |
_dl_runtime_resolve |
PLT 延迟绑定时自动触发 | 从调用者 SO 的依赖树中搜索,结果缓存到 GOT |
11.2 符号查找算法(BFS 广度优先)
AOSP linker 使用广度优先搜索(BFS)遍历依赖图,函数为 soinfo::find_symbol_by_name() / soinfo::lookup_symbol()(7.0+ 重构后):
// 简化版符号查找算法 |
BFS vs DFS:Bionic 使用 BFS,确保先搜索直接依赖、再搜索间接依赖。这与 GNU/Linux glibc linker 的默认行为一致。
11.3 符号版本匹配
版本匹配由 check_symbol_version() 在 bionic/linker/linker_gnu_hash.cpp 中实现:
bool version_match(Elf64_Sym* sym, soinfo* requesting_so, int version_index) { |
11.4 符号可见性过滤
在遍历 dynsym 时,linker 会检查每个符号的 st_other 可见性字段:
| 可见性 | 行为 |
|---|---|
| STV_DEFAULT (0) | 正常导出,任何依赖 SO 均可引用 |
| STV_HIDDEN (2) | 符号仅在定义 SO 内部可见。外部 SO 引用该符号时,linker 会跳过它(如同不存在) |
| STV_PROTECTED (3) | 符号可被引用,但引用永远在定义 SO 内解析,不会被其他 SO 的同名符号覆盖。类似 -Bsymbolic 的效果,但作用于单个符号 |
| STV_INTERNAL (1) | 与 HIDDEN 相似,语义更强(在某些工具链中等同于 HIDDEN) |
HIDDEN vs PROTECTED 实战差异:
__attribute__((visibility("hidden")))→ STV_HIDDEN → 外部调用该函数会产生链接错误__attribute__((visibility("protected")))→ STV_PROTECTED → 外部可以调用,但保证调用到这个 SO 的版本,不会被LD_PRELOAD或同名符号覆盖
11.5 全局作用域 (Global Scope) 与 Namespace 作用域
Android 7.0+ 的 namespace 机制增加了额外的作用域层级:
符号查找的完整路径: |
11.6 symbol lookup 与 dlsym 的差异
| 维度 | PLT 延迟解析 | dlsym() |
|---|---|---|
| 触发方式 | 自动(首次调用函数时) | 应用主动调用 |
| 搜索范围 | 仅依赖树 | RTLD_DEFAULT: 全局;具体 handle: 其依赖树 |
| 符号类型 | 仅 .dynsym |
.dynsym + .symtab(如果未 strip) |
| 是否缓存 | 结果写入 GOT | 每次调用都搜索 |
| 版本匹配 | 是(严格) | RTLD_DEFAULT 下可配置 |
十二、Android linker 的 soinfo 结构
soinfo 是 Bionic linker 内部用于跟踪每个已加载 SO 的核心数据结构。定义在 bionic/linker/linker_soinfo.h。
12.1 soinfo 结构(简化版)
// 源: bionic/linker/linker_soinfo.h |
12.2 soinfo 的分配与生命周期
soinfo 生命周期: |
12.3 load_bias 详解
load_bias 是实现 PIE/ASLR 的关键概念:
load_bias = 运行时 mmap 返回的基址 - ELF 中最早的 PT_LOAD 的 p_vaddr |
十三、Android 特有的 ELF 扩展
13.1 RELR 压缩重定位格式
Android 10 (API 29) 引入 RELR 压缩重定位格式,旨在压缩大量 R_AARCH64_RELATIVE 类型重定位(占大多数 SO 重定位条目的 90%+)。传统的 Elf64_Rela 每条 24 字节,RELR 使用位图编码可将体积压缩至原来的 1/5~1/10。
RELR 编码格式:
.relr.dyn section 包含一系列 64-bit 条目,格式如下:
RELR 条目类型(由 LSB 位 0 区分): |
示例:
.relr.dyn 的内容(64-bit words): |
在 .dynamic 中的标记:
$ readelf -d libfoo.so | grep -E "RELR|RELRSZ|RELRENT" |
注: Android 最初使用
DT_ANDROID_RELR/DT_ANDROID_RELRSZ/DT_ANDROID_RELRENT作为自定义标签。RELR 被标准化为通用 ELF 扩展后,现代 AOSP 同时识别标准名和 ANDROID_ 前缀名。
13.2 android_dlopen_ext 与 ANDROID_DLEXT_* 标志
Android 提供了 android_dlopen_ext() 作为 dlopen() 的扩展,允许指定更精细的加载控制:
|
核心 ANDROID_DLEXT_ 标志*(定义于 bionic/libc/include/android/dlext.h):
| 标志 | 值 | 说明 |
|---|---|---|
| ANDROID_DLEXT_RESERVED_ADDRESS | 0x1 | 要求在指定地址加载 SO(需要 library_fd 和预期地址) |
| ANDROID_DLEXT_RESERVED_ADDRESS_HINT | 0x2 | 建议在指定地址附近加载 |
| ANDROID_DLEXT_WRITE_RELRO | 0x4 | 将 RELRO 段写入文件(配合 ANDROID_DLEXT_RESERVED_ADDRESS 实现跨进程共享 GOT) |
| ANDROID_DLEXT_USE_RELRO | 0x8 | 从文件恢复 RELRO 段内容 |
| ANDROID_DLEXT_USE_LIBRARY_FD | 0x10 | 使用 library_fd 而不是文件路径加载 SO |
| ANDROID_DLEXT_USE_LIBRARY_FD_OFFSET | 0x20 | 配合 library_fd,从指定偏移开始读取(用于 APK 内的 SO,无需解压) |
| ANDROID_DLEXT_FORCE_LOAD | 0x40 | 即使 DT_NEEDED 未声明也强制加载依赖 |
| ANDROID_DLEXT_USE_NAMESPACE | 0x200 | 加载到 library_namespace 指定的 namespace 中 |
| ANDROID_DLEXT_FORCE_FIXED_VADDR | 0x400 | 强制 SO 加载到编译时的 p_vaddr(通常用于预链接的 vendor SO) |
| ANDROID_DLEXT_LOAD_AT_FIXED_ADDRESS | 0x800 | 在已确定的地址加载(用于 system server 启动优化) |
典型应用场景:
- APK 内 .so 直接加载: 使用
ANDROID_DLEXT_USE_LIBRARY_FD_OFFSET从 APK zip 内部偏移直接 mmap,避免解压到磁盘。Android 6.0+ 的NativeLibraryHelper默认使用此方式 - 跨进程 RELRO 共享: 使用
ANDROID_DLEXT_WRITE_RELRO/ANDROID_DLEXT_USE_RELRO在 Zygote 子进程间共享已解析的 GOT,加速应用启动
13.3 .note.android.ident Section
Android NDK r23+ 会在编译的 SO 中嵌入 .note.android.ident section,用于标识该 SO 是使用哪个 NDK 版本构建的:
$ readelf -n libfoo.so |
该 section 类型为 SHT_NOTE,包含 NDK 版本、clang 版本等构建元数据。主要用于:
- 兼容性诊断:崩溃报告中可以看到 SO 是用哪个 NDK 构建的
- 符号可用性判断:不同 NDK 版本对应不同的 API level 和可用符号集
13.4 Vendor NDK (VNDK) vs Platform NDK 的符号限制
Android 8.0 引入 VNDK(Vendor Native Development Kit),明确区分两类符号:
| 类型 | 链接库 | 符号范围 | 使用方 |
|---|---|---|---|
| NDK 公开 API | libc.so, libm.so, libandroid.so 等 |
严格限制在 NDK 文档列出的符号 | 应用和 vendor 模块 |
| VNDK 库 | libbase.so, libutils.so, libbinder.so 等 |
VNDK 子集(VNDK-core / VNDK-SP) |
Vendor HAL 实现 |
| 平台私有 (LLNDK) | libart.so, libandroid_runtime.so |
平台内部使用 | 仅 system 分区模块 |
| SP-HAL (Same-Process HAL) | VNDK-SP 库子集 | 隔离在 sphal namespace |
Google 提供的 HAL 实现 |
符号访问规则:
应用 SO (default namespace): |
十四、实战: SO 注入技术
SO 注入(SO Injection)在 Android 逆向工程中有广泛应用:Hook 框架注入、游戏修改、抓包工具等。以下是主流注入技术及其原理。
14.1 ptrace 注入法(经典方式)
原理:利用 ptrace 系统调用附加到目标进程,劫持其执行流,在目标进程内执行 dlopen 加载 SO。
ptrace 注入流程: |
ARM64 shellcode 模板(简化版):
// 目标: 在远程进程中执行 dlopen("/data/local/tmp/inject.so", RTLD_NOW) |
限制:Android 10+ 对非系统进程的 ptrace 附加有更严格的限制。普通应用进程无法 ptrace attach 其他应用进程(受 seccomp 和 SELinux 限制)。此方法主要用于 root 设备或系统级进程。
14.2 /proc/pid/maps 注入法
原理:通过修改目标进程的 /proc/pid/mem 和 /proc/pid/maps 来实现代码注入。
流程: |
限制:/proc/pid/mem 写入在 Android 上受到 SELinux 严格限制,非特权进程通常无法写入其他进程的内存。
14.3 Linker 级注入(LD_PRELOAD 等效)
Linux 上的 LD_PRELOAD 环境变量在 Android 上不直接生效(Android linker 忽略该环境变量)。但等效方法仍然存在:
方法一:wrap.sh(Android O+)
# 在应用 APK 的 lib/<abi>/ 目录下放置 wrap.sh |
Android O (8.0) 引入 wrap.sh 机制:如果 APK 中包含 lib/<abi>/wrap.sh,系统会在启动应用进程前执行该脚本。但 Android 10+ 限制了 wrap.sh 的使用范围(仅 debuggable 应用可用)。
方法二:修改 linker 的全局 SO 列表
Root 设备上可以:
- 通过 ptrace 修改 linker 内存中的
g_soinfo_list链表 - 将注入 SO 的 soinfo 插入到目标 SO 的依赖链中
- 触发目标 SO 的初始化函数执行
方法三:Zygote 注入
在 Zygote 进程 fork 应用进程之前注入:
- Root 设备上修改
init.rc中的 Zygote 启动参数 - 或使用 Magisk 模块(
ZYGISK模式)在 Zygote 启动时注入 - 所有应用进程启动时都会自动加载注入 SO
方法四:App 进程自注入(Xposed/LSPosed 方式)
- 使用
XposedBridge.jar通过System.loadLibrary()加载 native hook 库 - 在
JNI_OnLoad中执行 inline hook 或 PLT hook - 无需 ptrace 或 root,但需要 Xposed 框架环境
14.4 Android 安全机制对抗总结
| Android 版本 | 安全机制 | 对 SO 注入的影响 |
|---|---|---|
| 5.0+ | PIE 强制 | 注入 SO 必须编译为 PIE(ET_DYN) |
| 6.0+ | FULL RELRO + GOT 只读 | GOT Hook 失效,必须 inline hook |
| 7.0+ | Linker namespace 隔离 | 注入 SO 不能直接链接平台私有库 |
| 8.0+ | CFI (Control Flow Integrity) | 间接调用被硬件保护,限制 inline hook |
| 10+ | ptrace 限制 | 普通应用无法 ptrace attach 其他进程 |
| 10+ | 系统分区只读 (APEX) | 无法修改 /system 下文件进行注入 |
| 12+ | MTE (Memory Tagging Extension) | 内存标记位增加 shellcode 注入难度 |
| 13+ | 更严格的 seccomp 策略 | 限制可用的系统调用集合 |
十五、完整 Python ELF 解析器
#!/usr/bin/env python3 |
十六、逆向实战
16.1 函数定位与符号恢复
发布版 SO 通常 strip 了 .symtab 和 .strtab,但 .dynsym 始终保留(动态链接需要)。通过 .dynsym 可以恢复所有导出/导入的 JNI 函数名。
# 查看导入的 libc 函数 |
对于动态注册的 JNI,函数名不在符号表中,需要反汇编 JNI_OnLoad 找到 RegisterNatives 调用。
16.2 Inline Hook 原理
由于 FULL RELRO 保护了 GOT,modern Hook 框架使用 inline hook:
- 在目标函数开头保存前 N 条指令(N ≥ 4,ARM64 指令 4 字节)
- 用
LDR X17, #8; BR X17; .quad hook_addr(3 条指令 = 12 字节)覆写函数开头 - 当目标函数被调用时,跳转到 hook 函数
- hook 函数内部调用被保存的原始指令 + 跳回原函数继续执行
16.3 SO 加固检测
加固 SO 的常见特征:
.init_array中有解密/反调试函数- 存在非标准的 segment(自定义 PT_LOAD 类型)
.dynamic被修改或缺少某些预期 tag.text段在文件中和内存中内容不一致(运行时解密)p_filesz != p_memsz的 LOAD segment 异常大
面试常考问题
Q1: Android 上 ELF 和 Linux ELF 的主要差异是什么?
A: (1) Android 统一使用 ET_DYN 类型,无 ET_EXEC;(2) 引入 RELR 压缩重定位格式(DT_ANDROID_RELR);(3) linker 实现 namespace 隔离机制,限制应用访问非 NDK 系统符号;(4) 不支持 LD_PRELOAD 环境变量(linker 在 Android 7.0+ 中忽略 DT_RPATH/LD_LIBRARY_PATH 的应用设置);(5) 强制 FULL RELRO + PIE。
Q2: ELF 加载时,.bss 段为什么在文件中不占空间?
A: .bss 包含未初始化的全局变量(值均为 0)。Program Header 中 .bss 的 p_filesz=0 而 p_memsz>0,linker 在 mmap 时通过 MAP_ANONYMOUS 为多出的内存分配零填充页,避免浪费磁盘空间。
Q3: FULL RELRO 下还如何实现 Native Hook?
A: GOT 不可写后,三种方法:(1) Inline hook — 直接修改目标函数的前几条指令,跳转到 hook 桩代码;(2) PLT hook via dlopen — 用 dlsym 获取函数地址,再用 inline hook;(3) linker-based hook — Hook __loader_dlopen 或 do_dlopen 等 linker 内部函数,在加载时改写符号解析结果。
Q4: .rela.dyn 与 .rela.plt 的区别?
A: .rela.dyn 包含非 PLT 重定位:全局数据指针(如 C++ vtable、字符串指针)和作为数据引用的函数指针。这些在加载时必须立即解析(R_AARCH64_RELATIVE),与 BIND_NOW/Lazy 无关。.rela.plt 包含 PLT 函数重定位(如 printf, malloc),由 R_AARCH64_JUMP_SLOT 描述,在 lazy binding 下延迟解析。FULL RELRO 下两者在加载时都全部解析。
Q5: 如何从 tombstone 中的 PC 地址定位到源码行?
A: tombstone 中的 PC 是运行时的虚拟地址。需要:(1) 从 tombstone 的 memory map 区域找到崩溃 SO 的基址;(2) 计算 偏移 = PC - 基址;(3) 使用 addr2line -e obj/local/arm64-v8a/libxxx.so <偏移> 转换到源码行。注意必须使用未 strip 的带符号 SO(obj/local/<abi>/ 下的文件,而非 libs/<abi>/ 下 strip 后的产物)。
Q6: R_AARCH64_JUMP_SLOT 和 R_AARCH64_GLOB_DAT 有什么本质区别?
A: 两者都设置 GOT 条目,但用途和来源不同:JUMP_SLOT (1026) 来自 .rela.plt,用于 PLT 函数调用 (func()),在 Partial RELRO 下可延迟解析;GLOB_DAT (1025) 来自 .rela.dyn,用于数据引用(如 &func 取函数地址赋给全局变量),始终在加载时立即解析。.rela.dyn 的处理独立于 BIND_NOW 标志。
Q7: Android linke 符号版本化中,VER_NDX_LOCAL 和 STV_HIDDEN 的作用有何不同?
A: 两者都限制符号可见性,但作用层级不同:(1) VER_NDX_LOCAL(.gnu.version 中值为 0)在运行时查找阶段过滤,linker 在 BFS 搜索时跳过这些符号,是 GNU ELF 的版本化机制;(2) STV_HIDDEN(st_other 字段)更底层:编译器和静态链接器就不允许外部引用 HIDDEN 符号,直接报链接错误。两者叠加时效果一致,但作用时机不同。
Q8: soinfo 的 load_bias 和 base 是什么关系?
A: base 是 linker 调用 mmap 返回的加载基址(由 kernel ASLR 随机化)。load_bias = base - min(所有 PT_LOAD 的 p_vaddr)。对于首个 LOAD 段 p_vaddr=0 的 SO,load_bias == base;对于首个 LOAD 段 p_vaddr 不为 0 的 SO,load_bias != base。所有 ELF 内地址的运行时转换公式为 runtime_addr = p_vaddr + load_bias。
参考
- AOSP:
bionic/linker/linker.cpp— Android 动态链接器核心实现(dlopen,dlsym,do_dlopen,find_library,link_library) - AOSP:
bionic/linker/linker_soinfo.h—soinfo结构体完整定义(含所有字段和标志位) - AOSP:
bionic/libc/kernel/uapi/linux/elf.h— ELF 结构定义(Elf64_Ehdr,Elf64_Phdr,Elf64_Shdr,Elf64_Sym,Elf64_Rela,Elf64_Dyn) - AOSP:
bionic/libc/kernel/uapi/asm-arm64/asm/elf.h— ARM64 重定位类型定义(R_AARCH64_RELATIVE,R_AARCH64_ABS64等) - AOSP:
bionic/linker/linker_relocs.h— 重定位处理实现(process_relocation) - AOSP:
bionic/linker/linker_gnu_hash.cpp— GNU hash 表查找和符号版本匹配(check_symbol_version) - AOSP:
bionic/linker/linker_namespaces.cpp— Namespace 隔离实现(android_namespace_t, namespace 创建和链接规则) - AOSP:
bionic/linker/arch/arm64/begin.S— ARM64 PLT resolver stub 汇编实现(_dl_runtime_resolve入口) - AOSP:
bionic/libc/include/android/dlext.h—android_dlopen_ext和ANDROID_DLEXT_*标志定义 - AOSP:
bionic/linker/linker_phdr.cpp— ELF Program Header 解析和 LOAD segment mmap 实现 - ELF Specification:
http://www.sco.com/developers/gabi/latest/contents.html - ARM ELF64 (AAELF64):
https://developer.arm.com/documentation/ihi0056 - RELR 压缩重定位:
https://groups.google.com/g/generic-abi/c/bX460iggiKg





