
去年年底我给自己定了个小目标把BUUCTF上的pwn入门题系统性刷掉一批。结果第一道题就把我整懵了——下载附件一个ELF文件拖进IDA看到满屏汇编函数名倒算友好可就是不知道从哪下手。后来刷到三四十道才回过味来栈溢出、ROP、ret2csu、整型溢出、格式化字符串名字千变万化本质都是让程序自己交出控制权。这篇文章不写从零到精通的大话就踏踏实实记录我在kali下刷BUUCTF pwn题时配置环境、分析漏洞、调试踩坑的完整过程适合刚搭好pwn环境、想在实战题库里练手的同学参考。1. 为什么是BUUCTF题库分层与刷题心理建设1.1 一个练习题集中营对入门者的意义练pwn最愁的不是知识而是去哪找合适的题。BUUCTF把历年各大赛事的题目按方向归档pwn分类下有栈溢出、格式化字符串、堆、整型溢出等标签比你自己扒GitHub散落的题目要系统得多。每个题目都给出远程端口附件下载下来就能做完全不需要在本地搭一堆靶机。环境这块省下的时间足够你多复盘三题。另一个好处是题的梯度比较自然。早期的pwn题大多是经典的栈溢出、ret2text难度友好代码量少非常适合建立拿到题-看保护-找漏洞-写exp的完整流程。等到套路熟悉了再往ret2libc、ret2csu、堆利用走每一步都有前一步的积累。刷题最怕一上来就读不懂题解BUUCTF这点做得不错热门题目搜索题解非常方便。1.2 分数、热度和打卡心态的问题BUUCTF的题目分数不是写死的热门题随着解出人数增加单题分值会往下掉所以你看到的分数只能当作这题被多少人做出来过的参考别太较真。真正要较真的是自己的解题能力。我见过很多人刷题变成了刷已解决数量进去就搜题解复制exp跑通下一题。这样刷一百题也是原地踏步。比较建议的心态是第一遍独立尝试卡住再看题解看懂了合上自己从零写exp写通了这题才算过。这样刷虽然慢但每道题都会留下肌肉记忆后面做heap题时会发现很多看似新奇的利用链其实都是栈溢出时期攒下的老本。2. kali下的pwn工作台一次配好工具链与环境坑2.1 核心工具pwntools、pwndbg、one_gadget、ROPgadgetpwn刷题离不开四件套pwntools写exppwndbg看运行时状态ROPgadget找gadgetone_gadget查libc里的万能shell地址。安装顺序建议这样sudo apt update sudo apt install python3-pip gdb -y pip3 install pwntools git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.shpwndbg装上后gdb里自带checksec命令日常够用。如果你习惯单独的checksec脚本也无妨注意别装重复了。one_gadget是Ruby写的需要Ruby环境sudo apt install ruby ruby-dev -y sudo gem install one_gadgetROPgadget用pip装就行pip3 install ROPgadget为什么推荐pwndbg而不是PEDA或GEF不是它一定更强而是它的开箱体验最省心默认显示的上下文直接包含寄存器、栈顶数据和反汇编信息密度对新手很友好。装一个用熟比三个来回切强得多。2.2 32位程序跑不起来的坑让我卡了半天的multilib很多早期pwn题是i386的64位kali默认不带32位运行库你执行./pwn会直接报No such file or directory——注意文件明明存在报这个就是缺动态链接器。解决办法是装multilibsudo dpkg --add-architecture i386 sudo apt update sudo apt install libc6:i386 libstdc6:i386 libncurses5:i386 -y还有一类坑是32位程序在gdb里跑起来了但用pwntools的process()连不上或者printf输出不刷新。pwntools处理交互和终端不同所以本地测试时优先以pwntools的process为基准不要手动开终端跑了一遍正常就觉得万事大吉。2.3 建议的目录结构与exploit模板刷题到后面你会发现找题不麻烦麻烦的是翻旧exp。我现在的目录是这样的~/pwn/buu/ 2024_csaw_warmup/ warmup solve.py 2022_rop_ret2csu/ r2c exp.py每道题一个文件夹核心是留一个能直接跑的exp脚本。我自己的模板放这里新建题时复制一份改改就能用#!/usr/bin/env python3 from pwn import * context.arch amd64 context.log_level debug def conn(): if args.LOCAL: return process(./pwn) else: return remote(node4.buuoj.cn, 端口) p conn() elf ELF(./pwn) # 后面按题写逻辑context.log_level debug建议默认开着能清楚看到收发内容排查交互问题会省很多时间。注意args.LOCAL是pwntools的命令行参数机制跑本地用python3 solve.py LOCAL打远程就python3 solve.py这比每次手动改ip端口可靠。3. 栈溢出BUUCTF入门题的直球3.1 返回地址是怎么被改写的栈溢出题的核心就一件事修改函数的返回地址。函数调用时call指令会把下一条指令地址压栈函数返回时ret指令把这个地址恢复到rip。如果函数内部有char buf[0x20]这样的局部数组而你用read(0, buf, 0x80)往里写了超过0x20字节的数据多出来的部分就会一路覆盖到栈上保存的返回地址| 局部变量区域 | | saved rbp | | 返回地址 | -- 攻击目标 | 调用者栈帧 |我们发送精心构造的数据把返回地址改成程序里已有的危险函数比如system(/bin/sh)程序一ret就直接跳过去执行了。这就是ret2text的基本盘也是所有栈类题的地基。3.2 checksec输出怎么读拿到二进制第一件事就是checksec它会告诉你程序开了哪些保护字段含义做题时的直接影响Arch是32位还是64位决定payload写法32位参数在栈上64位参数在寄存器Canary栈金丝雀开启后不能直接连续覆盖往往需要先泄露canaryNX栈不可执行开启后不能直接在栈上执行shellcode得靠ROPPIE地址随机化开启后程序基址会变需要泄露地址RELROGOT表保护Full RELRO时GOT不可写Partial RELRO时可以改GOT比如checksec显示NX enabled但PIE disabled那你基本就可以确定走栈溢出ROP链的路子程序地址是固定的gadget地址可以直接写死。Canary开启了就得多一步先泄露canary再构造payload时原样填回去。3.3 用cyclic找偏移别手数新手最爱犯的错是拿十六进制一个个数偏移数错一次浪费半小时。正确姿势是让程序自己告诉你gdb ./pwn cyclic 200 run # 程序崩溃后pwndbg会显示rip寄存器的值比如 0x6161616161616161 cyclic -l 0x6161616161616161 # 输出40cyclic生成的字符序列有规律cyclic -l会根据崩溃地址反推出偏移。这个方法对所有栈溢出题通用等于白送的。我现在的流程固定是先cyclic测偏移再决定payload布局效率比手数高一个数量级。3.4 ret2text与ret2shellcode怎么选判断标准很简单NX开着栈不能执行shellcode那就找程序里的后门函数或者构造ROPNX没开栈可执行那直接在栈上放shellcode把返回地址改成栈地址就行。一种高频组合是NX没开、程序没开PIE输入点又在你溢出之后继续让你读一次那就可以先溢出布置shellcode地址再读入shellcode到栈上。shellcode直接用pwntools生成shellcode asm(shellcraft.sh())注意64位shellcode对栈对齐敏感有些环境需要你在payload里多加一个ret指令做对齐否则system(/bin/sh)莫名崩掉。这个坑后面还会遇到。4. ret2csu老题库里绕不开的万能补丁4.1 一段能控制三个参数的汇编x86_64调用约定下函数前三个参数分别放rdi、rsi、rdx。溢出后如果你只能找到pop rdi; ret这一个gadget那就只能调一个参数的函数比如system本身只要一个参数但write、read这种要三个参数的就抓瞎了。ret2csu解决的就是这个问题。所有glibc编译的64位ELF里__libc_csu_init这段函数尾部藏着两组现成的gadgetpop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret mov rdx, r15; mov rsi, r14; mov edi, r13d; call [r12 rbx*8] add rsp, 8; pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret第一段负责把寄存器填上值第二段负责把寄存器赋给rdx/rsi/rdi然后调用地址。组合起来你能控制rdi、rsi、rdx三个参数并调用任意地址相当于白捡了一张万能调用卡。4.2 一次真实的write泄露libc过程最常见的玩法是泄露GOT表里的libc函数地址。比如要调用write(1, write_got, 8)把write的真实地址打出来# 假设题目给了 buf 偏移 offset以及两个csu gadget的地址 payload bA * offset payload p64(pop_rbx_rbp_r12_r13_r14_r15_ret) payload p64(0) # rbx 0 payload p64(1) # rbp 1不参与计算满足add rsp,8即可 payload p64(write_got) # r12 要调用的地址指针也就是writegot payload p64(1) # r13 rdiwrite的第一个参数fd1 payload p64(write_got) # r14 rsiwrite的第二个参数要泄露的got payload p64(8) # r15 rdxwrite的第三个参数字节数 payload p64(mov_call_ret) # 执行第二段gadget payload p64(0) # add rsp, 8 吃掉一个栈位 payload p64(0) * 7 # pop rbx~r15 的一串占位 payload p64(main_addr) # 回到主函数进行第二阶段第二段gadget里的call [r12 rbx*8]r12填的是write_gotrbx填0就是call writegot等价于调用write函数。注意这里填的是GOT地址不是write的PLT地址这个坑我踩过一次直接把函数地址填进r12程序跳到一个不可执行的内存区域直接段错误。4.3 为什么老题库里ret2csu出现率这么高2016-2018年的pwn题很多是NX开启、partial RELRO、不开PIEGOT可写但程序本身没有system也没有pop rdi/pop rsi/pop rdx这种全套gadget。这种条件下ret2csu成了标准答案先泄露libc再算system和/bin/sh的实际地址最后再来一轮溢出调system。BUUCTF题库里大量复刻了那个时代的题所以ret2csu几乎是刷题路上绕不过去的一课。5. 整型溢出绕过长度检查的另一条路5.1 有符号/无符号的类型陷阱整型溢出在pwn里通常不是让你算数而是制造一种检查看着没问题但实际内存已经越界的假象。看一个典型代码char buf[0x100]; int size; scanf(%d, size); if (size 0x100) exit(0); read(0, buf, size);表面看size超过256就被拦住了。但scanf用%d读的是有符号int你输入-1size等于-1-1显然小于256检查通过。然后read的第三个参数类型是size_t无符号-1在无符号视角下是0xffffffffffffffff一个巨大的数。read虽然不会真的读4GB但它会把用户发来的数据原样写进bufbuf只有0x100字节你发0x300字节就溢出了。这就是整型溢出最常见的利用方式用负数绕过正面检查在底层视角里变成超大无符号数攻击者实际想发多少发多少。用pwntools打这种题很简单p.sendlineafter(binput:, b-1) p.send(bA * 0x300) # 轻松越过buf边界5.2 不只是read负数索引和malloc的骚操弄整型溢出还会出现在数组索引和堆分配上。比如int idx; scanf(%d, idx); if (idx 10) { printf(%s\n, names[idx]); }index被声明成int输入负数对10取余后可能访问names数组前面的内存配合地址排布可能直接读到或改到关键结构体。堆那边的经典操作是malloc(负数)负数转成size_t后变成超大值或者被截断成一个小值导致实际分配的空间小于写入量形成堆溢出。这类题在BUUCTF里比栈溢出的纯套路题稍复杂但内核还是同一个类型转换之后比较逻辑和实际行为不一致。5.3 审计代码时养成一个变量三连习惯刷这种题最怕盯着一个check看半天看不出来。我现在看到每个size、len、index变量都会强制走三遍先确认变量类型再看它在比较时的类型最后看它在使用点的类型。负数和无符号是重灾区大数和截断是次重灾区。检查通过了不代表数据安全这是整型溢出题教给我最深的一句话。6. 调试定位pwndbg里我每天重复的几条命令6.1 一套固定的起手式调试不是乱逛要有固定的起手顺序。拿到一个溢出点我一般是gdb ./pwn checksec b *0x401234 # 在下一次read前后下断点 cyclic 200 run x/20gx $rsp # 看栈上数据分布 telescope $rsp # 追踪栈指针指向的内存x/20gx是看原始十六进制telescope会把地址指向的字符串、指针也解析出来观察payload有没有按预期落到目标位置非常直观。配合pwndbg的自动上下文你基本不需要手动刷屏看寄存器。6.2 找gadget和看内存布局不在gdb里解题时找gadget用ROPgadgetROPgadget --binary ./pwn --only pop|ret | grep rdi\|rsi\|rdx如果程序没开PIE直接用ROPgadget --binary ./pwn列出全部gadget地址固定。开了PIE的话记得要在exp里先把程序基址算出来再往基址上加gadget偏移不能直接用ROPGadget给出的原始地址。想看libc里有没有现成的字符串比如/bin/sh可以strings /lib/x86_64-linux-gnu/libc.so.6 | grep /bin/sh或者直接在gdb里search /bin/sh。这一步在ret2libc题里几乎必用。6.3 本地通了远程炸四个方向排查这是刷题最磨人的阶段我整理过自己的排查顺序现象可能原因排查动作远程直接崩溃远程libc版本/偏移不同用libc-database或libc.symbols确认版本栈对齐导致system崩调用system时rsp没对齐payload前加一个ret对齐收发数据对不上远程程序输出缓冲或需要先收一段提示加recvuntil、sleep(0.2)PIE程序地址变了ASLR随机化先泄露基址再二次溢出远程有沙箱/seccomp程序限制execve检查启动代码里是否有seccomp相关函数尤其注意最后一点。很多看起来该用system的题远程环境被seccomp禁了execve直接system(/bin/sh)怎么都连不上这时候要用open/read/write这种绕过方式把flag打出来。本地一次通过并不代表远程能通过这是远程题的基本尊重。7. 刷题节奏、复盘方法与下一步路线7.1 单题节奏30分钟红线我在BUUCTF刷题时给自己定的规则是拿到题先checksec、file、strings、IDA定位关键函数控制在20分钟如果30分钟还没有形成完整思路就去看题解的第一段看懂攻击手段之后合上自己从零写exp。这里的红线不是不能看题解而是不能带着题解从头抄到尾。看明白漏洞类型和利用链后面的payload构造、调试、远程打通都靠自己完成这道题才能真正变成你的经验。每道题远程打通的瞬间我建议顺手把exp保存成exp.py留在题目文件夹里。别小看这一步三个月后你回来做heap题想复习ret2csu翻到自己写的注释清晰的exp比翻任何博客都管用。7.2 复盘从能跑到能讲能跑通exp只算完成了50%。刷到中期你会发现很多题看着代码完全不同漏洞点八竿子打不着但利用链骨架惊人相似。这时候就要强迫自己复盘给每道题写三行笔记漏洞点是什么导致控制流被劫持具体细节是什么利用链从触发漏洞到获取flag经过了哪几步绕过点有哪些保护被绕过用什么手段绕的写的时候想象你要把这个解法讲给一个只懂基础的菜鸟听你会发现很多你以为理所当然的步骤其实经不起追问。比如为什么这里要先泄露libc答案不只是为了算system地址而是因为远程libc版本未知直接拿本地的system偏移去打会崩。7.3 下一步路线从栈到堆的过渡栈溢出、ROP、ret2csu、整型溢出刷稳之后我在BUUCTF上的下一个方向就是格式化字符串和堆利用。格式化字符串可以泄露任意地址内容甚至改GOT是栈题和堆题之间的桥梁。堆那边就完全换了思路不再是覆盖返回地址而是伪造chunk、劫持函数指针、打tcache bin知识密度大很多。所以我也把这句话送给准备入坑的同学先别急着上堆把栈溢出和ROP刷出肌肉记忆让cyclic、ret2csu、one_gadget这些词从听说过变成下意识用出来再去看heap题你会发现自己上手比别人想象中快得多。最后分享一个小习惯每刷完一道题我会把exp里最难理解的那一行注释写明白然后删掉注释过几天不看答案重新写一遍。如果能不看注释写出来这道题才算真正过手。刷题不是做任务是把别人的套路变成自己的肌肉记忆这个过程没有任何捷径。