打CTF打到PWN题,是很多新选手的第一道坎,也是真正让人上瘾的起点。PWN这个词来自游戏里的“掌控”,在CTF里特指漏洞利用:给你一个编译好的二进制程序,你得找到它的漏洞,写一段攻击脚本,拿到服务器上那个flag。它不像Web题那样靠业务逻辑闪转腾挪,也不像逆向题那样只求看懂代码,PWN的核心是把内存布局、汇编指令、系统调用这些底层知识落在一行行exp里,让程序乖乖执行你想让它做的事。整个过程横跨逆向分析、动态调试、脚本编写三件事,踩坑多、正反馈也强。
这篇文章想把这套解题流程完整拆一遍,从刚拿到题目的第一步信息收集,到漏洞定位、利用构思、exp落地,再到常见的坑和调试习惯,适合刚接触PWN、刷了几道题还摸不清套路的选手看。内容里所有命令和脚本都是我在日常打题、出题、带新人的过程中反复用过的,可以直接照着操作。
1. 拿到题目先别上头,信息收集决定成败
很多人拿到PWN题的第一反应是赶紧拖进IDA里看伪代码,结果被一堆结构体和函数指针绕晕。我的习惯恰恰相反——先用一条命令把程序底细摸清楚,再考虑要不要上逆向来分析。
1.1 checksec:五秒看清保护机制
在Linux环境下,PWN题最常见的检查工具就是pwntools自带的checksec。进入题目文件夹后,一行命令就能看到所有关键保护:
checksec ./pwn输出大致长这样:
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)每一行都对应一种攻击路径的可行性:
- Arch:程序是32位还是64位,这直接决定偏移计算、参数传递方式。i386和amd64的利用细节差别很大,后面写exp时不能混。
- RELRO:Partial还是Full,影响能不能改GOT表。Partial RELRO常见于入门题,GOT表可写,ret2plt、GOT劫持都方便。Full RELRO则要另想办法,比如打栈、打hook。
- Stack Canary:没有canary,栈溢出就是摆在明面上的口子;有canary,就得先泄露canary值再覆盖。泄露canary本身又是另一套流程。
- NX:NX禁用意味着栈不可执行,ret2shellcode这种经典打法直接出局,得转向ROP。
- PIE:没有PIE说明程序加载地址固定,
.text段地址可以从IDA里直接抄;开了PIE所有地址都是相对的,需要先泄露一个libc或程序基址。
这些保护不是摆设,它们互相组合,基本规定了你能走的利用路线。我看checksec的时间通常不超过十秒,看完心里已经有了一到两套候选方案。
1.2 file、readelf与strings:摸清文件底细
除了checksec,还有几个命令在信息收集阶段值得养成习惯:
file ./pwn readelf -h ./pwn # 看ELF头,确认架构和入口 readelf -s ./pwn # 看符号表,有时直接暴露system、gets等敏感函数 strings ./pwn | head -50strings特别适合入门题,经常能看到/bin/sh、flag.txt、You are a hacker这类字符串,这些往往是后门函数或特定路径的线索。如果strings里直接出现了/bin/sh,基本可以断定题目准备了system或execve调用,后面找找引用位置就能锁定后门地址。
readelf -s配合objdump -d还能快速判断程序是否去符号。出题人如果不加-s编译,符号表会保留main、vuln、win这些函数名,逆向省一半力气。如果全被strip掉了,才需要靠入口地址和交叉引用慢慢还原结构。
1.3 先跑一遍,观察程序行为
静态信息看得差不多了,务必本地跑一次程序:
./pwn观察几个点:
- 程序是交互式输入还是读取文件内容。
- 输入有没有长度限制,收到的提示信息长什么样。
- 有没有明显的菜单、循环、回显,这些都会影响exp的交互方式。
我习惯用一个固定流程测试:先随便输入一串普通字符串,比如AAAA,看程序崩不崩;再用长字符串触发一次溢出,看会报什么错。
python3 -c "print('A'*200)" | ./pwn如果出现Segmentation fault或stack smashing detected,说明溢出点就在这条路径上。配合GDB或者gdb插件(比如peda、pwndbg),能直接看到崩溃时的返回地址被覆盖成了什么。这个“溢出点定位”是整个漏洞利用的地基,后面所有操作都从这里展开。
2. 漏洞定位:静态分析打底,动态调试补刀
信息收集做完,就该进到最核心的一步:找到漏洞并确认触达路径。我的流程是先用静态分析把程序逻辑理清楚,再用动态调试验证细节,两边交叉确认。
2.1 从IDA/Ghidra反编译看主逻辑
主流选择是IDA Pro或者免费的Ghidra。IDA的F5反编译对新手极其友好,但很多人一上来就F5看全貌,反而被各种变量名和结构体劝退。我的建议是分三步走:
第一步,看main函数和所有被调用函数的名字。如果符号表还在,光看名字就能圈出重点,比如vuln、vulnerable、gets、read、system。
第二步,追踪用户的输入数据流。从read、gets、scanf这些输入函数往下看,数据被存到了哪个缓冲区,缓冲区多大,有没有边界检查,后续有没有被拷贝、拼接、格式化输出。PWN题的漏洞绝大多数都藏在“输入数据怎么进、怎么被用”这条链路上。
第三步,看危险函数。gets、strcpy、sprintf、strcat、scanf这些老面孔都是高危点。看到gets基本等于题目在告诉你“这里有栈溢出”。如果看到printf且参数直接来自用户输入,就要小心格式化字符串漏洞。
举个例子,常见到不能再常见的一段代码:
#include <stdio.h> #include <string.h> void win() { system("/bin/sh"); } void vuln() { char buf[64]; gets(buf); } int main() { vuln(); return 0; }用gcc -m32 -no-pie -fno-stack-protector -z execstack -o vuln vuln.c编译出来,就是标准的入门栈溢出题。vuln里buf只有64字节,gets却无限读入,溢出点一目了然。静态分析到这里,思路基本已经成型:把返回地址改成win函数的地址,程序就会执行system("/bin/sh")。
2.2 一眼认出的高发漏洞模式
PWN高频漏洞就那么几类,做题多了几乎形成条件反射:
- 栈溢出:
gets、strcpy、read配合过大的长度。判断标准是缓冲区大小和输入长度限制的差额,外加有没有canary。 - 格式化字符串:
printf(user_input),可以利用%x泄露栈内容,用%n写任意地址。判断标准是printf的格式化字符串参数是否完全可控。 - 整数溢出:变量类型是
int却用unsigned int存长度,或者长度字段参与减法和比较时被截断。常见于带菜单功能的堆题和栈题。 - 堆溢出:
malloc后写入超长数据,可以改chunk头、伪造chunk,进而打free的GOT或__malloc_hook。这类通常放在进阶篇,入门先把栈溢出和格式化字符串吃透。 - UAF(Use After Free):释放后的堆指针没有置NULL,还能继续被使用。堆题重灾区。
我个人的经验是:不要一上来背所有漏洞类型,先把手头的栈溢出和格式化字符串玩到滚瓜烂熟,再碰堆。因为这两类漏洞原理清晰、工具链成熟、调试方法也相对直观,是用来建立“漏洞利用手感”最好的素材。
2.3 GDB怎么用得既快又稳
光靠静态分析,很多细节是看不到的,必须用GDB确认。我强烈建议装一个gdb插件,pwndbg或peda都行,我自己用的是pwndbg,它的checksec、cyclic、heap相关命令在打PWN时效率提升很大。
常用的调试操作不是很多,关键就几个:
gdb ./pwn b *0x4006a2 # 在目标地址下断点 r # 运行 # 输入触发溢出的payload x/20wx $rsp # 查看栈上的20个字 i r # 查看寄存器 c # 继续执行调试的核心目的是回答三个问题:
- 输入的数据在哪?看寄存器指向的地址和对应内存内容。
- 返回地址在哪?看栈上哪个位置会被溢出数据覆盖。
- 执行流能不能改到目标函数?改完返回地址后,单步走,看程序是否跳转成功。
一个高效的做法是先用pwntools的cyclic生成一串定制的模式字符串,撞出崩溃偏移。比如:
from pwn import * # 生成200字节的pattern pattern = cyclic(200)把pattern输入程序,崩溃后记下EIP/RIP的值,然后用cyclic_find反推偏移:
offset = cyclic_find(0x6161616c) print(offset)这个偏移就是覆盖返回地址之前需要的填充长度,省去手数字节的麻烦。
3. 利用构思到exp落地
漏洞找到了,偏移算好了,接下来就是把想法写成exp的过程。这部分是PWN最见功力的一环,也是网上教程最容易漏掉细节的地方。
3.1 保护机制如何决定你的攻击姿势
我习惯在写exp前,先把checksec的结果和漏洞类型对应一遍,形成一个checklist:
| 保护情况 | 常用打法 |
|---|---|
| 无canary、无PIE、NX disabled | ret2shellcode,栈上直接放shellcode,跳转过去 |
| 无canary、无PIE、NX enabled | ret2text,回到程序内的后门函数,或ROP调用system |
| 无canary、PIE enabled | 先泄露程序基址,再ret2text/ROP |
| 有canary | 想办法泄露canary,或利用格式化字符串/栈信息回显leak |
| Full RELRO | 不能改GOT,优先考虑劫持__free_hook、__malloc_hook或打栈 |
| Partial RELRO | 可以考虑覆盖GOT表项,比如把某个函数改成system |
这个表不是死的,但它能防止你走错方向。比如NX开启的题,你还在费劲往栈上塞shellcode,那就白干了。
拿前面那个vuln程序来说,假设checksec显示NX enabled但No PIE,那最稳妥的思路就是ret2text,直接返回win函数。如果题目没给后门函数,就需要构造ROP链调用system("/bin/sh"),这就要找libc地址,流程会复杂一些,但基础逻辑是一样的。
3.2 pwntools这些API足够撑起八成题目
pwntools是PWN选手最核心的武器库,新手不需要学会所有功能,先掌握下面这些就够了:
from pwn import * # 设置目标架构和日志级别 context.arch = 'i386' context.log_level = 'debug' # 启动本地进程/连接远程 p = process('./vuln') p = remote('127.0.0.1', 10001) # 发送和接收 p.sendline(payload) p.recvuntil(b'Input:') p.recvline() p.send(b'data') # 处理地址 win_addr = 0x08048456 payload = b'A' * offset + p32(win_addr) # 32位小端用p32 payload = b'A' * offset + p64(win_addr) # 64位小端用p64 # 交互获得shell p.interactive()这些接口在八成题目里都够用。如果开了system后需要传参数,64位程序还要用ROPgadget找pop rdi; ret这样的gadget:
ROPgadget --binary ./pwn --only "pop|ret"拿到gadget地址后,64位的system调用payload长这样:
pop_rdi = 0x401173 bin_sh = 0x402004 # .rodata里的/bin/sh地址 system_addr = 0x401040 payload = b'A' * offset payload += p64(pop_rdi) # 第一个参数进rdi payload += p64(bin_sh) # rdi = "/bin/sh" payload += p64(system_addr) # 跳到system这行东西看着简单,背后是System V AMD64调用约定:前六个参数依次放到rdi、rsi、rdx、rcx、r8、r9,然后call目标函数。所以想调用system,就得先把/bin/sh字符串地址放进rdi。
这里要提一个新手常见的误解:32位程序传参靠栈,64位程序传参靠寄存器。如果不小心把32位的传参方式用在64位上,漏洞就算找对了也利用不成功。
3.3 入门必会:ret2text与ret2libc的完整过程
我用一个完整示例把ret2text全流程过一遍,假设题目就是前面那个vuln.c:
先确认保护:
checksec ./vuln看到No PIE、No canary、NX enabled,思路确定为ret2text。用IDA加载,找到win函数的地址,假设是0x08048456。然后算出偏移:buf是64字节,32位下返回地址前还有4字节的saved ebp,所以填充长度是64 + 4 = 68字节。再用cyclic_find验证一遍,确保没有手滑。
最终exp:
from pwn import * context.arch = 'i386' context.log_level = 'info' p = process('./vuln') win_addr = 0x08048456 offset = 68 payload = b'A' * offset + p32(win_addr) p.sendline(payload) p.interactive()本地跑出shell后,把process('./vuln')换成remote('目标地址', 端口),就是完整的线上解题流程。
如果题目没有后门函数,就得走ret2libc。核心思路是:从程序里先调用一次puts@plt或printf@plt把某个GOT表项的实际地址打出来,然后再根据libc偏移算出system和/bin/sh的真实地址。这里涉及libc版本识别,可以用LibcSearcher或libc-database,也可以直接查本地libc的偏移:
readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system strings -a /lib/x86_64-linux-gnu/libc.so.6 | grep "/bin/sh"两边算出的偏移差值就是相对基址的偏移,leak出某个函数地址后,减去它在libc里的偏移,就得到libc基址,再加回system的偏移,就得到system的真实地址。这串计算逻辑第一次跑通后,后面遇到类似题目基本就是套模板。
4. 实战通用问题与排查实录
代码写出来容易,跑通是另一回事。PWN题的调试过程其实占了大半时间,很多问题来来回回就那么几个原因。
4.1 偏移量算错的经典场景
新手撞到Segmentation fault,第一个要怀疑的就是偏移错了。常见情况有三种:
一是平台位数搞混。32位程序偏移要加4字节的saved ebp,64位要加8字节,但有PIE时返回地址之前可能还有别的栈变量,不能想当然。
二是缓冲区大小没看清。IDA伪代码里char buf[64]不一定是栈上真正分配的大小,有时编译器会做栈对齐,实际偏移比64+8大一点。这时候不要手算,直接用cyclic测试最可靠。
三是多了PIE。PIE开启时,所有地址都是运行时动态变化的,你在IDA里看到的0x0x1234必须加上程序基址才是真实地址。很多题目开了PIE后还要先leak,不要拿到一个地址就硬写进payload。
排查偏移问题的标准姿势是:用cyclic覆盖,崩溃后用cyclic_find算偏移,别靠目测。
4.2 本地通了远程挂
这是最让人崩溃的一类问题:exp本地跑得好好的,一连远程就挂。常见原因按频率排:
- libc版本不一致:本地的libc和远程服务器的libc可能差好几个版本,
system、/bin/sh的偏移完全不同。应对方法是先leak几个GOT地址,再用libc-database识别远程版本。 - 交互缓冲不一样:本地
process的recv时机和远程网络延迟不同,recvuntil写死了某个字符串,远程可能没打出那个字,导致脚本卡死。这时要把recvuntil的内容改宽容,或者用recvline配合循环。 - 环境变量和CWD影响:本地目录里有flag文件,远程却在另一套文件系统里,
/bin/sh能执行但找不到flag。这类问题要用ls、cat flag一步步确认。 - 地址字节序问题:
p64和p32用反了,地址直接变乱码,这在写exp时非常容易手滑。
我在处理这类问题时,会先在脚本顶部加一行context.log_level = 'debug',把收发内容都打出来,拿远程返回的内容和本地逐字比对。这个方法比瞎猜快得多。
4.3 脚本调试习惯
最后分享几个我长期养成的调试习惯,对提升做题效率帮助很大:
第一,把exp拆成函数。leak函数、exploit函数、main入口分开写,不同题目之间能复用的部分直接拷贝,改起来也方便。
第二,善用gdb.attach。在exp里可以这样:
p = process('./vuln') gdb.attach(p, ''' b *0x08048456 continue ''')这样能在发送payload前把GDB挂上去,精确看到内存状态。调栈溢出、ROP、堆题时几乎是必备操作。
第三,记录每一次崩溃的地址。崩溃地址会告诉你“覆盖到了什么位置”,结合反汇编看,很快就能反推出栈布局。
第四,代码里尽量加上注释。CTF比赛时间紧,一道题过后容易忘记当时的思路,注释能让你赛后复盘和复现时省很多事。
我在实际做题中的体会是:PWN题的核心不是会某个奇技淫巧,而是把“分析程序、定位漏洞、构造利用、调试排错”这条链路跑得丝滑。初学阶段宁可慢一点,每一步都搞清楚为什么,也别急着追题量。栈溢出和格式化字符串这两类基础题如果能顺手写出稳定的exp,后面学堆利用、内核利用时,思路就不会乱。
最后再分享一个小技巧。如果你总是卡在“知道有漏洞但不会利用”,可以先去看同类型题目的writeup,重点不是抄脚本,而是看作者是怎么从checksec一步步推导到exp的。把推导过程在自己本地复现一遍,再换一道题目自己独立做,比单纯刷十道题都有效。PWN这东西,熟练度完全建立在动手次数上。