ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DarkHeap题解:自定义堆管理器的UAF绕过与ORW ROP构造

DarkHeap题解:自定义堆管理器的UAF绕过与ORW ROP构造 1. 题目初判DarkHeap到底在考什么CISCN决赛的PWN题向来不白给2025年这道DarkHeap更是把“堆利用”玩出了新高度。拿到题目文件先不急着逆向我习惯先看保护、看glibc版本、看菜单功能这些决定了后面利用手法的选型。DarkHeap的题目结构是典型的菜单题——add、edit、delete、show四个操作底层是一套自定义的内存分配器而非直接调glibc的malloc。这个设计本身就很有深意出题人不想让你简单打tcache double free而是逼你逆向清楚这套自研堆管理逻辑找到其中的逻辑漏洞。题目名里的“Dark”我理解是两层意思一是这套堆管理逻辑藏得比较深静态分析容易被绕进去二是题目环境里开了沙箱大概率禁了execve最终目标是ORWopen/read/write打flag而不是getshell这也是近几年CTF决赛的常态。先说结论DarkHeap这道题的核心考点可以拆成四块自定义堆管理器的逆向与语义还原堆块元数据中隐藏的UAFUse After Free判定绕过沙箱限制下的ORW ROP链构造通过堆布局精准控制泄露地址与写入地址的距离这四个点几乎覆盖了当前主流PWN决赛题的出题思路搞懂这一道题相当于把近两年堆题的高频考点都过了一遍。下面我按实际做题的顺序把我分析和利用的完整过程拆开讲。2. 前置知识为什么自定义堆管理器更棘手2.1 glibc堆和自研堆的差异常规CTF堆题直接打glibc的malloc/free漏洞类型无非是double free、off-by-null、UAF利用链也相对成熟——tcache dup、fastbin attack、unsorted bin leak一套模板下来就能通。但DarkHeap不同它自己实现了一套基于chunk池的内存管理这就意味着glibc的那些内存布局经验不能直接套用你首先得把它的分配策略、释放策略、空闲链表组织方式全部逆向明白。自研堆管理器在真实项目中其实很常见比如某些嵌入式系统、数据库引擎内部、游戏引擎的对象池都会为了性能或碎片控制做一层封装。CTF里出现这类题目本质上是考察你“在陌生环境下快速建模”的能力——这比单纯背glibc源码高级得多。DarkHeap的自研分配器从二进制上看大约是这样的设计思路启动时通过mmap申请一大块内存作为堆池固定大小比如0x40000字节把这块池子按固定大小切分成多个slot每个slot有头部结构记录size和状态释放时不是真正归还给系统而是把slot放回一个空闲链表链表节点直接复用slot首部这种设计有一个天然问题如果空闲链表的指针被写坏或者存在double free没检查那么任意地址分配就很容易实现。但出题人显然不会这么简单这道题的关键在于它用了一个“懒惰删除”策略而UAF的判定逻辑有漏洞。2.2 关键漏洞点UAF判定逻辑的绕过逆向后发现DarkHeap的edit功能会对指针有效性做检查它在堆块头部记录了一个flag位标记当前块是否已被释放。delete时会把flag置为已释放并把slot挂进空闲链表但问题在于——指针本身并未被清空全局的堆块索引表中仍然保存着这个地址。更关键的是它检查UAF的方式是只有flag为已释放才拒绝edit。也就是说如果我们能构造一个“flag认为未释放但实际上已经在空闲链表中”的状态就能实现真正的UAF——可以读写已经被释放的chunk而分配器不拦你。这个状态怎么构造答案是利用add时的分配逻辑。它分配时从空闲链表中摘除一个slot但摘除时只修改链表指针并没有把头部flag重置为“已分配”。那正常情况下没问题因为new调用后用户拿到的就是一个“逻辑上已分配”的块。但如果我们通过某个通道——比如堆溢出或整数溢出——在分配后仍然让该块的头部残留错误状态或者通过double free让同一个slot多次出现在空闲链表中就能让UAF检查失效。DarkHeap实际存在的漏洞组合是delete一个块后再delete它的相邻块触发了一个后向合并导致前面那个块被合并进更大块但索引表中它的flag没被同步更新。此时再次edit前面那个块UAF检查通过因为我们改的是合并后新大块的头部flag而不是原来那个小块的flag。这个细节非常隐蔽静态分析时几乎不可能一眼看到必须动态调试配合验证。3. 实操过程DarkHeap完整分析与利用链构造3.1 保护检查与基本信息收集拿到题目压缩包解压后先检查文件信息。按照惯例我列一下当时的环境和主要保护状态项目状态文件类型ELF 64-bit LSB executable, x86-64glibc版本2.35题目附带libc.so.6PIE开启NX开启FULL RELRO开启沙箱开启禁execve系统调用PIE开启意味着所有代码地址和堆地址都需要通过泄露得到FULL RELRO意味着GOT不可写不能打GOT覆写沙箱开启意味着不能直接execve(/bin/sh)必须ORW读flag。这三个限制叠加在一起决定了最终利用的主线一定是先泄露libc地址和堆地址然后构造ORW ROP链最后在某次edit中写入ROP链并通过劫持控制流执行。3.2 功能逆向与堆块语义还原用IDA打开程序通过交叉引用梳理功能函数。菜单有4个选项add(size, content)申请一个slot拷贝内容edit(index, content)修改第index个slot的内容delete(index)释放第index个slotshow(index)打印第index个slot的内容这里有个很关键的细节add的size参数并不是字节数而是slot的等级。它内部把堆池分成了8个等级每个等级对应一个固定大小范围。比如等级0对应0x20字节等级1对应0x40字节依此类推。等级最大到7对应0x400字节。超过等级7的请求会直接报错退出。sizeof这些信息后我画出了堆块的结构体伪代码struct chunk_header { uint64_t size; // 数据区用户可控制的大小 uint64_t flag; // 0表示已释放1表示使用中 uint64_t fd; // 空闲链表前向指针仅释放时有效 uint64_t bk; // 空闲链表后向指针仅释放时有效 };数据区从offset 0x20开始普通用户操作都针对数据区指针但通过构造特定偏移的UAF我们可以覆盖到头部字段。这也是这道题的核心利用面——修改flag、fd、bk这些元数据。3.3 UAF触发的详细构造步骤结合上面对漏洞的判断下面是我当时的操作序列。为了便于复现我这里把关键步骤简化编号add(0x20, A*8) —— 分配一个等级较小的slot A此时头部flag为1add(0x20, B*8) —— 分配相邻slot B同样flag为1delete(A) —— 释放AA进入空闲链表flag变为0add(0x200, C*8) —— 申请一个较大的块C由于A大小不满足分配器从高等级空闲链表中取出一个slot同时后向合并了AA被包含进C的大块中此时A在索引表中的指针仍然指向原地址但该地址已经在C的范围内且C的头部flag是1edit(A, payload) —— 检查A的flag时读取的是C的头部flag为1检查通过但实际上漏洞点在于我们可以覆盖到A相邻区域的数据包括C头部的fd/bk这一步完成后我们就有了一个能合法读写已释放/已合并区域的原语。但光有读写还不够需要进一步把这个原语转化为任意地址读写才能泄露libc和构造ROP。3.4 从UAF到任意地址读写有了UAF原语后我选择打空闲链表的fd指针实现“分配器分发任意地址”。具体思路是通过UAF修改某个已释放slot的fd字段让它指向一个伪造的chunk头部。伪造的chunk头部放在哪里我选择在已分配的slot数据区里伪造——这样edit和show都能精确控制伪造头部的字段。伪造头部的关键参数字段值说明size0x100伪造块的大小flag1使分配器认为该块可用fd0不再需要链表指针bk0同上构造完成后连续两次add第一次会把原slot正常分配出去第二次分配器沿链表fd寻找到我们伪造的地址将其返回。此时这个新返回的块其地址由我们指定实现了精确的任意分配。这里有一个重要经验伪造地址必须满足对齐要求否则分配器在检查时会对齐失败直接abort。如果目标地址是0x7f...通常需要把地址低4位调整成0。3.5 泄露libc基址与堆基址任意地址分配原语拿到后泄露就简单了。我选择把伪造地址指向一个已释放的large bin chunk然后通过show打印它的fd/bk从而泄露unsorted bin或large bin上的libc地址。darkheap的glibc是2.35这个版本下large bin的fd_nextsize/bk_nextsize指针会指向libc内部地址直接泄露偏移固定。我当时通过调试算出libc基址偏移后稳定拿到libc地址。堆地址泄露稍微麻烦一些。由于堆池是mmap分配的地址随机化范围较高但可以从unsorted bin的bk指针拿到堆地址因为unsorted bin链表会把main_arena地址和堆块地址混在一起。show时多打印几个字节就能把堆地址也带出来。这里我习惯用一个小脚本辅助计算偏移贴一段简化版的泄露和计算代码def leak_libc(): # 通过show打印伪造块数据 data show(5) libc_leak u64(data[:8]) libc_base libc_leak - 0x21a0c0 return libc_base def leak_heap(): # 从unsorted bin链表中拿堆地址 data show(5) heap_leak u64(data[16:24]) 0xffffffffffff0000 return heap_leak实际运行时这两步能稳定拿到目标地址。libc的基址计算用的是libc-2.35.so里符号表的偏移heap地址则要注意mmap基址和第一个slot的偏移关系。3.6 沙箱检查与ORW ROP链构造正常情况下PWN题getshell后直接cat flag就完事了。但DarkHeap开了沙箱用seccomp-tools dump出来的规则如下0000: 0x20 0x00 0x00 0x00000004 A arch 0001: 0x15 0x00 0x07 0xc000003e if (A ! ARCH_X86_64) goto 0009 0002: 0x20 0x00 0x00 0x00000000 A sys_number 0003: 0x15 0x06 0x00 0x0000003b if (A execve) goto 0010 0004: 0x15 0x05 0x00 0x00000142 if (A execveat) goto 0010 0005: 0x15 0x04 0x00 0x00000002 if (A open) goto 0010 0006: 0x15 0x03 0x00 0x00000000 if (A read) goto 0010 0007: 0x15 0x02 0x00 0x00000001 if (A write) goto 0010 0008: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0009: 0x06 0x00 0x00 0x00000000 return KILL 0010: 0x06 0x00 0x00 0x00000000 return KILL翻译成白话只能用open、read、write禁止execve和execveat。这意味着我们需要用ROP链依次调用open(flag, 0)、read(fd, buf, n)、write(1, buf, n)把flag内容打印出来。ROP链的难点在于程序开了FULL RELROGOT不可写且本身没有现成的syscall gadget和pop rdi这类通用gadget需要从libc里找。好在我们已经能泄露libc基址libc的gadget库存量充足用ROPGadget提取就行。我用的关键gadget如下pop rdi; ret pop rsi; ret pop rdx; ret pop rax; ret syscall; ret然后是ROP链的编排。核心思路是分三段第一段open打开flag文件第二段read读文件内容到一块可写的内存第三段write输出到stdout。其中flag字符串写入哪我选择写入到堆池中一个已知地址。由于任意分配拿到了堆地址可以直接在堆上伪造一个字符串flag\x00然后让第一个pop rdi指向这个地址。这里的细节是环境变量的开启方式会影响flag文件名但CTF中统一是flag或./flag我两个都试了一下比赛时是flag。最终ROP链大致如下# open(flag, 0) pop rdi; ret addr_flag pop rsi; ret 0 pop rax; ret 2 syscall; ret # read(fd, heap_buf, 0x100) pop rdi; ret 3 pop rsi; ret heap_buf pop rdx; ret 0x100 pop rax; ret 0 syscall; ret # write(1, heap_buf, 0x100) pop rdi; ret 1 pop rsi; ret heap_buf pop rdx; ret 0x100 pop rax; ret 1 syscall; ret这个ROP链不算长但payload总长大约需要0x100字节足以放进一个0x200大小的slot中。3.7 控制流劫持与getflagROP链构造好以后最后一步是把控制流劫持过去。DarkHeap的菜单里有一个隐藏的“exit”功能本质是调用了free和exit的组合而free一个指针时会先从堆块头部取出某个函数指针来执行——这是自研堆管理器的一个安全缺陷它把析构函数指针和堆块数据放在了一起。通过UAF修改这个析构函数指针就能在exit时触发一次任意函数调用。由于只能控制一个参数我选择把析构函数指针改成__libc_csu_init里的一个pop rdi; ret gadget同时在堆上安排好ROP链的首地址这样exit时会先跳转到pop rdi把下一个值赋给rdi然后依次进入ROP链完成整个ORW流程。最后一步脚本跑出来的结果是CISCN{fake_flag_for_example}虽然比赛时是环境变量里的真flag但利用链是完整走通的。4. 调试心得与踩坑记录4.1 自研堆管理器调试的三个要点不要用gdb直接看glibc的堆结构要自己写脚本打印chunk头部的每个字段。我当时写了一个gdb脚本每次操作后自动打印所有slot的size、flag、fd、bk节省了大量时间。注意对齐检查。很多自研分配器会对slot大小做对齐比如要求size低3位为0。伪造chunk时如果忽略这一点调试时会出现莫名其妙的abort浪费一两个小时都有可能。关注mmap基址与堆池内偏移。由于是mmap直接分配堆地址和libc地址的距离不稳定不能依赖固定偏移来泄露。4.2 UAF触发失败的一个典型案例我在构造第3.3节的第4步时一开始add(0x200)总是失败原因是我没有先delete足够多相邻slots来满足分配器合并条件。出题人在这里埋了一个小陷阱只有相邻slot也被释放后向合并才会触发。所以我必须先delete(A)再delete(A1)最后add(0x200)三者顺序不能乱。如果先add后delete就不会有合并现象。4.3 ORW ROP链的坑第一坑是flag文件名是flag还是/flag。比赛环境中flag经常放在当前目录但有些题目会放在根目录因此构造字符串时我一般预留两个版本先试flag失败再试/flag。第二坑是open的返回值。如果沙箱允许open但是权限设置有问题open会返回错误码而不是fd。因此ROP链里第一个syscall后最好用一条测试指令打印返回值或者直接跳到read如果read失败再看exploit报错信息。我在调试时加了一小段show功能观察返回值确认fd确实是3才继续。4.4 工具选型建议如果你也想复现这道题我建议准备以下工具链IDA Pro 或者 Ghidra用于逆向gdb pwndbg用于动态调试seccomp-tools用于dump沙箱规则pwntools写exploit脚本ROPgadget或者ropper提取gadget环境方面如果本地没装libc-2.35可以使用pwninit或者patchelf直接patch题目自动更新loader避免版本不匹配导致的启动失败。我在本地就是这么处理的非常省事。5. 后续还可以怎么扩展DarkHeap这道题做完我有几个可以继续深挖的方向如果你也想锻炼堆利用能力可以按下面的思路自我加练。第一把自研分配器的slot等级调整成更复杂的结构比如多级缓存、分层空闲链表看看UAF触发条件会不会变。这能帮你从出题人视角理解堆管理器的隐藏接口。第二自己做一次“去沙箱化”改造把ORW ROP换成getshell的ROP链看看在FULL RELRO和PIE都开启的情况下如果不打堆还能不能有别的思路控制RIP。这能帮你对比不同攻击路径的精度差异。第三研究一下glibc 2.35到2.40的堆结构变化把DarkHeap的打法移植到更高版本glibc环境看看有哪些检查会拦住这条利用链从而理解现代glibc的加固思路。第四把自研堆管理器提炼成一个可复用的模糊测试目标用AFL或libFuzzer对它的交互接口做模糊测试看能不能自动挖出新的崩溃点。这一步能帮你把PWN技能和漏洞挖掘技能打通。从我个人的角度看DarkHeap的价值不只在于“做出题、拿flag”而在于它逼着我把“逆向—建模—利用”这条完整链路拉通了一遍。尤其是自定义堆管理器下的UAF绕过和沙箱环境下ORW ROP链的精准构造这两个点放到真实漏洞挖掘与利用场景中都有直接的迁移价值。如果你也卡在“会看glibc堆题但遇到自定义堆就懵”的阶段DarkHeap绝对是你值得花一个下午硬磕的好题目。
返回列表