ARTICLE DETAIL

资讯详情

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

how2heap 堆利用入门:从 fastbin dup 到 unsafe unlink 的 glibc 攻防实践

how2heap 堆利用入门:从 fastbin dup 到 unsafe unlink 的 glibc 攻防实践 1. how2heap 到底是什么以及我为什么建议你从头啃一遍如果你已经在 pwn 圈子里潜水过一阵大概率听过how2heap这个名字。它是 shellphish 团队维护的一个开源项目仓库里按难度和技巧类型排列了几十个精心构造的堆利用案例每个案例都是一个独立的 C 文件配合对应的编译脚本用来演示 Linux 下 glibc 堆管理器ptmalloc2的各种攻击手法。从最简单的堆溢出覆盖 chunk 元数据到 fastbin 伪造链、unsorted bin 攻击、large bin 劫持、tcache poisoning几乎覆盖了 CTF 堆题的主流考点。我最初接触这个项目时犯过一个典型的错误——像刷题一样从第一个文件跑到最后一个文件每道题跑通就算过结果真到比赛里遇到一个变种题就卡住。后来我才意识到how2heap 的正确打开方式不是跑通利用而是看懂利用背后的分配器决策链为什么这个技巧在当前 glibc 版本下有效堆管理器在哪个环节出现了逻辑漏洞攻击者控制的输入到底影响了哪一块内存这篇内容是我重新系统学习 how2heap 的第一篇笔记我会顺着一条自认为比较合理的学习路径来写先搭好环境再补一遍堆管理器的核心机制然后逐个拆解经典利用技巧的完整链路。建议你准备一个 Linux 环境把仓库 clone 下来一边看文章一边跑实验。这篇文章的目标读者是已经会写基本栈溢出、了解 GOT 劫持但对堆利用还停留在听说过阶段的入门者。如果你连 chunk 和 bin 的概念都没接触过我会在第 2 部分尽量讲透后面的内容才不至于悬空。2. 环境搭建这一步决定了你要不要花两个小时处理 glibc 版本兼容问题2.1 为什么 glibc 版本比编译器版本更重要堆利用和栈利用有一个本质区别栈利用主要依赖 CPU 架构和内存布局但堆利用的核心攻击面是glibc 堆分配器的内部实现。同一个漏洞利用技巧在 glibc 2.23 和 glibc 2.35 上的效果截然不同。比如 fastbin dup 技术在 2.23 上是万金油但 glibc 2.27 引入 tcache 之后就被 tcache dup 取代到了 2.29 又加了 tcache 键值检查利用条件又变严了。Isolation所以第一步不是急着编译源码而是确定你本机可用的 glibc 版本然后根据版本选择学习哪些技巧。我用的是 Ubuntu 20.04默认 glibc 2.31这个版本同时存在 tcache 和 fastbin 机制既能学 tcache 相关技巧也保留了 fastbin 的利用空间算是比较理想的学习环境。提示先跑ldd --version确认你的 glibc 版本。如果你用的发行版默认 glibc 太新比如 Ubuntu 22.04 的 2.35有些老的利用技巧可能因为新增检查而失效跑不通不代表你没理解而是版本不对。2.2 用 docker 隔离多版本 glibc实际学习过程中你会发现不同技巧验证需要不同 glibc 环境。最省事的方式是准备一个基础镜像把 how2heap 和 pwntools 都装好然后通过切换镜像来测试不同 glibc 版本下的行为。我现在的做法是维护两个镜像一个基于 Ubuntu 16.04glibc 2.23用于学习 fastbin/unlink 等经典技巧一个基于 Ubuntu 20.04glibc 2.31用于 tcache 相关技巧。编译时注意 how2heap 仓库里每个文件都是独立的用 gcc 编译时建议关掉 PIE 和栈保护避免非堆因素干扰分析gcc -m64 -no-pie -fno-stack-protector -z execstack -o fastbin_dup fastbin_dup.c如果你不想折腾 docker也可以用patchelf直接修改二进制的动态链接器路径来指定特定 glibc但这种方式对新手不够直观我建议第一条路还是 docker 最稳妥。2.3 调试工具的配置建议堆调试比栈调试更依赖工具。栈上你用一个x/20gx $rsp就能看明白堆上你得同时跟踪 chunk 地址、bin 链表、tcache 条目和 arena 状态手动操作极易出错。我每天必用的三个工具GEF比 pwndbg 对 glibc 内部数据结构的解构更清晰尤其是heap bins命令能直接列出各 bin 里的 chunk 链表。pwntools写利用脚本的神器gdb.attach()可以在脚本运行到指定位置时自动拉起调试器。heapinfo / parseheap这两个小脚本在 CTF 中更常见适合快速查看堆布局我在分析时习惯用 GEF 看完整体布局再用 parseheap 核对细节。工具装好之后我强烈建议你做一个额外动作把 how2heap 仓库里的glibc_2.23等不同版本目录下的二进制文件编译出来后续讲解中我会以fastbin_dup.c和unsafe_unlink.c两个案例为主线你需要能跟得上。3. 必须先补的基础chunk、bin、malloc/free 背后的决策逻辑3.1 一切从 chunk 开始堆上的内存块在 glibc 中叫 chunk它不只是你 malloc 请求的那几字节而是带有元数据的结构体。一个已分配的 chunk 长这样----------------------------- | prev_size8字节仅前块释放时使用| ----------------------------- | size8字节包含标志位 | ----------------------------- | user datafd 指针、内容等 | -----------------------------这里最关键的是size字段的低 3 位PREV_INUSE表示前一个 chunk 是否被使用IS_MMAPPED表示是否通过 mmap 分配NON_MAIN_ARENA表示是否属于主分配区。这三位的状态直接决定了堆管理器做合并和切割时的行为后续所有利用技巧几乎都绕不开对 size 位的伪造。需要特别强调的是chunk 的 user data 部分在释放后会复用为链表指针。当一个 chunk 被 free 后它的前 8 字节 user data 变成 fd指向下一个空闲块如果是 large bin 还会有 bk 和 fd_nextsize 等额外字段。也就是说攻击者如果能在 free 之前往 user data 里写内容free 之后这些内容会被分配器当作链表指针来用——这正是大量堆利用的根本入口。3.2 bin 的层级结构tcache、fastbin、unsorted bin、small/large binglibc 的分配器维护了几组不同层级的空闲链表释放的 chunk 会按大小进入不同的 binbin 类型支持的 chunk 大小关键特征tcache每线程 64 个槽单个上限默认 0x410含元数据后进先出无检查低版本最高效fastbin0x20 ~ 0x8010 条后进先出不合并无检查2.23unsorted bin任意大小单链表先释放的进这里作为缓冲区small bin小于 0x400不含先进先出双向链表large bin大于等于 0x400双向链表按大小排序含 fd_nextsize我解释一下这几个 bin 之间的协作关系。当你 free 一个 chunk 时分配器先看它是不是足够小能进 tcache如果 tcache 满了且大小在 fastbin 范围内就进 fastbin否则进 unsorted bin。当你 malloc 请求一块内存时分配器从 tcache 先找然后 fastbin再然后 unsorted bin期间还会触发合并和整理操作把 unsorted bin 里的 chunk 按大小放到 small/large bin。这个流程里藏着很多可利用的窗口期。比如 fastbin 的 fd 指针在 2.23 上没有任何完整性检查你改写 fastbin 里某个 chunk 的 fd下一次 malloc 就可能返回任意地址——这就是 fastbin dup 和 fastbin attack 的基本原理。而 unsorted bin 在 malloc 扫描时会把 chunk 从链表取下如果我们能劫持 unsorted bin 的 fd 和 bk就能实现任意地址写延续出 unsorted bin attack。3.3 free 的时机与合并逻辑合并是堆管理器为了减少碎片做的优化但这恰好也是攻击者最喜欢利用的点。当一个 chunk 被释放分配器会检查物理相邻的前后 chunk 是否也是空闲状态如果空闲就合并成一个更大的 chunk然后放入 unsorted bin。合并操作需要读写相邻 chunk 的prev_size和size字段。如果攻击者能控制当前 chunk 的 fake chunk 内容和 size就能诱导分配器把物理相邻错认为存在于 fake chunk 旁从而实现任意地址读写。这在后面拆解 unsafe unlink 时会具体展开。理解 malloc/free 的决策顺序之后看 how2heap 的源码就不会一头雾水了。每一个技巧本质上都是在这个决策链上找到某个可以被我们控制的信息干扰分配器的判断。4. 手把手拆解 fastbin dup为什么同一个 chunk 可以分配两次4.1 初始源码与利用预期fastbin_dup.c 是最经典的入门案例源码很短作用路径很清晰int main() { unsigned long long *ptr0 malloc(0x20); unsigned long long *ptr1 malloc(0x20); fprintf(stderr, Allocating 3 buffers...\n); free(ptr0); free(ptr1); free(ptr0); // Double free fprintf(stderr, Now malloc...\n); void *a malloc(0x20); void *b malloc(0x20); void *c malloc(0x20); fprintf(stderr, 1st malloc(0x20): %p\n, a); fprintf(stderr, 2nd malloc(0x20): %p\n, b); fprintf(stderr, 3rd malloc(0x20): %p\n, c); }代码提交了三次 free 请求其中 ptr0 被释放了两次然后连续三次 malloc。运行结果会显示第一次 malloc 返回 ptr0第二次返回 ptr1第三次返回的还是 ptr2也就是 ptr0。注意这里的关键是为什么 fastbin 的 double free 没有被检测出来。在 glibc 2.23 中fastbin 的 free 流程只检查了当前释放的 chunk 地址是否等于 fastbin 链表的头节点地址但 ptmalloc 在 free 之前会先做一次malloc_consolidate相关逻辑的判断导致检查并不覆盖所有重复场景。更准确地说是 glibc 2.23 的 fastbin 并没有对 chunk 是否已经在链表中做完整遍历只简单比较了头节点而两次 free 之间插入了一个其他 chunkptr1作为新的头节点所以第二次释放 ptr0 时检查通过。4.2 从堆布局视角理解同一个地址 malloc 两次为了让你直观理解这里发生了什么我用 gdb 跟踪一遍内存状态。第一次 free(ptr0) 之后fastbin 链表头是 ptr0fd 为 NULL。第二次 free(ptr1) 后头变成 ptr1fd 指向 ptr0。第三次 free(ptr0) 时由于头节点是 ptr1不等于 ptr0检查通过ptr0 被再次插入头部此时链表的顺序是fastbin 头 - ptr0 - ptr1 - ptr0 - NULL是的你注意到链表里出现了环——ptr1 的 fd 原本指向 ptr0现在 ptr0 的 fd 又指向 ptr1。后面三次 malloc(0x20) 的操作从链表头部依次弹出第一次拿到 ptr0第二次拿到 ptr1第三次又拿到 ptr0。第三次 malloc 时返回的指针和第一次 malloc 的地址完全一样但此时这个地址已经被分配出去了两次。如果我们能在第一次和第三次之间往这块内存写入数据比如通过另一个指针就能在第三次分配时拿到一个指向自己伪造数据的 chunk。4.3 从 double free 到任意地址写fastbin dup 的进阶用法单纯的 double free 只能让堆管理器乱套真正有威胁的是把它升级成任意地址写。思路是在 fastbin 链表中塞入一个伪造的 chunk 地址使得后续 malloc 返回这个伪造地址。举个例子我们在堆上伪造一块内存其开头 8 字节写入目标地址比如某个全局变量的地址减去 0x18然后让 fastbin 头指向这个伪造 chunk。下一次 malloc 时分配器检查这个地址的 size 字段必须在 fastbin 范围内把它弹出链表并返回。此时返回的指针指向了我们伪造的位置往里写数据就能实现任意地址写。但是如果按照 fastbin_dup.c 的例子链表里有三个节点ptr0 - ptr1 - ptr0我们没法直接控制 fastbin 头指向伪造 chunk。在真实的利用场景里会更复杂一些通常要配合malloc(0x30)等特定的大小请求来精确控制弹出的顺序或者使用 fastbin_dup_into_stack 这类变种。how2heap 里专门有fastbin_dup_into_stack.c讲解这个进阶手法核心是修改某个 chunk 的 fd 让它指向栈上的 fake chunk前提是你能往这个 chunk 里写数据。伪造的 fake chunk 的 size 值必须与实际申请的 chunk 大小匹配否则分配器会报错。4.4 这个技巧在 2.27 的版本里被简单粗暴地取代了说到这里我猜你会问为什么现在 CTF 里感觉 fastbin dup 很少见了因为 glibc 2.27 引入了 tcachetcache 的 double free 检测更薄弱而且 tcache 本身没有 size 的严格校验所以更简单直接的利用方式就出现了。同样是 double free利用 tcache 只需要申请同大小 chunk然后让 tcache bin 里的同一个地址被分配两次就能快速实现任意地址写。但这不代表 fastbin dup 不值得学。两个原因第一很多预留的后门题目还是会强制使用 old glibc深圳的很多比赛到现在仍然用 2.23 的镜像第二fastbin dup 逼你理解链表结构这个基础能力在学 large bin attack 时会反复用上。4.5 实操起来的三个坑第一注意malloc(0x20)实际分配的 chunk 大小是 0x30 而不是 0x20。glibc 的 chunk 大小会按 16 字节对齐并加上 0x10 的元数据0x20 请求对应 0x30 的 chunk。所以在伪造 size 字段时一定要按实际 chunk 大小写而不是请求大小。第二double free 后的链表顺序很容易看晕。建议用 GEF 的heap bins fast命令查看链表确认每个节点的 fd 指向别靠猜。第三fastbin 的 tcache 交互问题。在 glibc 2.31带 tcache上free 一个小于 0x410 的 chunk 时优先进入 tcache 而不是 fastbin。如果你在使用新版环境fastbin dup 的复现路径会被 tcache 挡住必须先把 tcache 填满默认 7 个才能让后续的 free 进入 fastbin。这在 how2heap 的很多例子里都写了for (int i 0; i 7; i) malloc(...)这样的填充代码。5. unsafe unlink一个老古董里藏着堆利用的元思想5.1 前置背景unlink 到底是干什么的unlink 是堆管理器在合并空闲 chunk 时执行的操作核心是从双向链表中摘除一个节点。分配器在 free 一个 chunk 时会检查前后两个物理相邻 chunk如果它们是空闲的就把它们和当前 chunk 合并成一个更大的 chunk。合并时需要把相邻空闲 chunk 从它们所在的 bin 链表中摘除这个摘除动作就是 unlink。Fastbin 是单向链表不需要 unlink但 small bin 和 large bin 是双向链表需要 unlink 来维持链表结构。unlink 在旧版本的宏定义大致如下2.23#define unlink(P, BK, FD) { FD P-fd; BK P-bk; FD-bk BK; BK-fd FD; }这段宏的逻辑是设 P 是 bck 节点取它的 fd 赋给 FD取它的 bk 赋给 BK然后把 FD-bk 写成 BKBK-fd 写成 FD。正常情况这叫链表的摘除。但如果 P 不是真正的链表节点而是攻击者伪造的那P-fd和P-bk就能被任意指定。此时FD-bk BK就等价于向任意地址写入任意值。5.2 unsafe unlink 的触发条件实际上经典的 unsafe unlink 出现在一个场景攻击者可以通过堆溢出修改某个堆块的内容然后在它后面的一个 victim chunk 被释放时overlapping 的物理相邻检测会误以为 victim 的前一个 chunk 是空闲的从而触发 unlink。更常见的利用是在堆上放置一个 fake chunk在 fake chunk 里构造好 fd 和 bk然后想办法让堆管理器对一个 fake chunk 执行 unlink。how2heap 里 unsafe_unlink.c 的示例做法是在栈上放一个包含函数指针的结构体利用堆溢出构造一个覆盖全局指针global_ptr的场景。具体来说源码会构建一个 chunk0 和一个 chunk1chunk0 有一个 heap overflow 可以溢出到 chunk1在 chunk1 的 prev_size 位置伪造了一个 fake chunk并让 fake chunk 的 fd 和 bk 指向global_ptr - 0x18和global_ptr - 0x10的位置。当 chunk1 被 free 时堆管理器认为其前一个 chunkfake chunk空闲执行 unlink导致global_ptr被改写为global_ptr - 0x18。5.3 完整的利用链从溢出到任意写我把 unsafe_unlink.c 的利用链拆成四个阶段你就理解它为什么被称为堆利用的元思想了阶段一布局。申请两个堆块 A 和 B。A 的大小要能容纳 fake chunkB 要保证和 A 物理相邻。在 A 的 user data 区域构造 fake chunk并把 fake chunk 的fd写为target_addr - 0x18bk写为target_addr - 0x10。这里的 target_addr 是你想改写的全局变量地址一般是一个函数指针或堆指针。阶段二溢出。通过 A 的溢出覆盖 B 的prev_size字段写成 fake chunk 到 B 的偏移量和size字段的PREV_INUSE位清零让分配器认为前一个块是空闲的。阶段三触发。释放 B。分配器发现前一块空闲于是把 fake chunk 和 B 合并合并时执行 unlink(fake chunk)。由于 fake chunk 在 A 的 user data 区域fd 和 bk 都可以被我们完全控制。阶段四获得能力。unlink 执行后target_addr处被写入了target_addr - 0x18通过FD-bk BK这步实现。这样 target_addr 指向了自身附近的内存当我们往 target_addr 里写入数据时实际会写到target_addr - 0x18等于获得了一个以 target_addr 为基准的任意偏移写能力。CTF 里通常会把这个 target 指向一个堆指针或 GOT 表项最终完成控制流劫持。5.4 新版 glibc 的 unlink 检查机制glibc 2.23 之后的 unlink 宏加入了头节点验证if (__builtin_expect (FD-bk ! P || BK-fd ! P, 0)) malloc_printerr (corrupted double-linked list);也就是说unlink 前会检查P-fd-bk是否还等于 PP-bk-fd是否等于 P。如果我们要伪造的 fake chunk 无法满足这个双向一致性unlink 就会直接报错退出。攻击者必须精心构造P附近的内存使得在FD-bk和BK-fd的两次解引用都能恰好回到 P。这就是为什么后面出现了 unlink 的绕过技巧比如利用global_ptr指向的堆指针充当部分校验也对 new 手更不友好了。但这个历史包袱有一个好处理解 unsafe unlink 能让你彻底掌握双向链表的摘除本质是两次任意内存写这个核心观点。后面学的 tcache stashing unlink attack、large bin attack 都是围绕如何让分配器对攻击者可控的节点执行类似链路操作展开。5.5 一个真实的调试复现过程记录我在复现 unsafe_unlink.c 时遇到过一个很典型的报错corrupted double-linked list。排查过程如下首先用 GEF 在 free 处下断查看调用 unlink 时的传入参数。通过x/8gx $rdi查看待 unlink 的 chunk 地址。然后查看这个地址的 fd 和 bk再分别检查fd-bk和bk-fd是否指向原 chunk。当时我发现 fake chunk 的 fd 指向global_ptr - 0x18但global_ptr在 unlink 执行前还未初始化导致FD-bk访问了未初始化的内存自然不满足校验。解决办法是先给 global_ptr 赋一个指向 fake chunk 的初值这样FD-bk才能等于 P。这类坑在实战中非常常见。你在手工构造 fake chunk 时必须反复确认 fake chunk 的 fd 和 bk 指向的内存区域里是不是已经有合适的值凑出双向一致性。如果无法满足换一种伪造方式比如把 fake chunk 放在已经可控的堆区域。6. 不同 glibc 版本对利用技巧的影响对照学习 how2heap 时最让人头疼的就是版本适配。我整理了一张常用技巧在各 glibc 版本上的可用性对照表方便你排查问题时快速定位利用技巧glibc 2.23glibc 2.27glibc 2.31glibc 2.35fastbin dup直接可用可用需绕过 tcache可用需填满 tcache可用需填满 tcachetcache dup无 tcache直接可用需绕过 key 检查需绕过 key 检查unsafe unlink直接可用可用需满足检查困难困难unsorted bin attack直接可用可用受限受限large bin attack直接可用可用可用可用需绕过新检查tcache stashing unlink无 tcache不受影响适用适用这里解释一下几个判断glibc 2.23 是经典时代几乎所有技巧都能玩2.27 引入 tcache 后小堆块分配优先进入 tcachefastbin 的利用必须配合 tcache 填充2.29 在 tcache 的 free 里加入了 key 检查机制导致 double free 不再那么顺手2.31 进一步调整了 tcache 的 7 次上限以及一些 size 检查2.35 起大规模堆块的一些链表操作增加了完整性检查unsafe unlink 这类技巧基本退环境。如果你刚开始学我建议直接锁定 glibc 2.23 或 2.27把前面讲的 fastbin dup 和 unsafe unlink 反复练熟。这两个版本对应的 Ubuntu 镜像分别是 16.04 和 18.04docker 拉取很方便。7. 学习过程中最容易踩的坑和我的一点调试习惯踩过的坑足够多之后我总结了几条对初学者最有价值的经验按重要性从高到低列出来坑一不看实际分配的 chunk 大小就伪造 size 字段。这个几乎每个新手都踩。记住规则glibc 的 chunk size 用户请求大小按 16 字节对齐 8prev_size再按 16 字节对齐。所以malloc(0x18)的实际 chunk 是 0x20malloc(0x20)是 0x30malloc(0x28)也是 0x30。伪造 fake chunk size 时只写低字节为实际值高字节的 PREV_INUSE 等标志位不要落掉。坑二忽略了 tcache 的拦截效应。在 glibc 2.27 上free 小于 0x410 的 chunk 默认进 tcache而不是你预期的 fastbin。如果你在用新版环境复现 fastbin 技巧得先填满 tcache默认 7 个或者申请大于 0x410 的 chunk 绕过。如何填满how2heap 的源码注释都会写别跳过。坑三调试时只看寄存器不看链表结构。堆调试的核心动作是看链表。用 GEF 时我推荐的流程是每次 malloc/free 之后执行一次heap bins查看所有 bin 的链表状态释放前用x/8gx查看 chunk 的 fd/bk释放后再用x/8gx对比变化。这样你能直观看到每步操作对内存的影响而不只是盯着寄存器猜。坑四忽略了 unlink 检查与 fd/bk 指向内容的联动。unsafe unlink 里FD-bk ! P的检查要求你构造的 fd/bk 指向的内存里必须预先有一个能恰好等于 P的值。实际利用时这个前置条件常常靠 fake chunk 本身或者 fake chunk 前面的一个全局变量来满足。动手之前先在草稿纸上把 fd 指向的地址写到目标内存区看看它解引用后的 bk 是不是 P做完这个确认再写代码。再分享一个调试习惯我在做堆题时习惯把 pwntools 脚本和 gdb 配合到极致的做法是——在脚本里先用gdb.attach()附加调试然后单步到 free/malloc 调用处分别打印调用前后的 bin 链表。这个过程贴一段我在 fastbin dup 里用过的脚本片段你可以直接改from pwn import * p process(./fastbin_dup) gdb.attach(p, b *main120 heap bins fast c heap bins fast x/20gx main_arena ) p.interactive()8. 下一步学什么以及我个人的学习路线建议如果你把 fastbin dup 和 unsafe unlink 都跑通且能不看答案自己讲清楚原理了说明你已经具备了堆利用的基础直觉。这时候我建议按下面的顺序往后推进每一层都建立在前一层的认知上tcache dup / tcache poisoning理解 tcache 的结构学会修改 tcache 链表的 fd 指向任意地址。这是目前 CTF 出镜率最高的入门级技巧。house of spirit理解如何往 fastbin 里塞入一个伪造的 chunk配合上一阶段的 tcache poisoning 一起用。unsorted bin attack理解 unsorted bin 的链表操作如何实现任意地址写一个大数它是很多后续攻击的跳板。large bin attack理解 large bin 按大小排序的特性这种攻击能稳定地改写某个地址通常会配合堆地址泄露。house of serieshouse of force、house of einherjar、house of orange、house of cat 等这些属于综合技巧每种都有独特的前置条件和利用链等前面基础扎实后再逐个攻破。我的个人建议是每学一个新技巧不要只看 how2heap 源码要自己动手写一个触发该漏洞的 CTF 环境或者拿一个小题把源码包装成题目来做。how2heap 的每个案例是底料真正上桌的菜还是那些实战题。我在学习过程中会把 how2heap 里每个案例的原理做成一张卡片背面写该技巧在哪个环节对分配器做了什么假设复习时只看卡片回忆回忆不起来的才重新看源码。堆利用是一条越学越觉得深的路但好消息是它的基本功就那么几样理解 chunk 结构、理解 bin 链表、理解 malloc/free 决策链。把这三点吃透了后面所有技巧都是排列组合。下一篇我会写 tcache 相关的三个技巧dup、poisoning 和 stash 以及它们和 fastbin 技巧的联动方式到时候我们可以拿真实题目来验证今天学的这几种思路。
返回列表