ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

解析ELF .so:加载器为何只认Program Header

解析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 才有地方挂。理解了它,你才能解释为什么一个被加固工具处理过的 so,readelf -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: 183=EM_AARCH64, 62=EM_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_CLASS(1=32 位,2=64 位),e_ident[5]是EI_DATA(1=小端,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; // 1=PT_LOAD, 2=PT_DYNAMIC, 4=PT_NOTE, ... Elf64_Word p_flags; // 4=R, 2=W, 1=X 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_align:ABI 要求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(f"vaddr 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为 0(DT_NULL)时结束:

typedef struct { Elf64_Sxword d_tag; // 标签,如 DT_NEEDED=1, DT_STRTAB=5 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 hash:O(n) 和 O(1) 的取舍

符号查找的性能全压在哈希表上。ELF 支持两套:老的 SysV hash(DT_HASH)和 GNU hash(DT_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 == 0(SHN_UNDEF)表示这个符号在本库中没有定义,要靠外部提供,这正是导出符号和导入符号的分界线。st_info拆开看,bind 为 1 是GLOBAL,2 是WEAK;type 为 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)。

当一个符号带版本时,它的真实名字在链接器视角下是foo@VERS_1.2这样的形式。加载器在解析时先按无版本名找到候选,再比对版本需求,不匹配就继续往下找。这解释了为什么有些符号明明在dynsym里存在,运行时却还是报 “undefined symbol”——版本对不上,等同于不存在。

st_other的低两位是可见性:STV_DEFAULT(0)允许被外部覆盖(symbol preemption),STV_HIDDEN(2)不出现在动态符号表里,STV_PROTECTED(3)导出但拒绝被覆盖。PROTECTED是个微妙的设计——它让编译器可以放心地把库内对该符号的调用直接绑到本地地址,省掉一次 GOT 间接跳转,代价是牺牲了 interposition 能力。想用 LD_PRELOAD 替换一个PROTECTED符号,是换不掉的。

4. 重定位与 GOT/PLT:so 里的地址是在什么时候被填上的

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 >> 32,type = r_info & 0xffffffff。注意 32 位 ELF 的拆法是反的(sym = r_info >> 8,type = 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_*_RELATIVE,DT_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 = 1026;x86_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_NOW:GOT 里的值在静态分析时为什么是 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,配合 RELRO(PT_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 隐藏了。浪费了一个下午。

从那以后,我每次动手写解析逻辑之前,都先用现成工具把目标样本的依赖、符号、重定位各导一份出来作为基线,自研结果必须与基线逐项对齐之后才往下走。这一步多花五分钟,能省掉后面几个小时的自我怀疑。希望这个习惯也能帮到你——那份资源里的解析脚本和文档,正好可以拿来做这个基线对照的起点。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进