目录
  1. 1. 前言
  2. 2. 一、Android 中 ELF 的特殊性
    1. 2.1. 1.1 ET_DYN 统一化
    2. 2.2. 1.2 支持架构
  3. 3. 二、ELF 文件两种视图
  4. 4. 三、ELF Header (Ehdr) 逐字段解析
    1. 4.1. 3.1 完整结构定义
    2. 4.2. 3.2 真实 hex dump 分析
    3. 4.3. 3.3 e_ident 详解
  5. 5. 四、Program Headers — 执行视图
    1. 5.1. 4.1 Elf64_Phdr 结构
    2. 5.2. 4.2 常见 Segment 类型
    3. 5.3. 4.3 Segment 权限标志 (p_flags)
    4. 5.4. 4.4 真实的 Program Headers 实例
  6. 6. 五、Section Headers — 链接视图
    1. 6.1. 5.1 Elf64_Shdr 结构
    2. 6.2. 5.2 核心 Section 清单
    3. 6.3. 5.3 .dynsym 符号结构
  7. 7. 六、PLT/GOT 延迟绑定机制
    1. 7.1. 6.1 为什么需要 PLT/GOT?
    2. 7.2. 6.2 首次调用流程
    3. 7.3. 6.3 FULL RELRO vs Partial RELRO
  8. 8. 七、.dynamic Section — 动态链接信息
    1. 8.1. 核心 DT_ 标签
  9. 9. 八、ARM64 重定位类型详解
    1. 9.1. 8.1 重定位表项结构
    2. 9.2. 8.2 ARM64 核心重定位类型
    3. 9.3. 8.3 R_AARCH64_RELATIVE 详解
    4. 9.4. 8.4 R_AARCH64_JUMP_SLOT 与 R_AARCH64_GLOB_DAT 的区别
    5. 9.5. 8.5 linker 如何处理各类重定位(简化流程)
  10. 10. 九、符号版本化详解
    1. 10.1. 9.1 涉及的 Section
    2. 10.2. 9.2 特殊版本索引常量
    3. 10.3. 9.3 .gnu.version_r (Verneed) 结构
    4. 10.4. 9.4 版本匹配规则
    5. 10.5. 9.5 版本不匹配导致的常见错误
  11. 11. 十、Android Linker 特有机制
    1. 11.1. 10.1 Namespace 隔离(Android 7.0+)
    2. 11.2. 10.2 dlopen 流程
    3. 11.3. 10.3 DT_RELR — Android 的压缩重定位
  12. 12. 十一、linker 的符号解析 (lookup) 算法
    1. 12.1. 11.1 查找入口
    2. 12.2. 11.2 符号查找算法(BFS 广度优先)
    3. 12.3. 11.3 符号版本匹配
    4. 12.4. 11.4 符号可见性过滤
    5. 12.5. 11.5 全局作用域 (Global Scope) 与 Namespace 作用域
    6. 12.6. 11.6 symbol lookup 与 dlsym 的差异
  13. 13. 十二、Android linker 的 soinfo 结构
    1. 13.1. 12.1 soinfo 结构(简化版)
    2. 13.2. 12.2 soinfo 的分配与生命周期
    3. 13.3. 12.3 load_bias 详解
  14. 14. 十三、Android 特有的 ELF 扩展
    1. 14.1. 13.1 RELR 压缩重定位格式
    2. 14.2. 13.2 android_dlopen_ext 与 ANDROID_DLEXT_* 标志
    3. 14.3. 13.3 .note.android.ident Section
    4. 14.4. 13.4 Vendor NDK (VNDK) vs Platform NDK 的符号限制
  15. 15. 十四、实战: SO 注入技术
    1. 15.1. 14.1 ptrace 注入法(经典方式)
    2. 15.2. 14.2 /proc/pid/maps 注入法
    3. 15.3. 14.3 Linker 级注入(LD_PRELOAD 等效)
    4. 15.4. 14.4 Android 安全机制对抗总结
  16. 16. 十五、完整 Python ELF 解析器
  17. 17. 十六、逆向实战
    1. 17.1. 16.1 函数定位与符号恢复
    2. 17.2. 16.2 Inline Hook 原理
    3. 17.3. 16.3 SO 加固检测
  18. 18. 面试常考问题
  19. 19. 参考
【逆向安全技术-基础篇】so文件格式解析

前言

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
Type: DYN (Shared object file)

$ readelf -h /system/lib64/libc.so | grep Type
Type: DYN (Shared object file)

两者 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
┌──────────────┐ ┌──────────────┐
│ ELF Header │ │ ELF Header │
├──────────────┤ ├──────────────┤
│ Program │ ←── │ Program │ 加载到内存时使用
│ Header Table │ │ Header Table │
├──────────────┤ ├──────────────┤
│ Section 1 │ │ │
├──────────────┤ │ Segment 1 │
│ Section 2 │ │ (含多个 │
├──────────────┤ │ Section) │
│ ... │ │ │
├──────────────┤ ├──────────────┤
│ Section n │ │ Segment 2 │
├──────────────┤ │ │
│ Section │ ├──────────────┤
│ Header Table │ ──→ │ │ 不需要加载
└──────────────┘ └──────────────┘
  • Linking View(链接视图): 编译/链接阶段使用 Section Headers 组织代码和数据
  • Execution View(执行视图): loader (linker) 在运行时按 Program Headers 中的 Segment 描述将文件映射到内存

三、ELF Header (Ehdr) 逐字段解析

3.1 完整结构定义

// bionic/libc/kernel/uapi/linux/elf.h
typedef struct elf64_hdr {
unsigned char e_ident[16]; // ELF 标识
Elf64_Half e_type; // 文件类型
Elf64_Half e_machine; // 目标架构
Elf64_Word e_version; // ELF 版本(始终=1)
Elf64_Addr e_entry; // 入口地址(SO > 0,可执行文件 > 代码段)
Elf64_Off e_phoff; // Program Header 表偏移
Elf64_Off e_shoff; // Section Header 表偏移
Elf64_Word e_flags; // 处理器特定标志
Elf64_Half e_ehsize; // ELF header 大小(始终=64)
Elf64_Half e_phentsize; // 每个 Program Header 大小(始终=56)
Elf64_Half e_phnum; // Program Header 数量
Elf64_Half e_shentsize; // 每个 Section Header 大小(始终=64)
Elf64_Half e_shnum; // Section Header 数量
Elf64_Half e_shstrndx; // Section 名称字符串表的索引
} Elf64_Ehdr;

3.2 真实 hex dump 分析

以下是一个真实 libnative.so(ARM64)的 ELF header hex dump:

Offset  Hex                                              Decode
0x00 7F 45 4C 46 e_ident[0..3] = ELF magic "\x7FELF"
0x04 02 e_ident[4] = ELFCLASS64 (64-bit)
0x05 01 e_ident[5] = ELFDATA2LSB (小端序)
0x06 01 e_ident[6] = EV_CURRENT (版本1)
0x07 00 e_ident[7] = ELFOSABI_NONE
0x08 00 00 00 00 00 00 00 00 e_ident[8..15] = padding

0x10 03 00 e_type = 3 (ET_DYN)
0x12 B7 00 e_machine = 0xB7 = 183 (EM_AARCH64)
0x14 01 00 00 00 e_version = 1
0x18 00 10 00 00 00 00 00 00 e_entry = 0x1000 (入口地址)
0x20 40 00 00 00 00 00 00 00 e_phoff = 0x40 (Program Headers 起始)
0x28 50 C2 00 00 00 00 00 00 e_shoff = 0xC250 (Section Headers 起始)
0x30 00 00 00 00 e_flags = 0
0x34 40 00 e_ehsize = 64
0x36 38 00 e_phentsize = 56
0x38 0B 00 e_phnum = 11 (11 个 Program Headers)
0x3A 40 00 e_shentsize = 64
0x3C 1F 00 e_shnum = 31 (31 个 Section Headers)
0x3E 1E 00 e_shstrndx = 30 (Section 名称表在第30个section)

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 {
Elf64_Word p_type; // Segment 类型
Elf64_Word p_flags; // 内存权限标志(64-bit 中 flags 和 type 都是 4 字节)
Elf64_Off p_offset; // 在文件中的偏移
Elf64_Addr p_vaddr; // 虚拟内存地址
Elf64_Addr p_paddr; // 物理地址(通常与 vaddr 相同)
Elf64_Xword p_filesz; // 文件中占用的字节数
Elf64_Xword p_memsz; // 内存中占用的字节数(>= filesz)
Elf64_Xword p_align; // 对齐要求
} 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_DYN PIE),因为它含有 INTERP 段指定 /system/bin/linker64。普通 .so 共享库 不包含 INTERP 段——只有可执行文件才需要通过 INTERP 指定动态链接器的路径。

$ readelf -l /system/bin/app_process64

Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x0000000000000268 0x0000000000000268 R 0x8
INTERP 0x00000000000002A8 0x00000000000002A8 0x00000000000002A8
0x0000000000000019 0x0000000000000019 R 0x1
[Requesting program interpreter: /system/bin/linker64]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000A54 0x0000000000000A54 R E 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000208 0x0000000000000210 RW 0x1000
DYNAMIC 0x0000000000001010 0x0000000000001010 0x0000000000001010
0x0000000000000190 0x0000000000000190 RW 0x8
GNU_RELRO 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000208 0x0000000000000208 R 0x1
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10

这里有两个 LOAD segment

  1. 第一个 LOAD (R_E): 映射代码段 (.text, .rodata, .plt 等只读/可执行节)
  2. 第二个 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 {
Elf64_Word sh_name; // Section 名称在 .shstrtab 中的偏移
Elf64_Word sh_type; // Section 类型
Elf64_Xword sh_flags; // Section 属性标志
Elf64_Addr sh_addr; // 内存地址
Elf64_Off sh_offset; // 文件偏移
Elf64_Xword sh_size; // 大小(字节)
Elf64_Word sh_link; // 关联 section 的索引
Elf64_Word sh_info; // 额外信息
Elf64_Xword sh_addralign; // 对齐要求
Elf64_Xword sh_entsize; // 条目大小(固定大小的 table 有效)
} 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 {
Elf64_Word st_name; // 符号名在 .dynstr 中的偏移
unsigned char st_info; // 符号类型 + 绑定属性
unsigned char st_other; // 可见性 (STV_DEFAULT/HIDDEN/INTERNAL/PROTECTED)
Elf64_Half st_shndx; // 符号所在 section 索引
Elf64_Addr st_value; // 符号地址
Elf64_Xword st_size; // 符号大小
} 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 中的 openmalloc)在内存中的实际地址。GOT 存储这些地址,PLT 提供间接跳转。

6.2 首次调用流程

调用者代码:
BL printf@plt // ① 跳转到 PLT 表项

printf@plt: // ARM64 PLT 表项(每条 16 字节)
ADRP x16, .got.plt // ② 获取 GOT 表所在 4KB 页基址
LDR x17, [x16, #page_off] // ③ 加载 GOT 条目中的地址
ADD x16, x16, #page_off // ④ x16 = GOT 条目的精确地址(供 resolver 反查条目索引)
BR x17 // ⑤ 跳转到目标

首次调用时,GOT[printf] 尚未填充真实地址,而指向下一条指令(引导进入 resolver):

PLT[0] (公共 resolver stub): // ARM64 标准 PLT 解析器桩
// ARM64 GOT 布局约定:
// GOT[0] = .dynamic 段地址
// GOT[1] = link_map 指针(即 soinfo*)
// GOT[2] = _dl_runtime_resolve 函数指针
// 调用 _dl_runtime_resolve(link_map, reloc_index)
// 其中 reloc_index 由 linker 根据 x16 指向的 GOT 条目反推
STP x29, x30, [sp, #-16]! // 保存 frame pointer 和 link register
STP x0, x1, [sp, #-16]! // 保存参数/返回值寄存器
STP x2, x3, [sp, #-16]!
STP x4, x5, [sp, #-16]!
STP x6, x7, [sp, #-16]!
LDR x0, [x16, #-16] // 从 GOT[-2](即 GOT[1])加载 link_map → x0
LDR x17, [x16, #-8] // 从 GOT[-1](即 GOT[2])加载 resolver → x17
BLR x17 // ⑥ 调用 _dl_runtime_resolve(link_map, reloc_index)

_dl_runtime_resolve 内部:
1. 根据 reloc_index 定位到对应的 Elf64_Rela 条目
2. 从 .dynsym 中查找符号名(如 "printf")
3. 在已加载的依赖 SO 列表中搜索该符号的定义地址
4. 将解析到的地址写入 GOT[printf](更新为真实地址)
5. 跳转到真实的 printf 函数入口

后续调用:
BL printf@plt → GOT[printf] 已有真实地址 → 直接跳转,无额外开销

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.pltmprotect 为只读。这阻止了基于 GOT 覆写的 Hook,但 Frida/Xposed 仍可通过 inline hook(直接修改目标函数的前几条指令)绕过。

可以通过以下命令检查:

$ readelf -d libnative.so | grep BIND_NOW
0x0000000000000018 (BIND_NOW)

七、.dynamic Section — 动态链接信息

.dynamic 是 tag-value 对数组,linker 据此获取 SO 的所有动态链接信息:

typedef struct elf64_dyn {
Elf64_Sxword d_tag; // 条目类型
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} 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 {
Elf64_Addr r_offset; // 重定位目标地址(需要修正的内存位置)
Elf64_Xword r_info; // 高 4 字节: 符号索引, 低 4 字节: 重定位类型
Elf64_Sxword r_addend; // 显式加数(A 值)
} Elf64_Rela;

// 提取符号索引和重定位类型的宏
#define ELF64_R_SYM(info) ((info) >> 32)
#define ELF64_R_TYPE(info) ((info) & 0xFFFFFFFF)

与 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 段中的全局变量位置)
r_addend = 0x2000 (编译时 .rodata 字符串与 Base 的偏移)

执行: *(0x7000003000) = 0x7000000000 + 0x2000 = 0x7000002000

该字符串的运行时地址被正确写入全局变量中。

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):
sym = &dynsym[ELF64_R_SYM(r_info)]
type = ELF64_R_TYPE(r_info)

switch type:
case R_AARCH64_NONE:
continue // 跳过

case R_AARCH64_RELATIVE:
*(base + r_offset) = base + r_addend
break

case R_AARCH64_ABS64:
sym_addr = lookup_symbol(sym) // 符号查找
*(base + r_offset) = sym_addr + r_addend
break

case R_AARCH64_GLOB_DAT:
sym_addr = lookup_symbol(sym)
*(base + r_offset) = sym_addr + r_addend
break

case R_AARCH64_JUMP_SLOT:
if (FULL_RELRO || !lazy):
sym_addr = lookup_symbol(sym)
*(base + r_offset) = sym_addr + r_addend
else:
// Lazy: GOT 初始指向 PLT stub(已在编译时填充)
// linker 不需要在这里做任何事
pass
break

case R_AARCH64_COPY:
// 从定义 SO 复制数据到当前 SO 的 .bss 区域
memcpy(base + r_offset, def_sym_addr, sym.st_size)
break

case R_AARCH64_IRELATIVE:
resolver = (Elf64_Addr(*)()) (base + r_addend)
*(base + r_offset) = resolver()
break

九、符号版本化详解

符号版本化(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 {
Elf64_Half vn_version; // 版本结构版本号(始终为 1)
Elf64_Half vn_cnt; // 关联的 Vernaux 条目数量
Elf64_Word vn_file; // 依赖 SO 名称在 .dynstr 中的偏移(如 "libc.so")
Elf64_Word vn_aux; // 第一个 Vernaux 条目的偏移
Elf64_Word vn_next; // 下一个 Verneed 条目的偏移(组成链表)
} Elf64_Verneed;

typedef struct {
Elf64_Word vna_hash; // 版本名称的 ELF hash
Elf64_Half vna_flags; // 标志位
Elf64_Half vna_other; // .gnu.version 中使用的版本索引(≥2)
Elf64_Word vna_name; // 版本名称在 .dynstr 中的偏移(如 "LIBC")
Elf64_Word vna_next; // 下一个 Vernaux 条目的偏移
} Elf64_Vernaux;

9.4 版本匹配规则

linker 在符号查找时的版本匹配逻辑(bionic/linker/linker_gnu_hash.cpp 中实现,函数为 check_symbol_version):

给定: 符号 S 来自 .dynsym
S 在 .gnu.version 中的条目 → version_index

1. version_index == VER_NDX_LOCAL (0):
→ 本地符号,跳过,不暴露给任何引用者

2. version_index == VER_NDX_GLOBAL (1):
→ 全局符号,任何 SO 均可匹配

3. version_index >= 2:
→ 该符号有特定版本约束
→ 在 .gnu.version_r 中找到 version_index 对应的版本名称(如 "LIBC_PRIVATE")
→ 仅当引用者明确声明需要该版本时,才返回此符号

9.5 版本不匹配导致的常见错误

// dlopen 时的典型错误信息:
dlopen failed: cannot locate symbol "__emutls_get_address"
referenced by "libfoo.so"

这通常表示 libfoo.so 引用了某个需要特定版本标记的符号,但当前 Android 系统的 libc.so(或其他依赖库)没有导出该版本的符号。

常见原因

  1. NDK 版本不匹配:用 NDK r25 编译的 SO 放到旧版 Android 系统上,缺少新版本符号
  2. 平台私有符号引用:SO 试图引用 LIBC_PRIVATE 版本符号,但应用 namespace 不允许访问
  3. 缺少 Verneed 条目:编译时链接了某个依赖,但运行时该依赖不存在或版本不对

排查方法

# 查看 SO 需要的版本
$ readelf -V libfoo.so

Version needs section '.gnu.version_r':
Needed section: libc.so
0x0c00: Version: LIBC (1)

# 查看系统库提供的版本
$ readelf -V /system/lib64/libc.so | head -20

Version definition section '.gnu.version_d':
0x0001: Version: LIBC
0x0002: Version: LIBC_N
0x0003: Version: LIBC_P
0x0004: Version: LIBC_Q
...

十、Android Linker 特有机制

10.1 Namespace 隔离(Android 7.0+)

Android 7.0 引入 linker namespace 机制,限制应用 .so 可以 link 哪些系统库:

/system/lib64/ (平台 SO,只有 NDK 公开 API 可被应用访问)
├── libc.so ← 公开
├── libm.so ← 公开
├── libandroid.so ← 公开
└── libart.so ← 私有 (应用无法直接链接)

/vendor/lib64/ (厂商 SO)
/data/app/xxx/lib/arm64/ (应用 SO)

linker 维护多个 namespace(default, sphal, vndk 等),每个 namespace 有独立的搜索路径和链接规则。应用只能链接到 NDK 公开 API 列表中的符号。

10.2 dlopen 流程

void *handle = dlopen("libfoo.so", RTLD_NOW);

// linker 内部流程:
// 1. 解析 ELF header,验证 magic 和 e_machine
// 2. 遍历 Program Headers,mmap LOAD segments
// 3. 应用 RELRO 保护(mprotect)
// 4. 解析 .dynamic,遍历 DT_NEEDED 加载依赖 SO
// 5. 处理重定位(.rela.dyn, .rela.plt)
// 6. 调用 .init_array 中的初始化函数
// 7. 返回 soinfo 指针

10.3 DT_RELR — Android 的压缩重定位

Android 10+ 引入 RELR 相对重定位格式,将重定位表体积压缩 5-10 倍。传统的 .rela.dyn 每条重定位 24 字节 (ARM64),而 RELR 使用位图编码多个连续重定位,极大减小了 SO 体积。

$ readelf -d libnative.so | grep RELR
0x000000006FFFE000 (ANDROID_RELR) 0x1234
0x000000006FFFE003 (ANDROID_RELRSZ) 48 (bytes)

十一、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+ 重构后):

// 简化版符号查找算法
// src: bionic/linker/linker.cpp → soinfo::lookup_symbol()
// 需在 AOSP bionic/linker/linker.cpp 中验证最新实现

Symbol lookup(soinfo* requesting_so, const char* sym_name) {

// ① 首先在 requesting_so 自身的 .dynsym 中查找
Symbol s = requesting_so->find_symbol_by_name(sym_name);
if (s != NULL && version_match(s, requesting_so)) {
return s; // 自己定义的符号优先级最高
}

// ② 广度优先遍历 DT_NEEDED 依赖树
Queue<soinfo*> queue;
Set<soinfo*> visited;
for each DT_NEEDED so in requesting_so->children:
queue.push(so);

while (!queue.empty()) {
soinfo* cur = queue.pop();
if (visited.contains(cur)) continue;
visited.add(cur);

// 在当前 SO 中查找
s = cur->find_symbol_by_name(sym_name);
if (s != NULL && version_match(s, requesting_so)) {
return s;
}

// 将当前 SO 的依赖也加入队列(BFS 展开)
for each DT_NEEDED dep in cur->children:
queue.push(dep);
}

return NULL; // 未找到
}

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

switch (version_index):

case VER_NDX_LOCAL (0):
// 符号对引用者不可见
return false;

case VER_NDX_GLOBAL (1):
// 无版本约束,总是匹配
return true;

default (>= 2):
// version_index 对应 .gnu.version_r 中的一个 Verneed 条目
// 检查 requesting_so 是否声明需要该版本
Verneed* vn = requesting_so->get_verneed_by_index(version_index);
if (vn != NULL && vn->vna_name == sym_version_name) {
return true;
}
return false; // 版本不匹配,符号视为不存在
}

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 机制增加了额外的作用域层级:

符号查找的完整路径:

1. 在 requesting_so 所属 namespace 中搜索
- 遍历该 namespace 中已加载的所有 SO
- 按照 BFS 顺序搜索每个 SO 的 .dynsym

2. 如果当前 namespace 设置了 linked_namespaces
- 允许搜索其他 namespace 时,也遍历那些 namespace 中的 SO

3. 平台 SO(如 libc.so)导出两类符号:
- NDK 公开符号:放在全局可见的 namespace 中 ↓
- 平台私有符号(LIBC_PRIVATE 版本标记):仅平台 namespace 可见

4. 应用 SO 默认 namespace 只能访问 NDK 公开符号列表
- android_dlopen_ext 可以通过 ANDROID_DLEXT_USE_NAMESPACE 指定目标 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
// 以下为简化版本,完整结构含 60+ 字段
// 需在 AOSP bionic/linker/linker_soinfo.h 中验证完整字段列表

struct soinfo {

// === 基本信息 ===
char name[128]; // SO 名称(如 "libc.so"),非完整路径
const char* real_path; // SO 在文件系统中的完整路径
const ElfW(Phdr)* phdr; // Program Header 数组指针(mmap 映射到内存后)
size_t phnum; // Program Header 数量
ElfW(Addr) base; // SO 加载基址(第一个 LOAD segment 的 mmap 起始地址)
ElfW(Addr) load_bias; // base - min(p_vaddr of LOAD segments)
// 用于虚拟地址与运行时地址的转换
size_t size; // SO 在内存中占用的总大小
ElfW(Addr) entry; // 入口点(可执行文件 e_entry,SO 通常为 0)

// === .dynamic 段信息 ===
const ElfW(Dyn)* dynamic; // .dynamic 段在内存中的地址

// === 符号与字符串表 ===
const char* strtab; // .dynstr 指针
ElfW(Sym)* symtab; // .dynsym 指针
size_t strtab_size; // .dynstr 大小
size_t nbucket; // SYSV hash: bucket 数量
size_t nchain; // SYSV hash: chain 数量
uint32_t* bucket; // SYSV hash bucket 数组
uint32_t* chain; // SYSV hash chain 数组

// === GNU hash 表 ===
uint32_t gnu_nbucket; // GNU hash bucket 数量
uint32_t* gnu_bucket; // GNU hash bucket 数组
uint32_t* gnu_chain; // GNU hash chain 数组
uint32_t gnu_maskwords; // GNU hash bloom filter 位数
uint32_t gnu_shift2; // GNU hash bloom filter shift
ElfW(Addr)* gnu_bloom_filter; // GNU hash bloom filter

// === 版本信息 ===
const ElfW(Versym)* versym; // .gnu.version 指针
const ElfW(Verneed)* verneed; // .gnu.version_r 指针
size_t verneed_num; // Verneed 条目数量

// === 重定位表 ===
ElfW(Rela)* plt_rela; // .rela.plt 指针
size_t plt_rela_count; // PLT 重定位条目数量
ElfW(Rela)* rela; // .rela.dyn 指针
size_t rela_count; // 非 PLT 重定位条目数量

// === 依赖关系 ===
soinfo_list_t children; // DT_NEEDED 依赖列表(本 SO 依赖了谁)
soinfo_list_t parents; // 反向依赖列表(谁依赖了本 SO)
uint32_t ref_count; // 引用计数(dlopen/dlclose 管理)

// === Namespace ===
android_namespace_t* primary_namespace; // 所属 namespace

// === 标志位 ===
uint32_t flags; // FLAG_LINKED, FLAG_EXE, FLAG_LINKER, FLAG_NEW_SOINFO 等

// === PREINIT_ARRAY / INIT_ARRAY / FINI_ARRAY ===
linker_ctor_function_t* preinit_array;
size_t preinit_array_count;
linker_ctor_function_t* init_array;
size_t init_array_count;
linker_dtor_function_t* fini_array;
size_t fini_array_count;

// === TLS (Thread-Local Storage) ===
// module ID, offset, size 等

// === 链表指针(linker 全局 SO 列表) ===
// next, prev 等

// === 文件身份 ===
dev_t st_dev;
ino_t st_ino;

// ... 更多字段
};

12.2 soinfo 的分配与生命周期

soinfo 生命周期:

1. 分配
- 系统 linker(/system/bin/linker64)预分配一个静态 soinfo 数组
- 早期 Android 版本(< 7.0)使用固定大小的全局 soinfo_pool[]
- Android 7.0+ 重构后使用动态分配(soinfo::allocator),移除数量上限
- 内存来自 linker 自己的堆(linker 运行前 malloc 不可用)

2. 初始化 (find_library → load_library)
a. 创建 soinfo 对象
b. 设置 name(从 DT_SONAME 或文件名推断)
c. 打开 ELF 文件,读取 Elf64_Ehdr
d. 验证 e_machine 匹配当前架构
e. 读取 Program Headers
f. mmap 所有 PT_LOAD segments → 设置 base、size、load_bias
g. 设置 phdr、phnum、dynamic 指针
h. 解析 .dynamic 段 → 填充 strtab、symtab、hash、rela 等字段

3. 链接 (find_library → link_library)
a. 链接 DT_NEEDED 依赖:填充 children 列表
b. 建立反向依赖:填充被依赖 SO 的 parents 列表
c. 添加到全局 SO 链表 (g_soinfo_list)
d. 执行重定位(relocate())

4. 初始化 (call_constructors)
a. 递归初始化所有依赖 SO(自底向上)
b. 执行 .preinit_array、.init_array
c. 设置 FLAG_LINKED 标志

5. 销毁 (dlclose → unload_library)
a. ref_count--
b. 如果 ref_count == 0:
- 执行 .fini_array
- 递归卸载依赖(ref_count-- 并可能卸载)
- munmap LOAD segments
- 释放 soinfo

12.3 load_bias 详解

load_bias 是实现 PIE/ASLR 的关键概念:

load_bias = 运行时 mmap 返回的基址 - ELF 中最早的 PT_LOAD 的 p_vaddr

示例:
ELF 中: LOAD[0].p_vaddr = 0x0000 (代码段)
LOAD[1].p_vaddr = 0x1000 (数据段)

运行时: mmap 返回基址 = 0x7a00_0000

load_bias = 0x7a00_0000 - 0x0000 = 0x7a00_0000

地址转换:
运行时地址 = ELF 中的 p_vaddr + load_bias
ELF 地址 = 运行时地址 - load_bias

例如 .dynsym 的 ELF 地址为 0x500:
运行时 .dynsym = 0x500 + 0x7a00_0000 = 0x7a00_0500

十三、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 区分):

1. 地址条目(bit 0 = 0 或 bit 0 = 1 且全位图为 0):
→ 64-bit 值存储的是绝对虚拟地址(p_vaddr),需要对其进行重定位
→ *(base + value) += base

2. 位图条目(bit 0 = 1):
→ bits[63:1] 构成一个 63-bit 的位图
→ 位图中的某位为 1,表示 "距离上一个重定位地址 + 该位索引 * 8 字节" 的位置也需要重定位
→ 公式:*(base + prev_addr + (i+1)*8) += base (对于位 i)

示例

.relr.dyn 的内容(64-bit words):

0x0000000000002000 ← 地址条目: 重定位 vaddr=0x2000 的位置
0x0000000000000007 ← 位图条目: bit=1, bits[1]=1, bits[2]=1
→ 重定位 vaddr=0x2008, vaddr=0x2010 的位置

该序列表示 3 个连续重定位: 0x2000, 0x2008, 0x2010
用 RELA 格式需 3×24=72 字节,RELR 仅用 2×8=16 字节

在 .dynamic 中的标记

$ readelf -d libfoo.so | grep -E "RELR|RELRSZ|RELRENT"
0x000000006FFFE000 (RELR) 0x338 # .relr.dyn 在内存中的地址
0x000000006FFFE003 (RELRSZ) 32 (bytes) # .relr.dyn 的大小
0x000000006FFFE001 (RELRENT) 8 (bytes) # 每条 RELR 条目大小(始终为 8)

: 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() 的扩展,允许指定更精细的加载控制:

#include <android/dlext.h>

typedef struct {
uint64_t flags; // ANDROID_DLEXT_* 标志位组合
void* library_fd; // 预打开的文件描述符
off64_t library_fd_offset; // 从文件描述符的偏移开始读取
const char* library_namespace; // 目标 namespace 名称
// ... 更多字段
} android_dlextinfo;

void* android_dlopen_ext(const char* filename, int flag,
const android_dlextinfo* extinfo);

核心 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

Displaying notes found in: .note.android.ident
Owner Data size Description
Android 0x00000004 NT_VERSION
NDK r26b (clang 17.0.2)

该 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):
✓ 可以链接: NDK 公开符号(libc.so: LIBC 版本)
✗ 不能链接: LIBC_PRIVATE 版本符号
✗ 不能链接: libart.so 中的任何符号

Vendor SO (sphal namespace):
✓ 可以链接: VNDK 库中的 VNDK-core 符号
✗ 不能链接: 系统分区中的非 VNDK 库

系统 SO (default namespace, system 分区):
✓ 可以链接: 所有平台库

十四、实战: SO 注入技术

SO 注入(SO Injection)在 Android 逆向工程中有广泛应用:Hook 框架注入、游戏修改、抓包工具等。以下是主流注入技术及其原理。

14.1 ptrace 注入法(经典方式)

原理:利用 ptrace 系统调用附加到目标进程,劫持其执行流,在目标进程内执行 dlopen 加载 SO。

ptrace 注入流程:

1. ATTACH: ptrace(PTRACE_ATTACH, pid)
→ 暂停目标进程的所有线程

2. 保存现场:
→ ptrace(PTRACE_GETREGS) 获取目标进程的 CPU 寄存器状态
→ 保存 PC、SP、LR 等关键寄存器(用于后续恢复)

3. 注入 shellcode:
→ 在目标进程地址空间中使用 mmap 分配内存
→ ptrace(PTRACE_POKETEXT) 逐字写入 shellcode
→ shellcode 功能: 调用 dlopen("inject.so"), 调用 dlsym 获取入口函数

4. 劫持执行:
→ 修改目标进程 PC 指向 shellcode
→ ptrace(PTRACE_SETREGS)
→ ptrace(PTRACE_CONT) 恢复执行

5. Shellcode 执行完毕:
→ 触发 breakpoint (BKPT) 通知注入进程
→ 注入进程恢复原始寄存器状态

6. DETACH: ptrace(PTRACE_DETACH, pid)

ARM64 shellcode 模板(简化版):

// 目标: 在远程进程中执行 dlopen("/data/local/tmp/inject.so", RTLD_NOW)
// 注意: 需要先定位 dlopen 在 libc.so 中的地址
// 实际 shellcode 还需处理参数传递和返回值

// 保存现场
STP x29, x30, [sp, #-16]!
STP x0, x1, [sp, #-16]!
// ... 保存更多寄存器

// 准备 dlopen 参数
ADR x0, so_path // x0 = "/data/local/tmp/inject.so"
MOV x1, #2 // x1 = RTLD_NOW
LDR x16, dlopen_addr
BLR x16 // dlopen(path, RTLD_NOW)

// 调用注入 SO 的入口函数
ADR x0, entry_name // x0 = "entry"
MOV x1, x19 // x1 = 返回的 handle
LDR x16, dlsym_addr
BLR x16 // dlsym(handle, "entry")
BLR x0 // entry()

// 触发断点通知注入进程完成
BRK #0

// 恢复现场
// ...

so_path: .asciz "/data/local/tmp/inject.so"
entry_name: .asciz "entry"
dlopen_addr: .quad 0x0 // 需在注入前填充真实地址
dlsym_addr: .quad 0x0

限制:Android 10+ 对非系统进程的 ptrace 附加有更严格的限制。普通应用进程无法 ptrace attach 其他应用进程(受 seccomp 和 SELinux 限制)。此方法主要用于 root 设备或系统级进程。

14.2 /proc/pid/maps 注入法

原理:通过修改目标进程的 /proc/pid/mem/proc/pid/maps 来实现代码注入。

流程:
1. 解析 /proc/pid/maps,找到 libc.so 的加载地址
2. 在 libc.so 中查找 dlopen 和 dlsym 的函数偏移
3. 通过 /proc/pid/mem 写入 shellcode 和注入 SO 路径字符串
4. 需要某种方式触发执行(如利用信号处理函数、或配合 ptrace)

限制/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
#!/system/bin/sh
export LD_PRELOAD=/data/data/com.example/lib/libhook.so
exec "$@"

Android O (8.0) 引入 wrap.sh 机制:如果 APK 中包含 lib/<abi>/wrap.sh,系统会在启动应用进程前执行该脚本。但 Android 10+ 限制了 wrap.sh 的使用范围(仅 debuggable 应用可用)。

方法二:修改 linker 的全局 SO 列表
Root 设备上可以:

  1. 通过 ptrace 修改 linker 内存中的 g_soinfo_list 链表
  2. 将注入 SO 的 soinfo 插入到目标 SO 的依赖链中
  3. 触发目标 SO 的初始化函数执行

方法三:Zygote 注入
在 Zygote 进程 fork 应用进程之前注入:

  1. Root 设备上修改 init.rc 中的 Zygote 启动参数
  2. 或使用 Magisk 模块(ZYGISK 模式)在 Zygote 启动时注入
  3. 所有应用进程启动时都会自动加载注入 SO

方法四:App 进程自注入(Xposed/LSPosed 方式)

  1. 使用 XposedBridge.jar 通过 System.loadLibrary() 加载 native hook 库
  2. JNI_OnLoad 中执行 inline hook 或 PLT hook
  3. 无需 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
"""ELF 解析器 for Android .so files"""
import struct
import sys

# Architecture constants
EM_AARCH64 = 183
EM_ARM = 40
EM_X86_64 = 62

# Program header types
PT_LOAD = 1
PT_DYNAMIC = 2
PT_INTERP = 3
PT_GNU_RELRO = 0x6474E552
PT_GNU_STACK = 0x6474E551

# Dynamic tags
DT_NEEDED = 1
DT_SONAME = 14
DT_STRTAB = 5
DT_SYMTAB = 6
DT_STRSZ = 10
DT_GNU_HASH = 0x6FFFFEF5
DT_INIT_ARRAY = 25
DT_FINI_ARRAY = 26
DT_BIND_NOW = 24
DT_FLAGS_1 = 0x6FFFFFFB

# Section types
SHT_DYNSYM = 11
SHT_STRTAB = 3

EI_CLASS_64 = 2
EI_DATA_LSB = 1

class ELFParser:
def __init__(self, path):
with open(path, 'rb') as f:
self.data = f.read()
self.is_64 = self.data[4] == EI_CLASS_64
self.is_le = self.data[5] == EI_DATA_LSB
self.endian = '<' if self.is_le else '>'

def parse_header(self):
if self.is_64:
fmt = f'{self.endian}16sHHIIQQQIHHHHHH'
fields = struct.unpack_from(fmt, self.data, 0)
return {
'e_type': fields[1], 'e_machine': fields[2],
'e_entry': fields[4], 'e_phoff': fields[5],
'e_shoff': fields[6], 'e_phnum': fields[12],
'e_shnum': fields[14], 'e_shstrndx': fields[15],
}
else:
# 32-bit ELF
fmt = f'{self.endian}16sHHIIIIIHHHHHH'
fields = struct.unpack_from(fmt, self.data, 0)
return {
'e_type': fields[1], 'e_machine': fields[2],
'e_entry': fields[4], 'e_phoff': fields[5],
'e_shoff': fields[6], 'e_phnum': fields[11],
'e_shnum': fields[13], 'e_shstrndx': fields[14],
}

def parse_program_headers(self, hdr):
phdrs = []
for i in range(hdr['e_phnum']):
if self.is_64:
off = hdr['e_phoff'] + i * 56
typ, flags, f_off, vaddr, paddr, fsz, msz, align = \
struct.unpack_from(f'{self.endian}IIQQQQQQ', self.data, off)
else:
off = hdr['e_phoff'] + i * 32
typ, f_off, vaddr, paddr, fsz, msz, flags, align = \
struct.unpack_from(f'{self.endian}IIIIIIII', self.data, off)
if typ in [PT_LOAD, PT_DYNAMIC, PT_GNU_RELRO, PT_GNU_STACK]:
phdrs.append({
'type': typ, 'flags': flags, 'vaddr': vaddr,
'filesz': fsz, 'memsz': msz, 'offset': f_off,
})
return phdrs

def parse_sections(self, hdr):
"""解析 section names 并列出关键 section"""
if hdr['e_shoff'] == 0:
return {} # stripped,没有 section headers
if self.is_64:
entry_size = 64
else:
entry_size = 40

# 先读取 .shstrtab 获取 section 名
shstr_off = hdr['e_shoff'] + hdr['e_shstrndx'] * entry_size
if self.is_64:
_, _, _, _, sh_offset, sh_size = struct.unpack_from(
f'{self.endian}IIQQQQ', self.data, shstr_off)[:6]
else:
_, _, _, sh_offset, sh_size = struct.unpack_from(
f'{self.endian}IIIII', self.data, shstr_off)[:5]

sections = {}
for i in range(hdr['e_shnum']):
s_off = hdr['e_shoff'] + i * entry_size
if self.is_64:
name_off, shtype, _, addr, offset, size = \
struct.unpack_from(f'{self.endian}IIQQQQ', self.data, s_off)[:6]
else:
name_off, shtype, _, addr, offset, size = \
struct.unpack_from(f'{self.endian}IIIII', self.data, s_off)[:5]
if size > 0 and shtype in [SHT_DYNSYM, SHT_STRTAB]:
name = self._read_str(self.data, sh_offset + name_off)
sections[name] = {'addr': addr, 'offset': offset, 'size': size}
return sections

def parse_dynamic(self, hdr):
"""解析 .dynamic section 获取依赖和符号表信息"""
phdrs = self.parse_program_headers(hdr)
dyn_info = {'needed': [], 'strtab': 0, 'symtab': 0, 'strsz': 0}
dyn_off = None
for p in phdrs:
if p['type'] == PT_DYNAMIC:
dyn_off = p['offset']
break
if dyn_off is None:
return dyn_info

entry_size = 16 if self.is_64 else 8
i = 0
while True:
d_off = dyn_off + i * entry_size
if self.is_64:
d_tag, d_val = struct.unpack_from(f'{self.endian}QQ', self.data, d_off)
else:
d_tag, d_val = struct.unpack_from(f'{self.endian}II', self.data, d_off)
if d_tag == 0:
break
if d_tag == DT_NEEDED:
dyn_info['needed'].append(d_val) # offset in .dynstr
elif d_tag == DT_STRTAB:
dyn_info['strtab'] = d_val
elif d_tag == DT_SYMTAB:
dyn_info['symtab'] = d_val
elif d_tag == DT_STRSZ:
dyn_info['strsz'] = d_val
i += 1
return dyn_info

def _read_str(self, data, offset):
end = data.index(0, offset)
return data[offset:end].decode('ascii', errors='replace')

def dump(self):
hdr = self.parse_header()
print(f"ELF Type: {'64-bit' if self.is_64 else '32-bit'}")
print(f"Machine: {hdr['e_machine']} ({'ARM64' if hdr['e_machine']==EM_AARCH64 else 'ARM' if hdr['e_machine']==EM_ARM else 'x86-64' if hdr['e_machine']==EM_X86_64 else '?'})")
print(f"Entry: 0x{hdr['e_entry']:X}")
print(f"PHdr: {hdr['e_phnum']} headers @ 0x{hdr['e_phoff']:X}")

phdrs = self.parse_program_headers(hdr)
for p in phdrs:
names = {PT_LOAD: 'LOAD', PT_DYNAMIC: 'DYNAMIC',
PT_GNU_RELRO: 'GNU_RELRO', PT_GNU_STACK: 'GNU_STACK'}
name = names.get(p['type'], f"0x{p['type']:X}")
flags = ''
if p['flags'] & 4: flags += 'R'
if p['flags'] & 2: flags += 'W'
if p['flags'] & 1: flags += 'X'
print(f" {name:12} vaddr=0x{p['vaddr']:X} filesz=0x{p['filesz']:X} memsz=0x{p['memsz']:X} {flags}")

dyn = self.parse_dynamic(hdr)
print(f"\nDT_NEEDED: {dyn['needed']}")
# 通过 .dynstr 解析依赖 SO 名称
if dyn['strtab'] and dyn['needed']:
strtab_base = dyn['strtab']
for offset in dyn['needed']:
name = self._read_str(self.data, strtab_base + offset)
print(f" → {name}")

# 检查 FULL RELRO
# 需要遍历 .dynamic segment 来找 DT_BIND_NOW 或 DF_1_NOW
relro = "Unknown"
if self.is_64:
entry_size = 16
else:
entry_size = 8
# Find PT_DYNAMIC first to get the file offset of .dynamic
for p in self.parse_program_headers(hdr):
if p['type'] == PT_DYNAMIC:
dyn_file_off = p['offset']
break
else:
dyn_file_off = None

if dyn_file_off:
i = 0
while True:
d_off = dyn_file_off + i * entry_size
if self.is_64:
d_tag, d_val = struct.unpack_from(f'{self.endian}QQ', self.data, d_off)
else:
d_tag, d_val = struct.unpack_from(f'{self.endian}II', self.data, d_off)
if d_tag == 0:
break
if d_tag == DT_BIND_NOW:
relro = "FULL RELRO"
break
if d_tag == DT_FLAGS_1 and (d_val & 1):
relro = "FULL RELRO (DF_1_NOW)"
break
i += 1
print(f"\nRELRO: {relro}")


if __name__ == '__main__':
parser = ELFParser(sys.argv[1] if len(sys.argv) > 1 else 'libnative.so')
parser.dump()

十六、逆向实战

16.1 函数定位与符号恢复

发布版 SO 通常 strip 了 .symtab.strtab,但 .dynsym 始终保留(动态链接需要)。通过 .dynsym 可以恢复所有导出/导入的 JNI 函数名。

# 查看导入的 libc 函数
readelf -r libnative.so | grep "LIBC"

# 查看 JNI 导出函数 (静态注册)
readelf -s libnative.so | grep "Java_"

对于动态注册的 JNI,函数名不在符号表中,需要反汇编 JNI_OnLoad 找到 RegisterNatives 调用。

16.2 Inline Hook 原理

由于 FULL RELRO 保护了 GOT,modern Hook 框架使用 inline hook:

  1. 在目标函数开头保存前 N 条指令(N ≥ 4,ARM64 指令 4 字节)
  2. LDR X17, #8; BR X17; .quad hook_addr(3 条指令 = 12 字节)覆写函数开头
  3. 当目标函数被调用时,跳转到 hook 函数
  4. 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 中 .bssp_filesz=0p_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_dlopendo_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 的带符号 SOobj/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_HIDDENst_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.hsoinfo 结构体完整定义(含所有字段和标志位)
  • 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.handroid_dlopen_extANDROID_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
打赏
  • 微信
  • 支付宝

评论