☰
DarkHeap题解:自定义堆管理器的UAF绕过与ORW ROP构造
2026/10/21 12:34:16 网站建设 项目流程

1. 题目初判:DarkHeap到底在考什么

CISCN决赛的PWN题向来不白给,2025年这道DarkHeap更是把“堆利用”玩出了新高度。拿到题目文件,先不急着逆向,我习惯先看保护、看glibc版本、看菜单功能,这些决定了后面利用手法的选型。

DarkHeap的题目结构是典型的菜单题——add、edit、delete、show四个操作,底层是一套自定义的内存分配器,而非直接调glibc的malloc。这个设计本身就很有深意:出题人不想让你简单打tcache double free,而是逼你逆向清楚这套自研堆管理逻辑,找到其中的逻辑漏洞。

题目名里的“Dark”我理解是两层意思:一是这套堆管理逻辑藏得比较深,静态分析容易被绕进去;二是题目环境里开了沙箱,大概率禁了execve,最终目标是ORW(open/read/write)打flag,而不是getshell,这也是近几年CTF决赛的常态。

先说结论,DarkHeap这道题的核心考点可以拆成四块:

  • 自定义堆管理器的逆向与语义还原
  • 堆块元数据中隐藏的UAF(Use 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-64
glibc版本2.35(题目附带libc.so.6)
PIE开启
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个slot
  • show(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触发的详细构造步骤

结合上面对漏洞的判断,下面是我当时的操作序列。为了便于复现,我这里把关键步骤简化编号:

  1. add(0x20, "A"*8) —— 分配一个等级较小的slot A,此时头部flag为1
  2. add(0x20, "B"*8) —— 分配相邻slot B,同样flag为1
  3. delete(A) —— 释放A,A进入空闲链表,flag变为0
  4. add(0x200, "C"*8) —— 申请一个较大的块C,由于A大小不满足,分配器从高等级空闲链表中取出一个slot,同时后向合并了A,A被包含进C的大块中
  5. 此时A在索引表中的指针仍然指向原地址,但该地址已经在C的范围内,且C的头部flag是1
  6. edit(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 RELRO,GOT不可写,且本身没有现成的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 控制流劫持与getflag

ROP链构造好以后,最后一步是把控制流劫持过去。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(A+1),最后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绝对是你值得花一个下午硬磕的好题目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询