从glibc 2.27开始,堆题的常规打法变得很机械:tcache几乎没有限制,double free和poisoning基本是“一键操作”。可一旦题目在堆漏洞外面又套了一层seccomp沙箱,整条利用链就得重新想。你辛辛苦苦把__free_hook改成了system,结果execve被沙箱拦掉,shell根本起不来,flag自然拿不到。这篇文章就围绕“glibc2.27下堆题绕过沙箱”这一个场景,把沙箱规则怎么看、tcache怎么打、ORW链怎么拼、setcontext怎么用一次讲清楚,适合刚接触堆题、以及会打常规堆题但一遇沙箱就卡壳的同学。
1. 从“拿到shell”到“读文件”:堆题沙箱的常见套路
1.1 沙箱到底挡了什么
seccomp是Linux内核提供的一种系统调用过滤机制,本质上是给进程套一层“系统调用白名单”。在CTF的PWN题里,最常见的是把execve禁掉,也就是不让你执行任何新的程序。如果题目再把socket、connect这类系统调用也禁了,那反弹shell、外带数据统统别想。
这道题涉及的就是这个:堆漏洞仍然存在,控制流劫持仍然能做到,但唯一的目标变成“怎么用允许的系统调用把flag文件内容读出来并打印到终端”。换句话说,传统堆题追求的是拿到shell,而沙箱堆题追求的是完成一次open-read-write操作,业内习惯叫ORW链。
很多人一开始容易犯一个错误:看到堆题第一反应还是往__free_hook写system,等沙箱把execve一拦,整个利用链就卡住。所以拿到任何题目,第一步不应该是分析漏洞,而是先确认沙箱规则。规则决定了后面整个利用链的方向。
1.2 堆题的常规打法为什么失效
常规堆题的利用链通常是这样的:UAF → tcache poisoning → 把__free_hook改成system → free一个内容为"/bin/sh"的chunk → 拿到shell。这条链在无沙箱环境下非常稳,但在沙箱面前处处是坑。
system("/bin/sh")内部会调用execve,execve一旦被沙箱ban掉,就算控制流成功跳到system,也只能得到一个执行失败的结果。one_gadget也一样,大部分one_gadget的落脚点都是execve("/bin/sh"),同样会被沙箱拦下。所以常规打法的核心依赖不是“能不能劫持控制流”,而是“劫持之后能不能执行到想要的操作”。
沙箱堆题的目标因此变成了:劫持控制流后,不执行新程序,而是按白名单允许的系统调用,去打开、读取、输出flag文件。这需要更精巧的寄存器布局、ROP链设计,以及一套能在堆上完成栈迁移的手段。
2. glibc 2.27的堆利用基本功:tcache时代的几个关键点
2.1 tcache的分配与释放流程
glibc 2.27是tcache正式加入堆分配器的第一个主流版本。每个线程有一组tcache bin,默认64个,每个bin最多缓存7个相同size的chunk,并且每个chunk只保存一个next指针,没有任何校验字段。
释放时,如果chunk的size对应tcache bin还没满,就直接放入tcache,只有对应bin满了之后,小chunk才走fastbin或unsorted bin的流程。分配时,申请者会优先从tcache里取,同样不检查chunk的size字段是否真的匹配。这个“不校验”特性是非常关键的,后面所有tcache攻击都基于它。
2.27的tcache里没有像fastbin那样严格的double free检查,也没有后来2.29版本加的key机制,所以同一个chunk连续释放两次,两次都会进入tcache,形成两个相同的表项。这就是tcache dup。
2.2 tcache poisoning与double free
tcache poisoning是2.27最常用的堆原语。由于tcache链表是单链表,释放进tcache的chunk,其fd(也就是tcache_entry->next)会指向下一个chunk。如果我们能通过UAF修改这个fd,那下一次malloc就会把fd指向的地址当作chunk返回。
举个例子:先申请两个chunk A和B,释放A进入tcache,再通过UAF把A的fd改成一个目标地址 target。接着连续两次malloc,第一次malloc从tcache取出A,第二次就会取出target地址。这个操作不检查target的对齐、不检查target的size,直接返回。唯一需要满足的是返回地址按16字节对齐,因此目标地址通常要做一些错位处理。
double free配合poisoning就更简单:释放同一个chunk两次,tcache里出现两个相同地址,第一次malloc回来之后,chunk还在tcache链表中,修改它的fd,下一次malloc就能从我们指定的地址上分配内存。在2.27里,这套操作没有报错、没有abort,非常干净。
2.3 劫持hook的常用做法
glibc 2.27提供了几个很好用的hook指针,最常碰到的就是__free_hook和__malloc_hook。它们都存放在libc的数据段,权限可写。利用tcache poisoning往__free_hook写入一个地址,之后任何free调用都会先跳到这个函数指针。
常规堆题里,__free_hook写system,然后free一个内容为"/bin/sh"的chunk,就能弹shell。在沙箱题里,这个思路要修改:不是写system,而是写一个能帮我们把栈迁移到ROP链上的gadget,或者写setcontext系列地址。hook本身仍然是我们最稳定的控制流劫持点,只是劫持后的目标要换。
需要注意的是,2.27版本的__free_hook没有任何防护,也不参与任何安全检查,所以只要tcache poisoning成功,后续写入几乎不会受到干扰。2.34之后glibc删掉了这些hook,那套打法就要换,但在2.27这个版本里,hook仍然是最香的入口。
3. seccomp沙箱机制与检测方法
3.1 规则怎么配置的
seccomp有两种模式,简单模式和过滤模式。CTF题基本都用过滤模式,也就是seccomp-bpf。程序通过prctl系统调用加载一段BPF指令,内核在执行每个系统调用前都会先跑一遍这段指令,根据结果决定允许或拒绝。
白名单和黑名单两种思路都能实现。白名单常见写法是:先读系统调用号,如果等于某个允许的号码就返回ALLOW,否则返回KILL。黑名单则反过来,命中某些系统调用就返回ERRNO或KILL,其余放行。
对做PWN的人而言,不需要完全读懂BPF字节码,只需要知道最终哪些系统调用被允许、哪些被禁止。比如一段过滤规则可能是:
- 允许read、write、open、close、exit
- 禁止execve、execveat
- 其余系统调用全部KILL
这种规则一出来,思路就锁定在ORW上面。
3.2 怎么快速分析题目沙箱规则
分析沙箱最常用的工具是seccomp-tools。拿到题目二进制后,直接执行:
seccomp-tools dump ./pwn工具会输出类似这样的伪汇编:
line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x02 0xc000003e if (A != ARCH_X86_64) goto 0004 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x15 0x01 0x00 0x0000003b if (A == execve) goto 0005 0004: 0x06 0x00 0x00 0x00000000 return KILL 0005: 0x06 0x00 0x00 0x7fff0000 return ALLOW看到if (A == execve) goto KILL这种结构,就说明execve被禁,后面所有需要执行程序的方案都可以直接排除。如果连open也被禁,那ORW里的O就要用openat或者其他手段替代。所以分析沙箱规则这一步真的别省,它决定了利用链是打ORW还是打FSOP还是打SROP。
3.3 常见允许/禁止组合
| 沙箱组合 | 典型表现 | 首选利用思路 |
|---|---|---|
| 不设置沙箱 | 所有系统调用可用 | 常规tcache+system/one_gadget |
| 仅禁execve | open/read/write可用 | ORW链,setcontext+ROP |
| 禁open和execve | read/write可用,可能允许openat | 尝试openat,或复用已有fd |
| 极严格白名单 | 只允许read/write/exit | 考虑SROP、io_file攻击或文件描述符复用 |
| 允许mmap/mprotect | 可申请可执行内存 | tcache写shellcode,直接读文件 |
这不是严格的分类表,只是帮助建立第一反应。很多题的沙箱规则会稍微留几个“后门”,比如允许sendfile,那就可以绕开read+write,直接用sendfile把文件内容发给socket或stdout。规则越宽松,利用链就越短。规则越紧,越要往io_file这类能不靠ROP完成读写的方向上靠。
4. 绕过沙箱的核心方案拆解
4.1 ORW链的本质
ORW链就是一段ROP,依次完成三个系统调用:
- open("/flag", 0) —— 打开文件,返回文件描述符
- read(fd, buf, len) —— 读取文件内容到可写内存
- write(1, buf, len) —— 把内容输出到stdout
64位下open的系统调用号是2,read是0,write是1。ROP链需要找到能控制rdi、rsi、rdx的gadget,再找一个syscall; ret。glibc 2.27里这些gadget都存在,配合libc基址就能定位。
一个标准的ORW链长这样:
rop = pop_rdi_ret rop += flag_addr # "/flag" 字符串地址 rop += pop_rsi_ret rop += 0 # flags = O_RDONLY rop += pop_rdx_ret rop += 0 # 不需要mode rop += syscall_ret # open rop += pop_rdi_ret rop += 3 # fd=3(通常第一个可用的fd) rop += pop_rsi_ret rop += bss_addr # 放读取内容的缓冲区 rop += pop_rdx_ret rop += 0x50 # 读取长度 rop += syscall_ret # read rop += pop_rdi_ret rop += 1 # stdout rop += pop_rsi_ret rop += bss_addr rop += pop_rdx_ret rop += 0x50 rop += syscall_ret # write这里的fd不一定是3,如果题目环境已经打开过别的文件,fd可能要试4、5等。读取缓冲区选在bss段或者某个可控堆块都可以,只要进程可写。
4.2 setcontext+61栈迁移姿势
ORW链需要连续执行很多gadget,但堆题的控制流劫持往往只能改一个函数指针,没法直接跳进链表。这时候就需要栈迁移:把执行流搬到一个完全可控的区域,那个区域再平铺开完整的ROP链。
glibc的setcontext是一个非常经典的迁移入口。setcontext函数本意是恢复用户上下文,但它的后半段代码几乎就是一个“寄存器恢复加载器”,我们可以把它当gadget用。
setcontext+61这段代码会从rdx指向的内存里恢复一堆寄存器,关键效果是:把rdx+0xa0处的值赋给rsp,把rdx+0xa8处的值当作返回地址。也就是说,只要我们让rdx指向一块可控内存,再把ROP链首地址放在那块内存的0xa0偏移处,0xa8偏移处放一个ret地址,执行到setcontext+61后,rsp就会被迁到ROP链上。
使用前提很明确:rdx必须可控。在2.27的堆题里,rdx的控制方式一般是利用某个系统调用或函数调用留下的寄存器残留值。常见做法是,在触发hook之前,通过read等操作把rdx“污染”为可控内存地址。也可以直接把函数指针写成setcontext入口,让入口处的代码把rdi拷到rdx,而rdi正好是free的第一个参数,也就是我们可控的chunk指针。但入口处有额外检查,不同libc小版本表现不同,调试成本比较高。因此大多数exp写的是setcontext+61,配合一个能控制rdx的上下文。
4.3 进阶思路:io_file/FSOP与变种
如果ROP链和栈迁移都做不了,比如找不到合适的gadget,或者可控内存区域太少,那就需要考虑io_file攻击,也叫FSOP。
FSOP的核心是伪造一个FILE结构体,利用glibc的IO函数在处理某个FILE时,从结构体里取函数指针并调用。劫持_IO_2_1_stdout_的vtable,或者往_IO_list_all链表里挂一个伪造的FILE,都能在printf/exit等时机触发恶意调用。和沙箱结合时,利用链可以在不显式调用syscall的情况下完成文件读取。
FSOP的缺点是构造复杂。2.27版本里需要对vtable校验有一定的了解,还要知道_IO_file_jumps、_IO_str_jumps这些默认vtable的具体布局。不过在沙箱规则极严格、ROP链拼不起来的时候,它确实是最后的底牌之一。还有SROP思路:如果沙箱允许rt_sigreturn系统调用,就能伪造SigreturnFrame,直接用rt_sigreturn恢复所有寄存器并跳转,省去大量gadget搜索。很多严格白名单的题目反而允许sigreturn,因为白名单设计者没把它当危险的系统调用。
4.4 少走弯路的思路选择
面对一道glibc2.27沙箱堆题,思维顺序应该是:
- 看沙箱规则,列出允许的系统调用。
- 分析漏洞类型,确认能拿到哪些堆原语。
- 能泄露libc就先泄露libc,所有绕过方案几乎都依赖libc基址。
- 如果允许集里包含open/read/write,优先拼ORW。
- 拼ORW需要控制执行流,优先考虑tcache劫持hook,接着用setcontext做栈迁移。
- 如果setcontext用不了,再考虑SROP、FSOP、openat、sendfile等变种。
这个顺序能避免一开始就陷入复杂方案。很多时候setcontext+ORW就是最优解,没必要硬上FSOP。
5. 一次完整模拟实战:UAF堆题+禁用execve
5.1 漏洞点与前期泄露
模拟项目X,环境是常见的glibc 2.27,保护全开:NX、PIE、Full RELRO、Stack Canary。题目菜单基本就是add、edit、show、delete四个功能,chunk大小为用户自定义。
漏洞在delete:释放chunk后没有把指针清空,形成UAF。show还能正常打印这块已释放的内存,edit也还能修改这块内存的内容。这就同时给了泄露和控制两个能力。
第一步先泄露libc。分配一个比较大的chunk,比如0x500,释放后它不会进tcache,而是进入unsorted bin。此时chunk头部会写入main_arena附近的地址,用show打印这段数据,就能拿到一个leak。通过leak减去libc里main_arena对应偏移,就得到libc基址。
拿到基址后算出这些关键地址:__free_hook、setcontext+61、pop rdi; ret、pop rsi; ret、pop rdx; ret、syscall; ret。2.27的libc里这些gadget都很常见,用ROPgadget或ropper在本地libc里直接搜。
5.2 沙箱规则确认
用seccomp-tools dump检查,发现规则很简单:execve和execveat被禁止,open、read、write都被允许。这属于最典型的堆题沙箱配置,目标就是ORW链。
因为shell这条路已经死了,__free_hook写system、one_gadget统统排除。剩下的事情只有一件:劫持控制流到ORW链上。
5.3 利用链的完整构造
这道题有一个很关键的细节:delete功能不是直接free,而是先执行一次read往目标chunk里写入数据,然后再free。read的第三个参数来自堆上的一个size记录字段,而这整个记录结构也在堆上,可以被UAF的edit能力改写。
也就是说,我可以把那个size记录字段改成一个伪造结构体的地址。read的第三个参数在系统调用返回后仍然残留在rdx寄存器里,紧接着调用的free会带着rdx等于这个伪造结构体地址进入__free_hook。这样一来,setcontext+61需要的rdx可控条件就满足了。
利用链如下:
- 用UAF配合tcache dup,让tcache链上出现两个相同chunk。
- 连续两次malloc取回同一个chunk,利用第一次返回的chunk修改它自身在tcache里的fd,指向__free_hook。
- 再一次malloc,拿到__free_hook附近的内存,向里面写入setcontext+61地址。
- 在某块可控堆内存里构造伪造结构体:偏移0xa0处放ORW链地址,偏移0xa8处放ret地址,ORW链紧随其后。
- 修改size记录字段为伪造结构体地址。
- 触发delete,先read再free,free进入__free_hook时执行setcontext+61,寄存器被恢复,rsp被迁到ORW链上,最终完成open、read、write。
核心payload的简化写法:
# 1. tcache poisoning 把 __free_hook 写成 setcontext+61 edit(tcachunk, p64(free_hook)) add(0x10) # 拿到 chunk,改写 __free_hook add(0x10) edit(free_chunk, p64(libc.sym['setcontext'] + 61)) # 2. 伪造 setcontext 的 context 结构 fake = bytearray(0xb0) fake[0xa0:0xa8] = p64(rop_addr) # rsp -> ROP chain fake[0xa8:0xb0] = p64(ret_addr) # 返回地址 # 3. 构造 ORW ROP 链 rop = p64(pop_rdi_ret) + p64(flag_addr) rop += p64(pop_rsi_ret) + p64(0) rop += p64(pop_rdx_ret) + p64(0) rop += p64(syscall_ret) ...这里的伪造结构体不需要把每一个寄存器字段都填满,关键是0xa0和0xa8两个字段。其他字段用零填充,因为setcontext+61在恢复那些寄存器后,后续ROP链也不依赖它们的原始值。不过不同libc小版本在某些偏移上可能有细微差异,稳妥做法是先在gdb里对目标libc反汇编setcontext,确认0xa0和0xa8字段的含义。
5.4 执行与调试要点
整个利用最容翻车的地方在tcache poisoning阶段。调试动作要细:每完成一步,就用gdb检查tcache bin的状态,确认fd有没有写对。
如果poisoning失败,常见表现是malloc返回的不是__free_hook,而是别的堆地址。原因通常是tcache链上不止一个chunk,修改fd修改错了对象。建议在调试时打印tcache bin:
p tcache->entries[0]或者直接用pwndbg的heap命令看堆布局。确认好再继续,盲目往下打只会浪费时间。
触发free之后,重点观察两个地方:rdx是否等于伪造结构体地址,rsp是否成功迁到ROP链首地址。在gdb里对__free_hook下断点,然后单步跟踪:
b *__free_hook c # 命中断点后 p/x $rdx x/gx $rdx+0xa0 x/gx $rdx+0xa8如果rdx不对,说明size记录字段被改的值没有正确覆盖到read的第三个参数。如果rdx正确但rsp没有迁过去,说明伪造结构体的0xa0偏移填错,或者setcontext+61的入口地址在当前libc版本有偏差。
6. 常见问题排查与避坑技巧
6.1 tcache poisoning失败的原因
tcache poisoning在2.27里看起来很无脑,但实际操作还有几个坑。第一,目标地址要16字节对齐,__free_hook往往不是16字节对齐的地址,所以不能直接把fd写成__free_hook,而是写成__free_hook - 8这种错位地址,等malloc返回后再往后找对齐位置。第二,如果同一个tcache bin里还有其他chunk,那么链表结构会比预期复杂,修改fd时要算清楚第几次malloc会命中。第三,UAF修改chunk内容时,不要把tcache的next字段覆盖成非法值,否则后续malloc会崩。
在2.27上,tcache取chunk时不会校验next指针指向的内存有没有意义,但这不代表可以把next写到一个明显非法的地址,比如0x41414141。malloc会尝试把目标地址当作chunk头使用,然后在某些路径上访问它附近的字段,非法映射会导致segment fault。
6.2 setcontext迁移后栈不稳定的原因
setcontext+61用起来很爽,但一旦rsp迁过去之后段错误,最常见的两个原因:一是0xa8处的返回地址没有设置,导致setcontext末尾ret时跳到0地址;二是ROP链本身没有做栈对齐。
64位下如果ROP链中调用了libc函数或者某些需要movaps指令的函数,栈对齐不对会直接触发SIGSEGV。解决办法是在ROP链开头多放一个ret,先做一次栈帧调整,再进入真正的gadget序列。如果用的是syscall; ret,通常不需要额外的ret,但加上也无妨。
迁移后的栈最好放在一个相对“宽敞”的可控chunk上,而且要保证这个chunk不会被后续的malloc/free操作复用。否则ROP链执行到一半,栈数据被覆盖,整个利用直接作废。我在实际调试中踩过这个坑,所以建议触发利用前把所有不相关的堆操作停掉。
6.3 ORW链的常见翻车点
ORW链看着简单,翻车点非常细。open的路径要试对,有的题目flag在“./flag”,有的在“/flag”,有的在“/home/ctf/flag”,路径字符串必须写对并且要以\x00结尾。open返回的fd不一定是3,如果程序启动时已经打开过别的文件,fd可能是5、6,这时候read的rdi就不能写死3,要先用一个临时变量把fd存下来,或者在本地调试确认。
还有一个很经典的问题:read读取长度太大,读入了不可写区域。ORW的buf要选在真正可写的内存上,bss段、堆块都行,但如果你把ROP链和buf放在同一个chunk,buf的数据可能把后续的ROP链覆盖掉。最好把buf放在一个独立的、不会再被使用的区域。
如果沙箱允许sendfile,可以直接绕开read+write,用sendfile(1, fd, 0, 0x100)把文件内容从内核态发到stdout,这样少一次调用,也少一次buf选择问题。
6.4 调试工具与常规操作建议
调试沙箱堆题,我一般准备三样:pwndbg、seccomp-tools、以及一份目标libc文件。本地跑exp时用提供的libc,不要用系统默认libc,否则gadget偏移和tcache行为都可能对不上。
拿到题目后,我的习惯顺序是:
- checksec看保护。
- seccomp-tools dump看沙箱。
- 跑一遍菜单,分析漏洞类型。
- 先本地泄露libc并验证偏移。
- 把利用链拆成“泄露、劫持、迁移、ROP”四个阶段分别调试。
- 所有关键地址用gdb断点确认后再整体打remote。
这套流程虽然听起来繁琐,但能省掉大量试错时间。沙箱堆题最忌讳的就是打开就写exp,写几十行payload一把梭,梭不出来再猜。分阶段验证,每次只验证一个目标,效率反而最高。
7. 关于这套利用链,我的一点个人体会
做了这么多堆题,我个人最大的感受是:glibc2.27的堆溢出和UAF其实都很好打,真正的难点从来不在于堆本身,而在于沙箱把思路锁死之后,怎么把已有的堆原语翻译成最终能读flag的利用链。setcontext+ORW只能算是这个版本最通用的方案,它需要rdx可控,而rdx在2.27的题目里往往可以通过read的第三个参数残留来凑。如果rdx凑不出来,我一般会先去翻libc里有没有合适的花式gadget,再考虑FSOP。多准备几套备选方案,遇到题目就会从容很多。另外,本地调试时把每一步寄存器的变化都记下来,看起来慢,实际上比闷头跑exp快得多。