引言
在 Linux 系统中,可执行程序对动态库接口的调用机制设计十分巧妙,其核心依赖于过程链接表(PLT)和全局偏移表(GOT)。本文将从 ELF 文件层面,深入解析这两个表的工作原理及动态库调用流程。
动态库调用流程解析
非首次调用(常规流程)
当程序并非首次调用动态库接口时,调用流程如下:
1. 代码中对`printf`的调用会被编译为`printf@plt`形式,即先跳转到 PLT 表中对应的条目
2. PLT 表是一个由汇编代码组成的数组,除索引 0 外,每个条目对应一个外部符号的调用(假设`printf`对应 PLT 表索引为 n)
3. PLT [n] 的第一条指令是跳转到 GOT 表中对应的条目(假设对应 GOT 表索引为 m)
4. 此时 GOT [m] 已存储实际函数地址,直接跳转到动态库中的`printf`函数执行
5. 函数执行完成后返回原调用处,完成一次调用
首次调用(延迟绑定机制)
Linux 采用延迟绑定(Lazy Binding)机制,首次调用时 GOT 表中尚未填充实际函数地址,流程如下:
PLT 条目的结构
以`printf`对应的 PLT [n] 为例,其汇编结构如下(伪代码):
printf@plt:
jmp *GOT[m] ; 跳转到GOT表第m项(首次调用时指向本条目的下一条指令)
push n ; 将PLT索引n压栈作为参数
jmp PLT[0] ; 跳转到PLT[0]的桩代码
PLT [0] 桩代码结构
PLT 表索引 0 的特殊桩代码结构如下(伪代码):
PLT0:
pushq [GOT+8] ; 将GOT[1]中的link_map结构地址压栈
jmp [GOT+16] ; 跳转到GOT[2]指向的_dl_runtime_resolve函数
nop ; 内存对齐填充
nop
地址解析过程
1. 首次调用时,GOT [m] 指向 PLT [n] 的第二条指令(`push n`)
2. 执行`push n`将 PLT 索引压栈,再跳转到 PLT [0]
3. PLT [0] 将 GOT [1](link_map 结构地址)压栈,然后跳转到 GOT [2] 指向的`_dl_runtime_resolve`函数
4. _dl_runtime_resolve
函数通过两个参数(link_map 指针和 PLT 索引 n)执行符号解析:
- 根据 PLT 索引 n 找到.rela.plt 重定位节中的对应条目(Elf64_Rela 结构)
- 通过条目中的 r_info 字段定位.dynsym 动态符号表中的符号信息(Elf64_sym 结构)
- 利用符号信息中的 st_name 字段从.dynstr 动态字符串表获取符号名(如 "printf")
- 遍历 link_map 记录的已加载库,查找符号对应的实际地址(如 libc.so 中的 printf 地址)
5. 将解析得到的实际地址写入 GOT [m],完成地址绑定
6. 执行实际函数并返回,后续调用将直接使用 GOT [m] 中的地址(即常规流程)
安全风险与防护
ELF 文件的 PLT/GOT 机制存在潜在安全风险:攻击者可通过修改 GOT 表中的地址,将函数调用重定向到恶意代码,执行完成后再跳转回原流程,从而篡改程序行为。
针对此类风险,常用的防护手段是 "加壳":通过加壳工具对程序进行保护,增加逆向分析和篡改的难度。例如 Virbox Protector 等工具支持多种文件类型,能有效提升程序的安全性。