解析ELF .so:加载器为何只认Program Header 简介面向 Android 开发者的「解析so文件」示例工程聚焦原生动态链接库的结构理解与 JNI 调用链梳理适合需要排查 .so 加载失败、符号缺失或 Java 与 C/C 互调问题的初中级工程师。压缩包共 30 个文件、约 58KB含 18 个 class、5 个 java 源文件、2 个 h 头文件与 2 个 libhello-jni 系列 .so 样本另附 .classpath、.project、.prefs 等 Eclipse 工程配置导入后即可对照阅读。目前已有 3147 人学习下载。其价值在于把工程骨架与二进制样本并置读者可借助 readelf、objdump、nm 查看 ELF 头、反汇编代码与动态符号表也可沿 JNIEXPORT/JNICALL 实现路径理解 javah 生成头文件、System.loadLibrary 加载到函数注册的过程并延伸到 ABI 分包与体积裁剪的实践判断。1. 解析 so 文件为什么 loader 只认 program header不认 section header解析 so 文件的第一个反直觉结论section header 表可以整个删掉库照样能加载运行而 program header 表少一条PT_LOAD进程立刻段错误。这不是偶然。section header 是链接器写给人和工具看的“说明书”——符号名、调试信息、节区偏移都在里面strip 一下就没了。program header 是内核和动态链接器 mmap 的“施工图”——哪些字节映射到哪段虚拟地址、哪块可读可执行、入口在哪里运行时只信这一份。很多解析工具写崩就是因为拿说明书当施工图用。把这条分界线立住后面的符号查找、重定位、GOT/PLT 才有地方挂。理解了它你才能解释为什么一个被加固工具处理过的 soreadelf -S直接报“Section headers not found”但readelf -d依然能把依赖列得清清楚楚。2. ELF 头与 program header解析器如何在一堆字节里定位映射段2.1 e_ident 到 e_phnum结构体每个字段在回答什么问题ELF 头部是解析的入口它只回答两个问题这是什么文件以及去哪里找表。64 位下结构如下32 位把Q换成I、字段顺序略有差异typedef struct { unsigned char e_ident[16]; // 0x00: 魔数 \x7fELF 类别/字节序/版本/OSABI Elf64_Half e_type; // 0x10: ET_DYN(3) 表示共享库或 PIE Elf64_Half e_machine; // 0x12: 183EM_AARCH64, 62EM_X86_64 Elf64_Word e_version; // 0x14 Elf64_Addr e_entry; // 0x18: 共享库通常为 0 Elf64_Off e_phoff; // 0x20: program header 表的文件偏移 Elf64_Off e_shoff; // 0x28: section header 表的文件偏移 Elf64_Word e_flags; // 0x30 Elf64_Half e_ehsize; // 0x34: 头部自身大小一般 64 Elf64_Half e_phentsize; // 0x36: 单个 phdr 的步长不是 sizeof Elf64_Half e_phnum; // 0x38: phdr 条数 Elf64_Half e_shentsize; // 0x3a Elf64_Half e_shnum; // 0x3c: 可能为 0 Elf64_Half e_shstrndx; // 0x3e } Elf64_Ehdr;e_ident[4]是EI_CLASS132 位264 位e_ident[5]是EI_DATA1小端2大端。这两个字节决定了后面所有整数字段的解码方式写解析器时必须先读它们再决定struct的格式串不能硬编码Q。e_phentsize与e_phnum这一对字段是很多手写解析器第一个容易出错的地方。ABI 允许e_phentsize大于sizeof(Elf64_Phdr)遍历时必须用e_phoff i * e_phentsize做步进而不是e_phoff i * sizeof(...)。反过来如果直接用sizeof步进在正常文件上往往也能凑巧算对一旦遇到特别构造的样本就会整个错位读出来的p_type全是随机值。e_shnum为 0 时还存在一种约定如果e_shoff非 0 且e_shnum为 0真实节区数存放在第 0 个节头的sh_size里。这是历史遗留的兼容写法加固和裁剪工具会利用它藏信息也会直接把它抹成 0 来阻止静态分析。2.2 PT_LOAD 的 vaddr/offset 换算为什么“地址减基址”不一定对program header 里真正影响加载的只有PT_LOAD。结构如下typedef struct { Elf64_Word p_type; // 1PT_LOAD, 2PT_DYNAMIC, 4PT_NOTE, ... Elf64_Word p_flags; // 4R, 2W, 1X Elf64_Off p_offset; // 该段在文件中的起始偏移 Elf64_Addr p_vaddr; // 该段期望的虚拟地址 Elf64_Addr p_paddr; // 物理地址用户态忽略 Elf64_Xword p_filesz; // 文件中占用的字节数 Elf64_Xword p_memsz; // 内存中占用的字节数 filesz Elf64_Xword p_align; // 对齐要求 } Elf64_Phdr;p_vaddr在ET_DYN里是相对基址的偏移加载时内核会把整段映射到某个随机基址base实际地址变成base p_vaddr。所以“虚拟地址减基址得到文件偏移”这句话只对第一段成立。真正的约束是p_alignABI 要求p_vaddr % p_align p_offset % p_align。也就是说每一段自己的文件偏移和虚拟地址之间差一个固定的常量但这个常量各段不同。正确的换算方式是先找到虚拟地址落在哪一段的[p_vaddr, p_vaddr p_filesz)区间内再算PT_LOAD 1 def vaddr_to_off(self, vaddr): 把虚拟地址换成文件偏移必须逐段匹配不能全局减基址 for p in self.phdrs: p_type, p_flags, p_offset, p_vaddr, _pa, p_filesz, _ms, _align p if p_type PT_LOAD and p_vaddr vaddr p_vaddr p_filesz: return p_offset (vaddr - p_vaddr) raise ValueError(fvaddr 0x{vaddr:x} 不在任何 PT_LOAD 段的可映射区间内)这段函数是后续所有解析的基础dynamic 表、符号表、重定位表在文件里存的都是虚拟地址全得靠它换算成真实偏移。p_memsz p_filesz的那一段差额是.bss文件里没有对应字节换算时必须用p_filesz做上界用p_memsz会越界读到下一段数据。提示遇到p_align为0x1000或0x4000的段很常见但p_align为 1 的段也存在比如只读的元数据段别写死按页对齐去推断。2.3 PT_DYNAMIC 与 .dynamic同一份数据的两条访问路径.dynamic节和PT_DYNAMIC段指向的是同一块内存区别在访问路径节靠e_shoff索引段靠e_phoff索引。这块内存是一串 16 字节的键值对d_tag为 0DT_NULL时结束typedef struct { Elf64_Sxword d_tag; // 标签如 DT_NEEDED1, DT_STRTAB5 union { Elf64_Xword d_val; // 整数型标签的值 Elf64_Addr d_ptr; // 指针型标签的虚拟地址 } d_un; } Elf64_Dyn;用段落路径的好处很直接PT_DYNAMIC的p_offset是一个确定的文件偏移不需要任何换算就能开始遍历而.dynamic节要先找到.shstrtab、比对节名、读sh_offset链路长且依赖 section header 表完好。被加固处理过的 so 通常会把e_shoff置 0 或把整个节头表抹成随机值readelf -S会直接失败但加载器根本不看这些东西readelf -d依然正常工作因为 GNU readelf 的-d参数走的就是PT_DYNAMIC。这也是一个自查手段如果你的解析器在某个样本上崩溃了先换readelf -d试一次能出结果说明问题出在你的 section 路径上。DT_STRTAB、DT_SYMTAB、DT_RELA这些指针类标签存的全是虚拟地址必须过一遍vaddr_to_off。DT_NEEDED、DT_SONAME、DT_STRSZ这些是整数其中DT_NEEDED和DT_SONAME的值是字符串表内的偏移量不是地址。这一点在遍历时很容易混淆因为同一个循环里两类值混在一起。3. 依赖与符号解析从 DT_NEEDED 到 GNU hash 的查找链路3.1 DT_NEEDED、SONAME 与依赖图的广度优先加载加载器打开一个 so 后第一件事是遍历它的 dynamic 表把所有DT_NEEDED收集起来。每个DT_NEEDED的值是一个字符串表偏移解出来是依赖项的名字比如libfoo.so.2——这个名字通常就是被依赖库的DT_SONAME。依赖图按广度优先展开先加载主库和它的所有直接依赖再加载二级依赖。这个顺序直接决定了符号查找的优先级——全局符号范围global scope按加载顺序排列同名符号谁先加载谁生效。这也是“同一个库的两个版本被拉进同一个进程”时行为诡异的根源后加载的那个版本里的同名符号永远不会被找到。路径搜索的顺序是DT_RPATH若未设置DT_RUNPATH→ 环境变量 → 默认目录 →DT_RUNPATH→ 系统缓存。DT_RPATH会传递给间接依赖DT_RUNPATH只对直接依赖生效这个差别是设计上的修正前者容易造成依赖污染后者更受控。在依赖解析阶段解析器能拿到的最有价值的信息不是“依赖了什么”而是“依赖的 SONAME 和实际提供的 SONAME 对不对得上”。很多线上崩溃是版本错配造成的——应用包里塞了一个低版本 so覆盖了系统里的高版本符号地址全乱。3.2 SysV hash 与 GNU hashO(n) 和 O(1) 的取舍符号查找的性能全压在哈希表上。ELF 支持两套老的 SysV hashDT_HASH和 GNU hashDT_GNU_HASH。SysV 的结构是nbucket, nchain, bucket[nbucket], chain[nchain]本质是链式哈希算出h % nbucket拿到桶里的符号索引然后顺着chain一个个比字符串最坏情况退化成线性扫描。GNU hash 加了布隆过滤器能先一步排除掉绝大多数不可能命中的情况// GNU hash 头部64 位下 bloom 元素是 8 字节 uint32_t nbuckets; // 桶数量 uint32_t symoffset; // 第一个被 hash 覆盖的符号下标 uint32_t bloom_size; // 布隆过滤器元素个数 uint32_t bloom_shift; uint64_t bloom[bloom_size]; uint32_t buckets[nbuckets]; uint32_t chain[]; // 长度 dynsym 总数 - symoffset查找时先把 hash 的两个位h % 64和(h bloom_shift) % 64在对应的 bloom 元素里检查任一位没有置位则说明这个符号一定不存在直接返回一次字符串比较都不用做。只有两位都置位才进桶桶里再用chain做一次线性比较——注意chain里每个值的低位是终止标志(chain_val | 1) (h | 1)才说明 hash 低位相等此时再做字符串比对。代价在构建期GNU hash 要求同一个桶里的符号在.dynsym里连续存放链接器必须重排符号表symoffset就是第一个被纳入 hash 覆盖范围的下标——它之前的符号通常是STN_UNDEF和导入符号不参与导出查找。这就是为什么你在动态符号表里看到的顺序和源码声明顺序完全对不上。一个 so 可以同时带两种 hash链接器默认都生成只带 GNU hash 的库在很老的加载器上会加载失败但这一点在移动端早已不是问题。3.3 dynsym 与字符串表st_name 是索引不是指针符号表是Elf64_Sym数组每个 24 字节typedef struct { Elf64_Word st_name; // 相对 DT_STRTAB 的字节偏移不是地址 unsigned char st_info; // 高 4 位 bind低 4 位 type unsigned char st_other;// 低 2 位可见性 Elf64_Half st_shndx; // 所属节区下标0(SHN_UNDEF) 表示未定义 Elf64_Addr st_value; // ET_DYN 里是相对基址的偏移 Elf64_Xword st_size; } Elf64_Sym;四个字段各有陷阱。st_name是偏移不是指针配合DT_STRTAB的虚拟地址才算完整而DT_STRTAB本身又要过一遍vaddr_to_off两跳换算。st_value在共享库里是相对基址的偏移直接当成绝对地址打印会得到一串看起来像“库内地址”的假值——加上加载基址才是运行时地址静态分析时它只是“符号在段内的位置”。st_shndx 0SHN_UNDEF表示这个符号在本库中没有定义要靠外部提供这正是导出符号和导入符号的分界线。st_info拆开看bind 为 1 是GLOBAL2 是WEAKtype 为 1 是OBJECT数据2 是FUNC函数10 是GNU_IFUNC解析时调用一个函数返回真实地址。遇到GNU_IFUNC就不要再试图静态推断它的实现了——它的地址在加载时才由 resolver 决定静态看到的只是 resolver 的入口。.dynsym和.symtab的差别也值得记一笔前者是运行时必需的参与动态链接后者仅供调试strip之后只剩前者。所以想从一个发布版 so 里找内部函数名靠dynsym基本无望——只有那些被导出、被重定位引用的符号才会留在里面。3.4 符号版本与可见性为什么同名符号能共存同一个库的新旧两个版本可以被装在同一台设备上靠的是符号版本机制。.gnu.version节给每个动态符号配一个 16 位版本下标DT_VERSYM指向它.gnu.version_d定义本库导出的版本DT_VERDEF.gnu.version_r声明依赖的版本DT_VERNEED。当一个符号带版本时它的真实名字在链接器视角下是fooVERS_1.2这样的形式。加载器在解析时先按无版本名找到候选再比对版本需求不匹配就继续往下找。这解释了为什么有些符号明明在dynsym里存在运行时却还是报 “undefined symbol”——版本对不上等同于不存在。st_other的低两位是可见性STV_DEFAULT0允许被外部覆盖symbol preemptionSTV_HIDDEN2不出现在动态符号表里STV_PROTECTED3导出但拒绝被覆盖。PROTECTED是个微妙的设计——它让编译器可以放心地把库内对该符号的调用直接绑到本地地址省掉一次 GOT 间接跳转代价是牺牲了 interposition 能力。想用 LD_PRELOAD 替换一个PROTECTED符号是换不掉的。4. 重定位与 GOT/PLTso 里的地址是在什么时候被填上的4.1 Rela 表结构r_offset / r_info / r_addend 各自的作用共享库的代码段是只读的编译时不知道自己要落在哪个地址所以所有涉及绝对地址的位置都留了“占位”等加载时由动态链接器改写。这些占位的位置记录在重定位表里。64 位下用Elf64_Rela带显式加数typedef struct { Elf64_Addr r_offset; // 需要被改写的目标地址虚拟地址不是文件偏移 Elf64_Xword r_info; // 高 32 位存符号下标低 32 位存重定位类型 Elf64_Sxword r_addend; // 加数参与最终地址计算 } Elf64_Rela;r_offset是本章最容易踩的坑它是虚拟地址必须走vaddr_to_off才能定位到文件里那 8 个字节。很多人看到重定位表里一堆0x4000、0x4010之类的值直接当文件偏移去读读出来的是一段完全无关的机器码。r_info的拆解在 64 位下是sym r_info 32type r_info 0xffffffff。注意 32 位 ELF 的拆法是反的sym r_info 8type r_info 0xff混用会导致符号下标指向错误的条目。遍历代码本身很短但每一步都依赖前面两张表import struct DT_RELA, DT_RELASZ, DT_JMPREL, DT_PLTRELSZ 7, 8, 23, 2 def relocations(self): d dict(self.dynamic()) # 常规重定位表 jmp {} # PLT 重定位表单独一块 base d.get(DT_RELA) if base: off self.vaddr_to_off(base) for i in range(0, d.get(DT_RELASZ, 0), 24): # 每条 24 字节 r_offset, r_info, r_addend struct.unpack_from(QQq, self.buf, off i) yield { offset: r_offset, type: r_info 0xffffffff, sym: r_info 32, addend: r_addend, }常规表里放的是数据引用和R_*_RELATIVEDT_JMPREL单独指向另一块表专门放函数调用的JUMP_SLOT。两块表分开的原因是处理时机不同前者必须在加载时全部处理完后者可以拖到第一次调用。4.2 R_*_RELATIVE 与 GLOB_DAT、JUMP_SLOT 的分工常见类型按架构不同编号不同但语义一致。以 64 位 ARM 为例R_AARCH64_RELATIVE 1027、R_AARCH64_GLOB_DAT 1025、R_AARCH64_JUMP_SLOT 1026x86_64 上对应 8、6、7。RELATIVE最简单也最常见不需要查符号直接把base addend写进base r_offset。它处理的是库内部对自身数据的引用比如全局指针变量初始化为某个静态数组的地址。GLOB_DAT用于跨库的数据引用从符号表取出目标符号的运行时地址写入 GOT 表中的对应项。JUMP_SLOT用于函数调用写入的位置同样在 GOT 里只是访问路径走 PLT 桩。这三者加起来占了一个典型 so 的重定位表九成以上。真正需要符号查找的只有后两类所以如果你的目标是分析“这个库依赖了外部哪些函数”直接过滤GLOB_DAT和JUMP_SLOT两类把sym下标换算成符号名得到的就是一份完整的导入清单——比从dynsym里筛SHN_UNDEF更精确因为后者还包括了仅用于版本检查的弱引用。4.3 懒绑定与 BIND_NOWGOT 里的值在静态分析时为什么是 0函数调用通过 PLT 桩转发调用方bl plt_stub桩里第一条指令是adrp x16, GOT页ldr x16, [x16, #GOT偏移]然后br x16。如果这个 GOT 项还没被填它会跳回 PLT0把符号下标压栈调用动态链接器的 resolver解析完再把真实地址写回 GOT下次就直接命中。这就是懒绑定lazy binding。它的好处是启动快——一个大型应用可能有一万条JUMP_SLOT但真正用到的函数只占小部分坏处是第一次调用的开销不可预测而且在严苛的实时场景里难以接受。-z now或DT_BIND_NOW标记会让加载器在启动时一次性解析完所有JUMP_SLOT配合 RELROPT_GNU_RELRO把 GOT 所在页设为只读防止 GOT 覆写攻击。代价是启动时间上升。现在的移动端和主流发行版默认都偏向now因为安全收益远大于那点启动开销。对解析器来说这意味着静态读出来的 GOT 初始值是 0或指向 PLT 桩的中间位置不代表那个函数指针为空。要拿到真实目标只能自己按符号名去依赖库里查——静态世界里的 0 只是一个“待填”的占位符。注意如果 so 带DT_TEXTREL标记说明它含有对只读段的重定位加载时链接器得临时把代码段改成可写再改回来。这类库通常来自老工具链现代编译选项-fPIC下不该出现。5. 避坑与排查解析 so 时最容易翻车的五类现场5.1 文件结构层面的坑现象一readelf -S报 “Section headers not found”自己写的解析器直接抛异常退出。原因很明确——e_shoff被置 0 或e_shnum被清零了。加固工具、体积裁剪工具、某些打包流程都会做这一步因为 section header 对运行毫无用处删掉能省几 KB顺便增加静态分析成本。如果你的解析入口是从 section 表开始的第一个动作就失败了。解决思路是把解析入口整体切到 program header先用e_phoff/e_phnum遍历段表从PT_DYNAMIC拿DT_STRTAB/DT_SYMTAB/DT_RELA全程不碰 section 表。同时加一个降级分支e_shoff 0时跳过所有依赖节名的解析逻辑只输出依赖清单和导入导出符号。这样同一份代码在正常库和被裁剪库上都能跑只是后者拿不到节区布局信息。现象二解析出来的依赖清单里全是陌生名字跟预期完全对不上。大概率是拿错了架构的文件。一个应用包里经常同时存在arm64-v8a、armeabi-v7a、x86_64三份同名的 so目录名相似文件名一模一样。file命令能看架构但脚本里批量处理时很容易把所有同名文件混进一个列表。更隐蔽的情况是两个不同架构的文件都能被同一份解析器打开因为 ELF 头和 program header 的结构布局是通用的只有e_machine和重定位类型编号不同。解析器不校验e_machine就会把 ARM 的重定位类型 1027 当成 x86_64 的某个无关类型来解释输出一堆看似正常实则完全错误的结果。解决办法是在解析入口强制校验e_machine白名单183/62/40 等不匹配直接报错退出并把e_machine打进结果元数据。批量处理时按(文件名, e_machine)做联合主键不要按文件名去重。5.2 符号与重定位层面的坑现象三读重定位表时r_offset处的值跟预期差了一个巨大的常量。这不是解析错误是把虚拟地址当文件偏移用了。r_offset的值可能只有0x4000左右看起来完全像个文件偏移因为在小型 so 里第一个PT_LOAD段的p_offset常常是 0、p_vaddr也是 0此时两者恰好相等。一旦换一个大一点的库或者第一个PT_LOAD的p_offset不是 0这个巧合就破了读到的位置整体错位。判断方法如果算出来的值是一段看起来像机器码的数字或者明显超出预期范围先打印每个PT_LOAD的(p_offset, p_vaddr)做对照。解决就是老老实实走vaddr_to_off别做任何“减基址”的简化。现象四GOT 表里某个函数指针的值是 0误判为“空实现”或“被裁剪”。懒绑定下这是完全正常的初始状态。加载器在第一次调用时才把真实地址写进去静态文件里那个位置就是 0。带-z now的库也一样因为文件里存的是重定位前的初始值真实地址只在内存里出现。验证方式有两个看 dynamic 表里有没有DT_BIND_NOW或DT_FLAGS的DF_BIND_NOW位有的话说明加载时全部解析静态看到 0 只是因为文件里本来就没写或者直接查重定位表里有没有对应的JUMP_SLOT条目有就说明这个槽位是“待填”而不是“空”。两条线索一致时才能下结论。现象五so 能正常解析出结构但符号名全是乱码或者st_name指向的位置读出二进制垃圾。最常见的两个原因。其一是DT_STRTAB没有正确换算——它存的是虚拟地址字符串表所在段的p_offset与p_vaddr不相等时就会整体偏移。其二是遇到了加密或混淆的 so部分加固方案会把.dynstr也加密只保留最小可用的头部供加载器识别等特定时机在内存里解密。这种情况下文件里的字符串表本来就是密文任何静态解析都只能得到乱码。识别特征是DT_STRSZ大小正常但读出来的字节熵极高或者字符串表首字节不是\0规范要求第一个字节必须是空字符。遇到这种样本静态解析的边界就到了需要转向内存 dump 或运行时 hook。硬啃文件格式没有意义——加密后的字节流本来就不遵循格式语义。坑位直接症状定位手段section 表被抹解析器启动即异常改走PT_DYNAMIC检查e_shoff多架构混包依赖清单全是陌生名字校验e_machine按架构分组vaddr 当偏移读出的值错位一大截打印各PT_LOAD的 offset/vaddr 对照GOT 为 0误判函数被裁剪查JUMP_SLOT与DT_BIND_NOW字符串表加密符号名乱码、熵值高检查DT_STRSZ与首字节6. 静态解析的边界什么时候该停下来写解析器最容易犯的错是默认“文件里有什么运行时就是什么”。前面几章讲的所有机制本质上都在打破这个假设section 表可以被删GOT 的值可以被延后填字符串表可以被加密符号地址要加载时才确定。静态解析能给出的是“结构”不是“状态”。所以真要做验证最省事的办法是交叉比对。readelf -d拿依赖readelf -sD拿动态符号readelf -r拿重定位nm -D再对一遍导出符号——四份输出和自研解析器的结果逐条对齐。差异点往往就是坑所在某一项数量对不上多半是遍历步长写错某一项名字对不上多半是字符串表偏移算错。我一般会把四份输出都归一化成(符号名, 类型, 值)三元组再做集合差比肉眼逐行看快得多。再往上走一步就该考虑换用运行时视角了。dl_iterate_phdr能枚举进程内所有已加载模块及其基址/proc/self/maps能看到实际映射的权限和范围两者结合可以把“文件里的 vaddr”换算成“运行时的真实地址”。这条路的信息量比静态解析大得多因为它反映的是加载器完成所有重定位之后的状态——GOT 已经填好字符串已经解密懒绑定也早已触发。代价是需要一个运行环境无法离线批量处理。这里有个明确的取舍静态解析胜在可以离线、可以批量、可以看到“没有被运行污染的原始意图”运行时解析胜在真实、完整、不受混淆影响。做依赖审计、体积归因、符号导出检查这类事静态足够做崩溃栈还原、热修复补丁校验、加固对抗必须上运行时。判断标准就一条你要的答案是不是“加载之后才存在”的——是就换路径别在文件格式里死磕。这个教训来自一次离线分析。当时拿着一份被裁剪过的 so 反复调试解析器dynsym读出来只有三十几个符号一度以为是字符串表偏移算错了改了三四版换算逻辑直到用readelf -d一比对才发现那个库本来就只导出了三十几个符号剩下全被链接脚本里的 version script 隐藏了。浪费了一个下午。从那以后我每次动手写解析逻辑之前都先用现成工具把目标样本的依赖、符号、重定位各导一份出来作为基线自研结果必须与基线逐项对齐之后才往下走。这一步多花五分钟能省掉后面几个小时的自我怀疑。希望这个习惯也能帮到你——那份资源里的解析脚本和文档正好可以拿来做这个基线对照的起点。本文还有配套的精品资源点击获取