第三章 ret2libc 与动态链接:PLT/GOT 表机制、泄露 libc 基址、计算 system 地址、构造 ret2libc 攻击
好,咱们进入第三章。这一章是 Pwn 入门的分水岭——ret2libc。
为什么说它是分水岭?因为之前的 ret2shellcode 太理想了。你想想看,真实环境里 NX 保护几乎是标配,栈上不能执行代码,那你的 shellcode 写得再漂亮也没用。我早年打 CTF 时就在这上面栽过跟头,以为拿到控制流就万事大吉,结果被 NX 一巴掌拍回来。
ret2libc 的思路很简单:既然栈上不能执行,那我们就用程序已有的代码。libc 里什么都有——system、execve、/bin/sh 字符串,我们只需要把它们串起来。
3.1 动态链接与 PLT/GOT 表
要理解 ret2libc,必须先搞懂动态链接机制。说白了,就是程序运行时才把函数地址绑定进来。
我刚开始学的时候,总觉得 PLT 和 GOT 很玄乎。其实没那么复杂,我画个图你就明白了。
核心概念速览
- PLT(过程链接表):一段存根代码,负责跳转到 GOT 表项或触发动态链接器解析
- GOT(全局偏移表):存放函数实际地址的数据表,初始指向 PLT 解析代码
- 延迟绑定:函数第一次调用时才解析地址,之后直接查 GOT
这张图你得刻在脑子里。攻击的核心在于:GOT 表项里存的是 libc 函数的真实地址。如果我们能读出这个地址,就能算出 libc 基址,进而算出 system 的地址。
实战技巧:我个人习惯用 readelf -r 查看可执行文件的重定位表,确认哪些函数有 GOT 表项。另外,objdump -d 看 PLT 段也很直观。
3.2 泄露 libc 基址
好,现在问题来了:怎么拿到 libc 基址?
思路其实很直接——利用程序本身的输出函数。比如 puts、printf、write,它们都会把数据打印到终端。我们只需要让程序调用 puts(puts@got),就能把 puts 在 libc 中的地址打印出来。
具体步骤是这样的:
- 覆盖返回地址,控制程序流
- 调用 puts(puts@got),泄露 GOT 表项内容
- 接收泄露的地址,计算 libc 基址
- 返回到 main 函数,进行第二次攻击
我举个例子,假设目标程序是 64 位 x86_64 架构:
# 第一步:泄露 puts 地址
payload = b'A' * offset # 填充到返回地址
payload += p64(pop_rdi_ret) # 设置 rdi = puts@got
payload += p64(puts_got) # puts@got 地址
payload += p64(puts_plt) # 调用 puts 打印
payload += p64(main_addr) # 返回 main 重新执行
# 第二步:接收并计算
puts_addr = u64(io.recv(6).ljust(8, b'\x00'))
libc_base = puts_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
bin_sh_addr = libc_base + next(libc.search(b'/bin/sh'))
踩坑提醒:我曾经在 64 位程序上忘了考虑 pop rdi; ret 这个 gadget。x86_64 传参用寄存器,rdi 就是第一个参数。没有这个 gadget,你传不了参。用 ROPgadget --binary vuln | grep "pop rdi" 找一下。
3.3 计算 system 地址与 /bin/sh 字符串
拿到 libc 基址后,剩下的就是算术题了。
| 符号 | 含义 | 计算方式 |
|---|---|---|
| libc_base | libc 加载基址 | leaked_addr - libc.symbols['func'] |
| system_addr | system 函数地址 | libc_base + libc.symbols['system'] |
| bin_sh_addr | "/bin/sh" 字符串地址 | libc_base + next(libc.search(b'/bin/sh')) |
这里有个细节:不同版本的 libc,符号偏移不一样。我建议你用 libc-database 或者 pwntools 自带的 libc 对象来查。
我的习惯做法:
- 本地调试时,用
ldd vuln看 libc 路径,然后readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system - 远程 CTF 时,泄露两个函数地址(比如 puts 和 write),用
libc-database匹配版本 - 用 pwntools 的
ELF('./libc.so.6')加载 libc,直接取符号偏移
3.4 构造 ret2libc 攻击载荷
现在万事俱备,只欠东风。我们把第二次攻击的 payload 拼起来:
# 第二次攻击:调用 system("/bin/sh")
payload2 = b'A' * offset
payload2 += p64(pop_rdi_ret) # 设置 rdi = "/bin/sh"
payload2 += p64(bin_sh_addr) # "/bin/sh" 地址
payload2 += p64(ret_gadget) # 栈对齐(64位需要)
payload2 += p64(system_addr) # 调用 system
io.sendline(payload2)
io.interactive()
等等,为什么中间插了个 ret_gadget?
这是 x86_64 的一个坑。System V ABI 要求栈 16 字节对齐,而 system 内部会调用 do_system,如果栈没对齐,就会 crash。加一个多余的 ret 指令,相当于把栈指针再推 8 字节,对齐就解决了。我当年第一次写 ret2libc 时没注意这个,调试了一下午才发现是栈对齐的问题。
快速检查栈对齐:在 gdb 里用 stack 命令看 rsp 的值,如果 rsp % 16 == 0 就是对齐的。或者直接用 gdb-peda 的 checksec 和 pattern 功能。
3.5 完整攻击流程总结
来,我们把整个流程串一遍:
- 信息收集:确定 offset、PLT/GOT 地址、找 pop rdi gadget
- 第一次攻击:泄露 puts@got,得到 libc 基址
- 计算地址:算出 system 和 /bin/sh 的地址
- 第二次攻击:调用 system("/bin/sh"),拿到 shell
说起来就四步,但每一步都有坑。我建议你动手写一遍,用 pwntools 的 gdb.attach() 调试,单步跟踪看看 GOT 表项的变化。亲眼看到 GOT 从指向 PLT 变成指向 libc,那种感觉比看十遍文档都管用。
常见错误:
- 忘记处理输出中的换行符,导致 u64 解析出错
- libc 版本不匹配,算出来的地址不对
- 64 位程序忘记栈对齐
- 第二次攻击没有返回到 main,导致程序退出