CTF里的Pwn方向,越打到后面越会发现一个事实:思路决定上限,但工具链决定下限。很多新手在比赛里不是没有想法,而是被“工具不好用”卡死在半路——ROPgadget跑出来的地址不对、one_gadget打远程全断、gdb里断点下错了位置,Libc版本查不到,最后把本该拿下的题目白白交出去。这篇文章围绕CTF比赛中的Pwn工具链整理一份可以直接照做的清单,从环境准备、静态分析、动态调试到利用脚本生成,把高频工具和它们真正适合的场景完整过一遍。目标读者是刚入门Pwn方向、或者已经入门但总在比赛中被环境问题拖后腿的选手。全篇没有花哨的高深概念,只有我这两年比赛里反复验证过的最顺手的一套餐具。看完你至少能照着一套固定流程,把一道栈题从拿到手到打通的每一步走顺。
1. 先聊聊我手里这份工具清单是怎么来的
1.1 一道栈题暴露的完整链路
前阵子打一场线上赛,有一道特别标准的64位ret2libc题。题目很常见:read读入,栈溢出,没有后门函数,没有输出函数。很多选手很快判断出需要leak libc地址,但接下来就有点乱——有人用ROPgadget找半天找不到合适的pop rdi; ret,有人拿了地址却不知道用哪个libc算偏移,还有人把exp写好了本地通远程挂。
我看了一眼,用了大约二十五分钟把这道题拿下。不是因为逆向比别人快,而是整个工具链闭着眼都能跑完:
- 先用
file确认是64位动态链接的x86_64程序; checksec看保护,确认只开了NX,没开Canary,没开PIE;- 用IDA打开,找到漏洞点:
read在栈上写0x100个字节,但buf只有0x20; - 用
ROPgadget --binary ./pwn --only 'pop|ret'找到0x40123a的pop rdi; ret; - 先leak出
puts@got地址,再用LibcSearcher匹配到题目给的libc版本; - 算出
system和/bin/sh的实际地址; - 在本地用pwntools一套
process('./pwn')打通,然后改remote()打远程,收flag。
整个过程里涉及的工具并不算多:系统自带的file、pwntools内置的checksec、IDA、ROPgadget、LibcSearcher、one_gadget、gdb加pwndbg。但它们串起来以后,覆盖了信息收集、逆向分析、漏洞利用、动态调试和远程拿flag五个环节。
我习惯把这套流程叫做“Pwn工具链闭环”。如果闭环里的任何一环出了问题,一道本来三十五分钟能做完的题就会变成三个小时的折磨。所以这篇文章的第一个建议就是:先别急着收藏各种花里胡哨的工具,把这套闭环里的核心工具用好,就能覆盖七成以上比赛题。
以下表格是我个人最常用的工具组合:
| 环节 | 工具 | 用途 | 常见替代品 |
|---|---|---|---|
| 信息收集 | file, checksec, readelf, objdump | 确认架构、保护、动态链接情况 | pwntools内置checksec |
| 逆向分析 | IDA Pro / Ghidra | 定位漏洞函数,查看伪代码 | radare2 |
| 漏洞利用 | pwntools | 连接、交互、payload构造、格式化字符串 | 自己手写的半成品库 |
| ROP构造 | ROPgadget / ropper | 查找gadget | pwntools自带ROP类 |
| libc识别 | LibcSearcher / libc.rip | 根据泄露地址匹配libc版本 | 本地保存的libc数据库 |
| 一键利用 | one_gadget | 快速查找execve("/bin/sh")地址 | 手动execve系统调用 |
| 动态调试 | gdb + pwndbg / GEF | 看寄存器、内存、堆结构、断点调试 | gdb裸用 |
1.2 选型原则:工具是给解题服务的,不是用来收藏的
我见过不少选手电脑里装了十几个工具,每个都是挖洞神器,但实际比赛时反而不知道用哪个。比如调gdb时同时装了pwndbg和GEF,栈信息展示冲突,一个命令输出两套东西,直接干扰判断。
工具链的选型原则很简单:每个环节只固定一个工具,把它用到形成肌肉记忆。不要今天试试这个,明天换那个。工具之间切换是有时间成本的,比赛里每一分钟都很贵。
我在本地长期保持的是一套“极简核心”组合:pwntools、gdb+pwndbg、IDA、ROPgadget、one_gadget、LibcSearcher。辅助工具只有patchelf和pwninit,用来处理题目给的libc与本地ld不一致的情况。这套组合从打栈题到打堆题,从32位到64位,从本地验证到远程连接,基本没有让我在比赛过程中因为工具本身翻车过。
对于刚入门的朋友,我的建议是不要碰太多“一键利用”工具。比如有些工具能自动生成ret2dlresolve的payload,但当你payload打不出去时,因为你不知道它内部做了什么,排错会非常痛苦。先把基础原理弄清,再用自动化工具提速,这个顺序不能反过来。
2. 从零开始搭建一套能出战的Pwn环境
2.1 系统与工具链的基线版本
Pwn方向对操作系统其实要求不算高,但环境的一致性很重要。我推荐用Ubuntu 22.04或者20.04的64位系统,原因很简单:这两个版本的glibc分别是2.35和2.31,CTF题目里大量libc基于这两个版本。你要是在Debian 12或者更新的发行版上,默认glibc可能到2.36以上,距离常见题目环境偏差更大,调试时经常出现本地能打远程不能打的情况。
Python环境建议用Python 3.10左右,不要用最新的3.12甚至3.13。虽然pwntools通常都能装,但有些依赖(例如pwntools的特定分支)可能还没跟上新版本。我踩过一次坑,在新版本Python上用pip install pwntools之后,fmtstr_payload的输出结果和旧版本不一致,排查了很久才发现是Python版本差异导致的结构体对齐行为不同。所以现在我在本地用一个独立的virtualenv,锁住Python和pwntools版本。
如果你喜欢用虚拟机,建议装好环境之后做个快照。比赛前如果系统被自己折腾坏了,恢复快照比重新装环境快得多。使用Docker跑题目的选手也一样,容器环境做好之后提交到本地镜像仓库,比赛时直接docker run,避免现场现拉镜像或者重装依赖。
2.2 核心工具安装命令:一条龙照抄
下面是完整安装命令。我在全新Ubuntu 22.04上实测过,照着执行基本能一步到位:
sudo apt update sudo apt install -y gdb python3 python3-pip git patchelf build-essential pip install pwntools git clone --depth 1 https://github.com/pwndbg/pwndbg cd pwndbg && ./setup.sh gem install one_gadget pip install ROPgadget pip install ropper各工具的具体分工:
- gdb:动态调试的基础调试器,配合pwndbg插件后能直观查看栈、堆、寄存器、进程映射。
- pwntools:Pwn题的核心框架,用来连接本地/远程进程、发送payload、解析地址、构造格式化字符串payload。
- pwndbg:gdb插件,显示反汇编、栈回溯、heap bins信息、GOT/PLT表,调试堆题时尤其有用。
- one_gadget:在给定libc中搜索可直接执行的
execve("/bin/sh", NULL, NULL)地址。栈题、堆题都能用,省去手动构造ROP链。 - ROPgadget:在二进制或libc中搜索可用的gadget,比如
pop rdi; ret、pop rsi; ret这类经典片段。 - ropper:和ROPgadget功能类似,某些场景下搜索速度更快,我一般作为备用。
装好之后建议顺手跑一下pwndbg自检,确认插件和当前gdb版本兼容。注意不要在同一个系统里同时启用pwndbg和GEF,两个插件会同时往gdb里注入启动文件,导致调试输出混乱。如果真想换,就在~/.gdbinit里切换到对应插件。
2.3 给题目快速“贴标签”和patch本地环境
比赛时经常遇到这样的情况:题目附件除了binary之外,还给了一个libc.so.6和一个ld-2.31.so。如果你直接在默认系统环境下跑process('./pwn'),本地进程用的是系统自带的glibc,而不是题目给的那份。这会导致你本地泄露的地址偏移和远程完全对不上——最典型的翻车点。
实际处理时我非常依赖patchelf和一个小工具pwninit。pwninit会根据当前目录下的libc.so.6和ld-2.31.so自动生成修正后的启动方式,内部其实就是调用了patchelf --set-interpreter和--replace-needed这两个核心参数:
patchelf --set-interpreter ./ld-2.31.so ./pwn patchelf --replace-needed libc.so.6 ./libc.so.6 ./pwn这样处理过之后的二进制文件,在本地启动时就会加载题目指定的ld和libc,调试时的偏移才和远程服务器一致。这个操作几乎是每道带libc题目必做的。比赛前可以多练习几遍,不要等到赛场上才现学。
除了patch启动器,我还建议在本地维护一个libs/目录,把常见libc版本(2.23、2.27、2.31、2.35等)都存下来。配一个简单的shell别名或Python脚本,每次拿到新libc先ls一下,如果本地有相同版本就能直接用,省去联网查询Libc的时间——比赛时这个时间差可能决定你能不能冲进前十。
3. 拿到题目后的前三十分钟,我到底在干什么
3.1 用file和checksec读题目“体检报告”
很多人拿到Pwn题就直接拖进IDA,这其实是低效的。正确的做法是先花两分钟做体检。
file pwn的输出能告诉你架构(x86_64还是i386)、位数(32位还是64位)、动态链接还是静态链接、有没有strip。这个信息决定了后续所有payload的数据宽度和汇编写法。你要是拿32位思维去写64位payload,地址截断问题会让你debug到崩溃。
checksec是检查安全机制的必备命令。pwntools自带这个命令,也可以直接执行checksec --file=./pwn。我主要看四项:
- RELRO:如果GOT表只读,就不能通过覆盖GOT来劫持流程;如果叫Partial RELRO,hijack GOT依然是可行方案。
- Canary:检测栈保护。开Canary意味着你需要先leak或者爆破canary,不能直接拿栈溢出打。
- NX:栈不可执行。开了NX就不能在栈上放shellcode直接跳过去。
- PIE:地址随机化。PIE开启时程序基址未知,需要先leak一个地址再计算实际偏移;没开PIE可以直接用固定地址。
我比赛时习惯把这些信息直接记在草稿纸(或者一个notes.md里),形成“体检结论”:比如“64位,NX开,PIE开,Canary开,Partial RELRO”,然后对应地套用不同的利用模板。这不光省时间,还能避免写到一半才发现某个保护没有绕过。
3.2 静态分析:IDA/Ghidra里的定位习惯
拿到体检结果后进入逆向分析。我用IDA比较多,F5找到main函数,然后顺着函数调用关系看哪些函数里存在不安全的操作。Pwn题的高频漏洞点非常固定,看到gets、read、scanf("%s")、strcpy、sprintf时,就要想到栈溢出;看到printf或fprintf第二个参数是自定义缓冲区时要想到格式化字符串;看到malloc/free成对出现、但循环不检查当前堆块大小,就要小心堆溢出。
Ghidra的伪代码理解和IDA差不多,胜在免费且更新快。不管用哪个,一条关键经验是:不要只看F5出来的伪代码,还要切回汇编界面看栈布局。伪代码里的read(0, buf, 0x100)不会告诉你buf和返回地址之间差多少字节,但汇编里sub rsp, 0x30能直接告诉你偏移。这个偏移量才是构造栈溢出payload的关键。
更进阶的做法是结合字符串和交叉引用。shift+F12打开字符串列表,看到/bin/sh、cat flag、flag这些字符串后,直接交叉引用回到调用它的函数,往往会快速定位到核心漏洞逻辑或后门函数。
3.3 动态验证:拿到偏移再动手,不要靠猜
在静态分析中判断出漏洞之后,我一般不会立刻去写完整exp,而是先验证栈布局和输入点。
新手最常犯的错是看到一个read就直接往栈上塞0x100个A,然后观察程序崩不崩。这确实能验证溢出,但没法精确算出需要多少字节覆盖到返回地址。正确姿势是使用pwntools的cyclic:
gdb-pwndbg$ cyclic 200 aaaabaaacaaadaaaeaaafaaagaaahaaaiiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaaza...把一段pattern发过去,程序崩溃后,cyclic命令会根据崩溃时的RSP/RIP值反推出溢出偏移。pwndbg里直接用cyclic -l 0x6161616e也能做同样的事:
gdb-pwndbg$ cyclic -l 0x6161616e Offset: 40得到偏移是40字节,那就意味着填充40个字符后,下8个字节正好覆盖返回地址。这一步确认之后再进入利用阶段,思路会清楚很多。
3.4 常见漏洞类型和工具速查表
为了方便赛事中快速定位,我把常见Pwn题型和推荐工具、模板整理成一张表:
| 漏洞类型 | 常见函数/模式 | 利用路线 | 最常使用的工具 |
|---|---|---|---|
| 栈溢出 | gets, read, strcpy | ret2text / ret2libc / ret2dlresolve | pwntools, ROPgadget, one_gadget |
| 格式化字符串 | printf 回显用户输入 | leak提权 / GOT覆盖 | pwntools fmtstr_payload |
| 整数溢出 | 长度参数可控 | 堆溢出 / 越界读写 | gdb+pwndbg, pwntools |
| 堆UAF | free 后指针未置NULL | tcache poisoning / fastbin attack | pwndbg heap bins |
| 堆溢出 | 用户输入超过堆块大小 | unsorted bin attack / house of系列 | gdb+pwndbg, LibcSearcher |
| 命令注入 | system / exec 参数可控 | shell命令拼接 | pwntools, 网传shell拼接 |
这不是完整的映射表,但覆盖了比赛里八成以上的内容。做题时先判断类型,再决定用模板,整个流程会快很多。
4. 利用脚本里的“半成品”,才是真正能反复用的资产
4.1 我几乎每次都会写的pwntools开头
写Pwn题exp,开头几行基本固定。我会把这些代码存成模板,每次都复用:
#!/usr/bin/env python3 from pwn import * context(arch='amd64', os='linux', log_level='info') context.binary = elf = ELF('./pwn') libc = ELF('./libc.so.6') io = process('./pwn') # 本地调试 # io = remote('目标地址', 端口) # 打远程时切换 io.recvuntil(b'Input: ') payload = b'A' * offset + p64(pop_rdi) + p64(addr) io.sendline(payload) io.interactive()这里context.binary = elf = ELF('./pwn')一行很有价值:pwntools会自动读取二进制里的架构、位宽、got、plt等信息,后面用elf.plt['puts']、elf.got['puts']都不用自己查地址。p64/u64负责字节序转换,64位地址必须用这两个函数,pack/unpack也可以,但没那么直观。
交互函数的选择是有讲究的。我一般用sendlineafter,它会等待并消耗掉指定的提示字符串再发送,而不是傻等固定时间。比赛时如果有人机交互提示Input:,sendlineafter(b'Input: ', payload)比sleep(1)靠谱得多。
4.2 ROP链构建:从手查到半自动
构造ROP链有三种常见方式,按熟练度可以分成三级。
第一级是纯手动:用ROPgadget查所有gadget,自己手工拼接。比如查pop rdi; ret:
ROPgadget --binary ./pwn --only 'pop|ret' | grep pop_rdi第二级是半自动:用pwntools自带的ROP类。它可以直接根据ELF来搜索gadget,并帮你组装ROP链:
rop = ROP(elf) rop.call('puts', [elf.got['puts']]) rop.call('gets', [elf.bss()]) payload = flat(rop.chain())它内部会处理gadget的偏移,还能自动加入函数调用前的参数对齐。但要注意,ROPgadget能找到的gadget未必会被pwntools的ROP类识别到,特殊情况下还是需要手动指定。
第三级是全自动:比如ret2dlresolve可以直接交给pwntools的Ret2dlresolvePayload,它能在没有libc的情况下动态解析system地址:
dlresolve = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh']) payload = flat(ROP(elf).resolve(dlresolve))这个工具对静态分析和动态符号解析机制不熟的人非常友好,但前提是你理解它到底做了什么。我建议基础扎实后再使用这种全自动方式,否则出问题时排错极其困难。
实际比赛中,栈题最常见的还是ret2libc;堆题里则大量使用one_gadget。one_gadget非常高效,但需要判断约束条件是否满足。例如某个libc里可能给出:
0x45226 execve("/bin/sh", rsp+0x30, environ) constraints: rax == NULL那你打payload前就要在gdb里确认rax是否为0。若约束不满足,需要先用ROP链调一下寄存器,再跳one_gadget。这是很多新手第一次用one_gadget失败的原因。
4.3 Libc版本识别:泄露地址后别瞎猜
栈题打ret2libc时,我们通常先leak一个真实函数地址,比如puts。由于puts在libc里的偏移是固定的,libc基址就等于“泄露地址减偏移”。但如果你不知道这个libc到底是哪个版本,偏移就没法算。
常用方案有两种。一是本地维护一个libc数据库,用libc-database的find命令:把泄露的函数名和地址后三位填进去,它会返回可能的libc版本。二是直接用在线Libc库,比较方便的入口是提供LibcSearcher库:
from LibcSearcher import LibcSearcher libc = LibcSearcher('puts', puts_addr) libc_base = puts_addr - libc.dump('puts') system_addr = libc_base + libc.dump('system')使用LibcSearcher有个注意点:你提交的地址必须是该函数在libc内的真实偏移,也就是去除低12位后的值需正确。比如puts函数的真实地址后12位通常就是0x080或0x10这样的固有偏移。不同系统可能略有差异,所以千万不要直接在远程exp里写死偏移,先查清楚再说。
更保险的做法是题目给了libc文件的话,直接本地用ELF('./libc.so.6')解析偏移:
libc = ELF('./libc.so.6') system_off = libc.symbols['system'] bin_sh_off = next(libc.search(b'/bin/sh'))比赛时能这样做就绝不猜。只有题目没给libc时才需要用LibcSearcher或在线匹配。
4.4 格式化字符串漏洞:几行代码搞定
格式化字符串漏洞的处理,pwntools有一个非常好用的封装fmtstr_payload。它能自动生成一个payload,把指定地址处的值改成目标值,同时避开坏字符。核心用法如下:
payload = fmtstr_payload(offset, {printf_got: system_addr})这里的offset是格式化参数偏移,也就是你的输入第一个占位符是%1$p还是%7$p。确定这个偏移的方法是:先发AAAA.%p.%p.%p.%p...数一下第几个处看到0x41414141,那个序号就是offset。用pwntools的FmtStr甚至可以自动完成这个探测:
fmt = FmtStr(execute, offset=unknown) fmt.write(printf_got, system_addr) payload = fmt.payload但fmtstr_payload生成的payload往往很长,某些题目对输入长度有限制,这时只能手动分段格式化,或者用%hn写低两字节。这是比较进阶的内容,建议先用自动工具打通一遍,再手工拆解理解。
5. 调试技巧决定你被一道题卡多久
5.1 让gdb不再是反人类的黑窗
裸gdb调试Pwn题体验很差,所以插件是必须的。我用的pwndbg,它启动后自动开启“增强模式”:每次停下来就能看到当前指令、调用栈、寄存器高亮、栈顶数据、以及分页的GOT表。调试堆题时,heap bins命令直接给出各个bin的链表结构,极大方便了判断堆块布局。
pwndbg的几个高频命令值得背下来:
vmmap:查看进程内存映射,找libc真实基址,避免手数偏移;telescope 0x...:连续查看某个地址附近的内存,适合看栈上的返回地址;heap bins:列出tcache/fastbin/unsorted bin链,堆题必需品;deref:逐层解引用指针,看链表关系。
有了这些,调试效率至少翻一倍。我也鼓励大家不用强迫自己背全部命令,每道题反复用到的其实就那么几个方向。比如栈题重点用telescope $rsp-0x20观察栈布局,堆题重点用heap bins看链结构,仅此而已。
5.2 怎么下断点最不浪费人生
调试Pwn题,断点不是随便下的。我常用的几个位置:
main函数开头,看初始化和环境的布局;- 漏洞函数调用结束的位置,比如
read返回后立即停,观察栈里的数据是否按预期排列; ret指令处,看返回地址是否被正确覆盖,常见是b *0x401286这种具体地址;- 对堆题,
b malloc、b free或者目标分配函数的入口。
条件断点在处理循环或爆破时很有用。比如你想观察主循环第100次时的状态,可以这样下:
b *0x401234 if $rdi == 100有时还需要watchpoint。比如你想观察某个栈地址是否被写入,可以用:
watch *(long*)0x7fffffffe140但watchpoint会拖慢程序执行速度,小范围用没问题,不要把整个内存区域都watch了。
pwntools的gdb.attach(io)是我的救星。它可以在运行exp时一键弹起gdb并附加到目标进程上,你甚至可以在exp脚本里设置好触发条件,等到payload发送前自动暂停。这样“代码自动化+人工调试”结合,效率极高。
5.3 本地通远程挂?排查顺序很重要
这是Pwn比赛里最令人血压飙升的问题。我总结了一套排查顺序:
- 先确认本地确实是用题目给出的libc跑起来的。如果没patch,默认的glibc和远程不同,大概率挂;
- 确认架构和位数。32位题如果按64位方式打,payload构造全错;
- 确认PIE和ASLR。没开PIE的二进制,远程基址固定;开了PIE,基址会随机,需要leak后计算偏移;
- 检查交互协议。本地是
process('./pwn'),远程是remote(ip, port),但远程服务可能有前置banner,比如需要先输入y确认,或者发回车才会输出; - 如果用了one_gadget,检查约束条件在远程环境是否满足,必要时提前构造小ROP来满足条件;
- 最后检查网络超时。写死
sleep不是好习惯,用recvuntil配合timeout参数才好控制。
一条实用技巧是:在exp里加一个context.log_level='debug'开关。调试时开成debug,能看到收发数据的具体字节内容,很多隐藏的换行问题、编码问题一瞬间就暴露了。但正式打远程时再关掉,避免大量输出刷屏。
6. 比赛场上的工具链协作和时间分配
6.1 模板脚本:比赛前就准备好,不是现场写
比赛时的每一分钟都很宝贵,完全没必要现场从零拼一个exp框架。我会在本地维护一个templates/目录,里面放着几套常用模板:
- ret2text模板
- ret2libc模板(包含libc base计算)
- 格式化字符串payload模板
- 堆UAF基础模板
- 通用remote连接受保护逻辑
模板脚本的骨架完全一致,只把具体地址和偏移留空。这样拿到题确认漏洞类型后,直接复制一份,填充关键参数即可。省掉大量写基础结构的时间。
举个例子,我的ret2libc模板开头长这样:
#!/usr/bin/env python3 from pwn import * context(arch='amd64', os='linux', log_level='info') context.binary = elf = ELF('./pwn') libc = ELF('./libc.so.6') REMOTE = True host, port = '', 0 def start(): return remote(host, port) if REMOTE else process('./pwn') def exp(): io = start() # Stage 1: leak addr # Stage 2: calc # Stage 3: getshell io.interactive() exp()后面打堆题时,我再在模板基础上加book堆相关状态。这个目录里的东西不是一次成型,每次比赛后我都会把新学到的利用姿势沉淀进模板里,下次比赛直接复用。
6.2 等待也能并行:别让工具吃了比赛时间
有些操作天生比较慢,比如ROPgadget在大型libc里全量搜索gadget、one_gadget在多个libc中循环匹配、LibcSearcher在线查询等。我通常会在等待时并行处理其他题目,或者至少先把别的题目信息收集跑一遍。
更具体的做法:把“信息收集”这步拆成可以跑批的任务。对同一场比赛的多个Pwn题,一次性file *、checksec *、strings,把它们的结果都打到一个notes.md里。这样等数据时不需要干等一个exp,而是同时推进三到四道题的初步分析。等某个工具的结果回来后,再集中写利用。
还有一个小妙招:在命令行里用timeout限制工具执行时间。比如:
timeout 10 ROPgadget --binary ./pwn --only 'pop|ret'如果超过10秒还没搜完,说明这个二进制可能很大或者gadget太少,我会换其他工具继续。别让一个命令把整个解题节奏拖死。
6.3 多人解题赛里的工具链和组织纪律
组队打CTF时,Pwn队伍内部也要有分工。我习惯的模式是:
- 队员A负责逆向分析,把漏洞类型和关键偏移及时同步;
- 队员B负责利用脚本,直接基于漏洞点写exp;
- 队员C负责libc匹配和题目环境patch,相当于一个“环境管理员”。
这样分工的前提是工具链统一。大家都用同一套工具版本,至少实测环境保持一致。不然A在本地用pwndbg分析好的堆结构,B在自己的机器上用另一个gdb插件看到的堆布局完全不同,来回沟通成本极高。
写writeup或共享notes时,我习惯把关键结论数字化:漏洞偏移多少、canary是否开启、libc的基址偏移、got表地址、one_gadget地址,都直接写成一行一行。比如:
pwn1: ret2libc, offset=40, pop_rdi=0x40123a, puts@got=0x403000这样其他队员只需要照着参数就能复用exp,不用重新逆向一遍。看起来很简单,但比赛时能大幅减少“你刚才说的是哪个地址”这类沟通成本。
6.4 最后二十分钟的取舍
比赛接近尾声时,人的精力会下降,这时候必须做取舍。我的经验是:
- 如果一道题有明确思路但卡在最后一步(比如算不对偏移),别放弃,试着把libc版本确认一遍,再打一次;
- 如果一道题连思路都没有,与其硬撑,不如去给已攻克的题写writeup,争取拿分数以外的排名依据;
- 如果一道题打了几次都崩,先停手,检查是不是环境没patch完整,而不是无限重试。
我会在比赛最后阶段用脚本循环测试外星人?(No,不需要),用最保守的payload再打一次。团队里这时候最好有一个人专门做“收尾”,他负责把所有exp的process切到remote,挨个验证一遍flag输出,其他队员继续攻未解题目。这个收尾角色不需要技术最高,但要足够细心,能在exp里发现REMOTE=True忘改之类的低级错误。
7. 老油条才愿意分享的几条“最佳实践”
7.1 环境快照与版本锁定
我前面提到过虚拟机快照,这里再强调一次。比赛前一夜,确认所有工具都能正常运行,然后给虚拟机拍一个“黄金快照”。比赛当天如果出现系统更新把某条依赖打乱、或者pip源不可用的情况,直接恢复快照,十分钟内回到可用状态。
版本锁定同样重要。不要突然升级pwntools或one_gadget到最新版,除非有明确的版本兼容说明。工具更新可能带来输出格式变化、API变化,甚至一些潜在bug。比赛前我最不愿意做的就是“重新适配工具”。所以我的本地环境里用pip的requirements.txt锁住关键版本,并在README里记录每场比赛前的实测结果。
7.2 先确认flag格式和目标交互,再开始打
听起来像废话,但真有很多人栽在这上面。有的平台flag格式是flag{...},有的是ctf{...},还有的是自定义前缀。如果你用错误的正则去匹配输出,可能明明打成功了,脚本却报错。建议拿到题目时先看平台规则,然后打开靶机端口发个空数据或者id确认一下回复内容,再决定exp的解析逻辑。
远程连接时还要确认端口本身是否稳定。有些靶场会自动限制连接频率,你疯狂重连可能被封IP一段时间。我遇到这类情况时会放慢重试间隔,或者用sleep 2间隔,避免把服务打崩。
7.3 别让“一键脚本”变成“一键崩服”
CTF靶机资源通常有限。一个未经验证的循环发送payload脚本,可能把远程服务打挂。而很多解题赛一旦服务崩溃,就要等管理员重启,或者直接判你“解出失败”。所以我写远程脚本时坚持一条原则:默认不加循环爆破,除非实在没有别的办法;如果要爆破,先确认服务端能够承受,且本地测试过发送间隔。
我还习惯在exp的每个关键recvuntil后面加一个timeout=5参数。这样即使某次发送失败,脚本也会自动停止而不是卡死在那里。否则每次打远程都要等半天才能发现问题,临场心态再稳也会崩。
7.4 赛后沉淀:把每一段调试经历变成资产
比赛结束不是终点。我每场赛后都会花一到两小时做干净的重建:把通过了的exp、题目二进制、对应libc都放进同一个目录,然后写一个几十字的说明文件,记录“这是什么漏洞、哪里坑了我、下次怎么做”。这个目录说白了就是个人Pwn知识库。
别小看这个习惯。几个月后遇到同类型题目,你翻一下知识库,可能十分钟就写出exp。如果不沉淀,下一次比赛你还会踩同一个小坑,比如忘记patch本地invocation、忘记64位要p64、忘记Canary没绕过。我的工具链逐步变顺手,不是因为工具本身有什么神奇,而是因为这些沉淀下来的经验已经变成了我肌肉记忆的一部分。
7.5 最后说点不常登场的体会
如果你问我工具链的“最佳实践”到底是什么,我的答案不是某个特定的工具组合,而是“稳定、快速、可复用”这三个词。稳定指环境不突然坏,快速指整套流程能顺利跑完,可复用指你能在下一场比赛里把上次的脚本拿出来直接用。
现在的CTF比赛,Pwn方向的题目难度水涨船高,栈题开始降到签到题难度,堆题和kernel题占据了更多分数。但工具链的训练不能跳过基础直接上高难度。我见过太多人因为没吃透工具原理,一遇到堆题就卡在heap bins看不懂,一遇到kernel题就卡在qemu调试环境配不起来。这些问题的本质都一样:不是题目难,是工具链没闭环。
所以我的建议是:先拿十道经典栈题,用我前面写的工具链从头到尾“套路”一遍。等你能闭着眼睛把ret2libc打出来,再去碰堆题和kernel题。工具链是你和题目之间的桥梁,桥梁不结实,再好的解题思路也会掉进水里。希望这篇清单能让你至少少走几个月弯路。