从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)内部会调用execveexecve一旦被沙箱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 freetcache 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 常见允许/禁止组合沙箱组合典型表现首选利用思路不设置沙箱所有系统调用可用常规tcachesystem/one_gadget仅禁execveopen/read/write可用ORW链setcontextROP禁open和execveread/write可用可能允许openat尝试openat或复用已有fd极严格白名单只允许read/write/exit考虑SROP、io_file攻击或文件描述符复用允许mmap/mprotect可申请可执行内存tcache写shellcode直接读文件这不是严格的分类表只是帮助建立第一反应。很多题的沙箱规则会稍微留几个“后门”比如允许sendfile那就可以绕开readwrite直接用sendfile把文件内容发给socket或stdout。规则越宽松利用链就越短。规则越紧越要往io_file这类能不靠ROP完成读写的方向上靠。4. 绕过沙箱的核心方案拆解4.1 ORW链的本质ORW链就是一段ROP依次完成三个系统调用open(/flag, 0) —— 打开文件返回文件描述符read(fd, buf, len) —— 读取文件内容到可写内存write(1, buf, len) —— 把内容输出到stdout64位下open的系统调用号是2read是0write是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 # fd3通常第一个可用的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 setcontext61栈迁移姿势ORW链需要连续执行很多gadget但堆题的控制流劫持往往只能改一个函数指针没法直接跳进链表。这时候就需要栈迁移把执行流搬到一个完全可控的区域那个区域再平铺开完整的ROP链。glibc的setcontext是一个非常经典的迁移入口。setcontext函数本意是恢复用户上下文但它的后半段代码几乎就是一个“寄存器恢复加载器”我们可以把它当gadget用。setcontext61这段代码会从rdx指向的内存里恢复一堆寄存器关键效果是把rdx0xa0处的值赋给rsp把rdx0xa8处的值当作返回地址。也就是说只要我们让rdx指向一块可控内存再把ROP链首地址放在那块内存的0xa0偏移处0xa8偏移处放一个ret地址执行到setcontext61后rsp就会被迁到ROP链上。使用前提很明确rdx必须可控。在2.27的堆题里rdx的控制方式一般是利用某个系统调用或函数调用留下的寄存器残留值。常见做法是在触发hook之前通过read等操作把rdx“污染”为可控内存地址。也可以直接把函数指针写成setcontext入口让入口处的代码把rdi拷到rdx而rdi正好是free的第一个参数也就是我们可控的chunk指针。但入口处有额外检查不同libc小版本表现不同调试成本比较高。因此大多数exp写的是setcontext61配合一个能控制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等变种。这个顺序能避免一开始就陷入复杂方案。很多时候setcontextORW就是最优解没必要硬上FSOP。5. 一次完整模拟实战UAF堆题禁用execve5.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、setcontext61、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。这样一来setcontext61需要的rdx可控条件就满足了。利用链如下用UAF配合tcache dup让tcache链上出现两个相同chunk。连续两次malloc取回同一个chunk利用第一次返回的chunk修改它自身在tcache里的fd指向__free_hook。再一次malloc拿到__free_hook附近的内存向里面写入setcontext61地址。在某块可控堆内存里构造伪造结构体偏移0xa0处放ORW链地址偏移0xa8处放ret地址ORW链紧随其后。修改size记录字段为伪造结构体地址。触发delete先read再freefree进入__free_hook时执行setcontext61寄存器被恢复rsp被迁到ORW链上最终完成open、read、write。核心payload的简化写法# 1. tcache poisoning 把 __free_hook 写成 setcontext61 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两个字段。其他字段用零填充因为setcontext61在恢复那些寄存器后后续ROP链也不依赖它们的原始值。不过不同libc小版本在某些偏移上可能有细微差异稳妥做法是先在gdb里对目标libc反汇编setcontext确认0xa0和0xa8字段的含义。5.4 执行与调试要点整个利用最容翻车的地方在tcache poisoning阶段。调试动作要细每完成一步就用gdb检查tcache bin的状态确认fd有没有写对。如果poisoning失败常见表现是malloc返回的不是__free_hook而是别的堆地址。原因通常是tcache链上不止一个chunk修改fd修改错了对象。建议在调试时打印tcache binp tcache-entries[0]或者直接用pwndbg的heap命令看堆布局。确认好再继续盲目往下打只会浪费时间。触发free之后重点观察两个地方rdx是否等于伪造结构体地址rsp是否成功迁到ROP链首地址。在gdb里对__free_hook下断点然后单步跟踪b *__free_hook c # 命中断点后 p/x $rdx x/gx $rdx0xa0 x/gx $rdx0xa8如果rdx不对说明size记录字段被改的值没有正确覆盖到read的第三个参数。如果rdx正确但rsp没有迁过去说明伪造结构体的0xa0偏移填错或者setcontext61的入口地址在当前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迁移后栈不稳定的原因setcontext61用起来很爽但一旦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放在同一个chunkbuf的数据可能把后续的ROP链覆盖掉。最好把buf放在一个独立的、不会再被使用的区域。如果沙箱允许sendfile可以直接绕开readwrite用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的利用链。setcontextORW只能算是这个版本最通用的方案它需要rdx可控而rdx在2.27的题目里往往可以通过read的第三个参数残留来凑。如果rdx凑不出来我一般会先去翻libc里有没有合适的花式gadget再考虑FSOP。多准备几套备选方案遇到题目就会从容很多。另外本地调试时把每一步寄存器的变化都记下来看起来慢实际上比闷头跑exp快得多。