在Linux系统中,动态链接是程序运行的重要组成部分。而过程链接表(PLT)和全局偏移表(GOT)则是实现动态链接的关键结构。本文将深入探讨PLT与GOT表的工作原理,以及它们在动态库调用中的作用,同时还会涉及相关的安全风险与防护措施。
动态库调用流程解析
非首次调用(常规流程)
当程序并非首次调用动态库接口时,调用流程相对简单。以printf函数为例,其调用过程如下:
- 跳转到PLT表:代码中对
printf的调用会被编译为printf@plt形式,即先跳转到PLT表中对应的条目。 - PLT表结构:PLT表是一个由汇编代码组成的数组,除索引0外,每个条目对应一个外部符号的调用。假设
printf对应PLT表索引为n。 - 跳转到GOT表:PLT[n]的第一条指令是跳转到GOT表中对应的条目(假设对应GOT表索引为m)。
- 执行函数:此时GOT[m]已存储实际函数地址,直接跳转到动态库中的
printf函数执行。 - 返回调用处:函数执行完成后返回原调用处,完成一次调用。
首次调用(延迟绑定机制)
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
地址解析过程
- 首次调用时:GOT[m]指向PLT[n]的第二条指令(
push n)。 - 执行
push n:将PLT索引压栈,再跳转到PLT[0]。 - 跳转到
_dl_runtime_resolve:PLT[0]将GOT[1](link_map结构地址)压栈,然后跳转到GOT[2]指向的_dl_runtime_resolve函数。 - 符号解析:
_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地址)。
- 地址绑定:将解析得到的实际地址写入GOT[m],完成地址绑定。
- 执行实际函数:执行实际函数并返回,后续调用将直接使用GOT[m]中的地址(即常规流程)。
安全风险与防护
PLT/GOT机制虽然高效,但也存在潜在的安全风险。攻击者可以通过修改GOT表中的地址,将函数调用重定向到恶意代码,执行完成后再跳转回原流程,从而篡改程序行为。这种攻击方式在恶意软件和黑客攻击中较为常见。
针对此类风险,常用的防护手段是“加壳”。加壳工具可以对程序进行保护,增加逆向分析和篡改的难度。例如,Virbox Protector是一款非常优秀的加壳工具,它支持多种文件类型,能够有效提升程序的安全性。通过使用Virbox Protector,开发者可以为程序添加多层保护,防止恶意篡改和逆向工程。
总结
PLT与GOT表是Linux系统中动态库调用的核心机制,它们通过巧妙的设计实现了高效的符号解析和函数调用。然而,这种机制也带来了安全风险。通过使用像Virbox Protector这样的加壳工具,开发者可以有效保护程序的安全性,防止恶意篡改和逆向工程。希望本文能够帮助你更好地理解PLT与GOT表的工作原理,以及如何保护你的程序免受安全威胁。