
某年某顶级安全会议的 CTF 总决赛上蓝队防线组出了一道很有意思的攻防结合赛题名字叫疯狂之屋House of Madness。这道题表面上是一个标准堆利用题但赛方给它套了蓝队防线的壳要求参赛者不光要打穿它还得说清楚每一步利用背后的原理并给出对应的防御方案。自己实际做下来最大的感受是以前做 pwn 题拿到利用链就撤但这次要求边攻边守把漏洞根因、利用机制、防御手段串成一条完整链路整个人的思路层次完全不一样了。这篇文章会把当时分析这道题的全过程整理出来从环境还原、静态分析、漏洞根因到利用链设计、防御加固建议再到调试排错的经验教训全部按步骤拆开讲。如果你也在刷堆题或者在做服务端安全、内网安全这些方向这篇文章应该能帮你建立一套既能攻进去又能扛得住的完整分析视角。1. 赛题定位与挑战背景1.1 疯狂之屋到底是个什么样的题先解释一下名字。CTF 圈子里用House of XXX命名的堆利用技术有很多比如 House of Spirit、House of Einherjar、House of Orange、House of Lore它们都是针对 glibc 堆管理器的经典利用手法。这道题叫 House of Madness并不是 glibc 官方技术名词而是出题人自创的名字。按我后来翻官方说明和复盘材料看到的说法出题人想表达的是通过制造一连串让堆管理器发疯的操作让它的记账逻辑走向失控状态。题目的载体是一个模拟展品登记系统。程序启动后先打印一个菜单有新增展品、编辑展品描述、删除展品、查看展品详情四个功能。每个展品在堆上分配两个对象一个用于存储展品基本信息的管理结构体一个用于存储展品名称字符串的缓冲区。看起来就是一个教科书级的菜单题。但它的特别之处在于后半阶段的评分。打完得到 shell 或者读取到 flag 之后裁判组会要求选手提交一份技术分析报告内容必须覆盖漏洞根因分析、利用步骤拆解、对应防御策略三个部分。也就是说攻击成功只是第一层过关真正拉开分数差距的是后面这部分蓝队思维的内容。1.2 蓝队防线的考核逻辑Blue Team Village 这类活动气氛和纯进攻型 CTF 不太一样。它强调的核心是站在防守方视角理解攻击。换成实战场景你不可能光盯着攻击者的利用链看你还得知道该在哪个环节加防护、该在哪个环节做监控、哪些攻击特征值得写进检测规则。所以这道题的具体考核逻辑是分层的第一层跑通利用链拿到目标内容证明你理解漏洞第二层能解释每一步操作对应堆管理器的什么行为第三层能提出本方可落地的加固措施并说明为什么这些措施能阻断该利用链我当时做完第二层之后第三层想得比较浅只写了升级 glibc开沙箱之类的空话结果在报告中吃了大亏。后来复盘才意识到题目要的防御方案是针对这条利用链的逐点防御不是那种放之四海而皆准的安全建议。这一点我在本文第 5 部分会详细展开。1.3 这道题适合什么人如果你有这些背景读这篇文章收益最大学过堆利用基础但没系统梳理过漏洞如何一步一步变成任意代码执行在做服务端安全、系统安全相关工作希望把 CTF 里学到的内存安全知识迁移到工程防御里备战攻防演练想理解攻击者的利用思路从而写出更有效的检测规则和加固方案如果你是完全没碰过 glibc 堆管理的新手前面可以直接从第 2 部分开始看我会尽量把每个原理用大白话讲清楚。遇到实在陌生的专业名词可以先当成黑盒里的一个内部机制来看跟着分析思路走完一遍回头再看基础书会顺很多。2. 环境还原与静态分析2.1 拿到二进制之后的第一轮检查任何 pwn 题拿到手第一步永远不是急着开 IDA而是先看文件属性和安全机制。这道题给的是一个 64 位 ELF 文件没有给源码只有二进制和一份简单的运行说明。按照我的固定流程先跑两条命令整理基本信息。file house_of_madness checksec --filehouse_of_madnessfile 命令的输出显示这是一个 dynamically linked、stripped 的 64 位 ELF也就是说我们在 IDA 里看到的符号会很有限很多函数名需要靠识别。checksec 的结果很关键。这道题开启了全 RELROGOT 表在加载后变成只读不能直接改 GOTStack Canary栈上存在金丝雀值栈溢出类利用基本被堵死但本题不是栈题NX栈和堆都不可执行显然不能直接注入 shellcodePIE地址随机化开启程序基址不固定这意味着利用链不能走改 GOT、不能走栈溢出、不能直接执行堆里的代码。必须从信息泄漏开始逐步构造任意地址写的原语最后靠劫持控制流或者触发已有函数完成目标。我同时又跑了一遍 strings 和 readelf目的是快速看看有没有什么明晃晃的辅助信息比如后门函数、可疑的 system 字符串、flag 文件名称等。结果很干净什么都没有。这说明出题人没用放水型后门你必须老老实实走完整个利用过程。2.2 程序功能逻辑与堆对象模型用逆向工具还原出来的程序逻辑是一个四功能菜单程序add输入展品名称和描述内部创建一个管理节点和一块内容缓冲区edit输入索引修改某个展品的描述内容delete输入索引删除某个展品show输入索引查看某个展品的详情管理节点在堆上的结构体长这样用逆向还原的伪代码表示typedef struct { char *name; // 指向名称缓冲区 int size; // 内容大小 int magic; // 校验字段固定值 void (*print)(struct Item*); // 展示回调函数指针 } Item; Item *g_items[32];程序在 add 时会先 malloc 一个 Item 结构体再 malloc 一个 name 缓冲区然后把两个地址都填进 g_items 数组中对应的位置。show 功能会调用 Item 里的回调函数指针来打印内容。这个回调函数指针的设计很关键。它相当于在堆里放了一个可以劫持的函数指针在后文构造利用时它就是我们要攻击的目标对象。现实中的很多 C 程序也广泛存在类似的函数指针模式比如设备驱动里的 ops 结构体、C 对象里的虚表指针、协议栈里的回调注册表所以这题的设计非常贴近实际。2.3 两处关键代码层面的问题静态分析还原出关键的删除和编辑逻辑后我发现了两处问题。第一处在 delete 功能里void delete_item(int idx) { if (idx 0 || idx 32) return; if (g_items[idx] NULL) return; free(g_items[idx]-name); // 注意没有执行 g_items[idx] NULL g_items[idx]-magic 0; }源代码确实把 magic 字段清零了但完全没有把 g_items[idx] 这个指针置空。这是一个典型的 use-after-free节点对象本身没有被释放只是它指向的 name 缓冲区被释放了而数组里仍然保存着这个节点的地址因此后续仍然可以对这个节点调用 edit / show。第二处在 edit 功能里void edit_item(int idx, char *data, int len) { if (idx 0 || idx 32) return; if (g_items[idx] NULL) return; if (g_items[idx]-magic ! 0xdeadbeef) return; Item *item g_items[idx]; int real_len (len 4) | (len 28); // 某种可疑的整数变换 if (real_len item-size 0x10) return; // 边界检查看似存在 memcpy(item-name, data, len); // 但 memcpy 用的是原始 len }问题就出在长度校验和实际拷贝长度不一致。程序先对 len 做了一次诡异的整数变换得到 real_len用它做边界检查可 memcpy 时却使用最原始的 len。这意味着我可以通过一个精心设计的 len让 real_len 通过检查而实际 len 远超缓冲区大小从而产生堆溢出。这种校验值和实际使用值不一致的漏洞在真实世界里同样很常见。比如很多解析器从报文头部取一个长度字段做校验后面又从头部的另一个字段取长度做拷贝一旦两个字段的含义没有对齐就会产生溢出。这提醒我们在安全审查中永远要追问校验用的值和实际用的值是不是同一个值。3. 漏洞根因与原理拆解3.1 为什么是堆管理器发疯理解了漏洞位置之后接下来要回答的问题就是这些看似普通的 UAF 和堆溢出是如何组合成House of Madness的要讲清楚它必须先回顾 glibc 堆管理器在分配和释放时的几个基本行为。这里我用白话来解释。在 glibc 中每个堆块chunk都有自己的头部数据里面记录着上一个块和当前块的大小、是否在使用中等信息。这些头部数据被称为元数据。当某个块的元数据里的 size 字段被改掉free 这个块的时候堆管理器就会按照错误的大小去找对应的邻居进行合并。如果这个邻居被伪装成一块合法的空闲块堆管理器就会把整块区域合并成大块放进 bin 链。tcache 是一种在每个线程内独立的小型空闲块缓存。它在分配时优先返回缓存里的块并且对块的校验不如 unsorted bin 完善是很多利用的切入点。unsafe unlink / 合并攻击利用的正是堆管理器对 size 字段的信任让它以为某个位置还挂着一个空闲块从而把相邻的已分配内存也算进合并范围。这道题里的 madhouse 手法本质上就是把 tcache 机制和 unlink 式合并机制混在一起制造一个非常混乱但仍旧合法的大块。glibc 的堆管理器虽然有一套很复杂的校验逻辑但只要伪造的块满足以下四个条件它通常就会相信这块内存是一块合法的空闲块size 字段落在合法的对齐范围内前一个块处于空闲状态对应的标志位fd/bk 指针指向合理地址相关 bin 链上的指针校验能通过换句话说堆管理器的逻辑校验点是局部的它不会验证整个堆布局的全局一致性。这给了攻击者以小博大的机会我只要能把某个 size 字段改成一个精心构造的值就能让堆管理器在后续的合并 / 分配过程中把一块被控制的内存当成空闲块返回出来从而实现任意读写。3.2 利用原语从改 size到任意地址写我在这道题里的实际推进是这样的。第一步先用 UAF 泄漏信息。delete 之后name 缓冲区被放到 tcache 或 unsorted bin 中。对于 tcache 中的块它的前 8 字节会被写入一个指向下一个空闲块的指针对于更大的块放入 unsorted bin它的 fd 和 bk 的前 8 字节会分别指向 main_arena 中的特定位置。show 功能在读这块内存时不会检查它是否已经释放因此我可以直接把这些残留指针读出来。一个有趣的小细节是如果释放后该块被放入 unsorted binfd 指针指向的是 main_arena 内部的一个地址而这个地址和 libc 基址之间有固定偏移所以通过一次 show 就能算出 libc 基址实现 ASLR 绕过。放在 tcache 里的块则只能泄漏堆地址除非把 tcache 填满让块进入 unsorted bin。第二步利用 edit 里的溢出改掉相邻 chunk 的 size 字段。我会先分配两个大小相近的 name 缓冲区让它们在堆上相邻排列。然后编辑第一个缓冲区把第二个缓冲区的 size 字段改成一个大得离谱的值。这样当我要释放第二个缓冲区时glibc 会以为它是一个超大块尝试和前面的块进行合并。如果我在第一个缓冲区里预先埋好了一个伪造的 chunk 头让这个超大块的上一个块看起来是空闲状态并且它的 prev_size 字段是一个被我控制的值那么合并完成后堆管理器就会构造出一块跨度很大的空闲块里面覆盖了原本已经被分配的其他堆对象。第三步把这块大到不真实的空闲块重新申请出来。由于我申请的是一个大小适中的块glibc 会从这块大空闲块中切出一段返回给我。而这段内存并不是一个真正空闲的区域它实际上覆盖了某些仍然在用的堆对象比如某个 Item 结构体里的 name 指针和回调函数指针。此时我已经获得了重叠内存同一块地址既能被受害者对象继续使用又能被我通过这个新申请到的块任意读写。有了重叠内存接下来的操作就顺理成章了。通过新块修改目标 Item 结构体中的回调函数指针把它改成 system 的地址同时把 name 字段改成 /bin/sh 字符串所在地址。然后触发 show 功能程序就会调用回调指针经过合理的传参布局最终执行 system(/bin/sh)拿到 shell。3.3 为什么常规防护拦不住这道题开了全 RELRO、NX、PIE、Canary为什么还是被打穿了原因在于这些防护解决的是不同类型的问题NX 解决的是能不能在不可执行页里跑代码的问题但它拦不住攻击者去执行已经存在的 libc 函数PIE 解决的是可执行文件基址是否已知的问题但攻击者通过信息泄漏就能绕过Full RELRO 解决的是能否改写 GOT 表的问题但攻击者可以不打 GOT 的主意转而劫持堆对象里的函数指针这也是很多人做堆题时容易陷入的误区以为防护开得越全越安全。实际上防护的全与强只决定攻击者要在利用链上多走几步并不能决定漏洞是否存在。真正的安全还是要回到程序逻辑里是否存在危险操作这一根本问题上来。所以说House of Madness 这种利用方式给我们的第一课并不是如何绕过安全机制而是在内存安全问题面前安全机制的组合不是银弹。4. 利用链构造从信息泄漏到控制流劫持4.1 准备调试环境在展开利用链之前先交代我当时使用的调试环境。由于 glibc 版本对堆利用的影响非常大这道题官方指定的是一个特定版本的 libc我建议你在本地复现时先确认这一点。方法是把题目给的所有文件包括 libc.so.6 和 ld-linux 等放进一个独立的目录然后用 patchelf 给目标二进制替换解释器和库路径。patchelf --set-interpreter ./ld-2.31.so ./house_of_madness patchelf --set-rpath ./. ./house_of_madness我个人的经验是把环境固定住之后再开始调试能省下大量因为本地 glibc 和远程不一致造成的排错时间。这一点在后面常见问题部分还会重点提。调试时我用 gdb 搭配 pwndbg 插件这个组合对堆题的帮助非常大看堆块、bin 链、chunk 信息都是可视化的。命令我一般只用三个heap、bins、dq。heap 看整片堆布局bins 看各个 bin 链的挂载情况dq 按十六进制查看内存。堆利用就像做拼图你需要经常抬头看全貌又低头精读单块内存的字节。4.2 第一步构造 UAF 泄漏这道题的利用第一步是信息泄漏。我先分配一个较大的 name 缓冲区比如 0x100 字节然后立即 delete 它。因为 0x100 大于 tcache 对应的最大值这块缓冲区在被释放后会进入 unsorted bin。unsorted bin 是个双向链表它的 fd 指针会指向 main_arena 中的 unsorted bin 头部而这个头部地址与 libc 基址之间的偏移是固定的。当我用 show 功能查看这个已经被释放的展品时由于程序不会去校验 name 缓冲区是否已经 free我就能直接把 fd 指针读出来。有了这个值减去偏移量libc 基址就拿到手了。这里有一个小经验泄漏的值经常需要确认到底落在哪个符号附近。我会在 gdb 里先把 main_arena 地址打印出来然后对照 libc 基址做差值计算确认我用的偏移无误。因为你用的 libc 版本不同这个偏移值是完全不同的直接背网上教程里的偏移不可靠。4.3 第二步布置堆风水信息泄漏做完之后接下来要布置堆布局。我选择的是先建三个相邻缓冲区再腾出中间一个作为突破口的经典布局。具体操作如下添加展品 Aname 缓冲区的大小为 0x80添加展品 Bname 缓冲区的大小为 0x80添加展品 Cname 缓冲区的大小也是一个可控对象删除展品 B让 B 的缓冲区变成空闲块编辑展品 A利用溢出把 B 的 size 字段改掉在真实比赛里我只能控制大小无法完全控制物理相邻关系所以需要先做几次分配 / 释放来把堆整理成规整的排布。这个过程大家喜欢叫堆风水本质上就是通过 malloc 和 free 的特定顺序让自己想要的对象落在可控的偏移位置。做堆风水时最容易犯的错误是一上来直接按理想布局分配结果发现 glibc 从 tcache 里直接返回了复用的旧块导致相邻根本不是你想的那样。正确的做法是先摸清当前堆的状态比如通过 show 泄漏所有已创建对象的地址确认它们的排列顺序再决定怎么调整。有一个判断相邻关系的小技巧在 gdb 里用 heap 命令查看所有 chunk 的地址算出每个 chunk 的 user 区域首地址。如果 A 的 user 区域结束位置正好是 B 的 chunk 头起始位置说明它们物理相邻。把这个地址差记住后续写利用脚本时偏移量就全在此基础上计算。4.4 第三步伪造 chunk 头与触发合并这一步是整个利用链的核心也是最容易翻车的地方。我要在编辑 A 的 name 缓冲区时不仅往 B 的 size 字段写入伪造值还要在 A 的缓冲区尾部预先埋下伪造的 chunk 头。伪造的 chunk 头需要满足的条件是prev_size 字段等于我要绕过检查的某个长度size 字段等于一个合法的空闲块大小并且设置 PREV_INUSE 位为 0表示前一个块是空闲状态fd 和 bk 指针在后续合并时可以保持链表的完整性然后我执行 delete B。B 的 name 缓冲区实际上已经被我改过头了它的 size 字段变得非常大堆管理器会在 free 时判断这个块的前一个块是否在使用。由于我在 A 的尾部伪造的 size 字段已经把 PREV_INUSE 位清零了堆管理器会认为前一个块是空闲的于是触发 unlink / 合并操作把 A 和 B 所在的区域连同伪造块一起合并成一个超大空闲块。这个超大空闲块会被放入 unsorted bin。由于它覆盖了相邻的 C 的缓冲区C 的底层内存已经处于名存实亡的状态。也就是说C 仍然被程序认为是有效的展品但它的内存控制权已经被我这块大空闲块接管了。很多人在这一步会遇到传说中的 corrupted size vs. prev_size 报错。这个报错的一般原因要么是 prev_size 和伪造 size 对不上要么是合并后的大小没有对齐。排查方法我在第 6 部分会写。4.5 第四步从重叠内存到控制流劫持此时堆上出现了重叠C 的 name 缓冲区实际上位于我申请到的大空闲块里面。我接下来申请一个大小合适的块让 malloc 把这整块区域切一块出来给我这样我就得到了一个新块它和 C 的 name 缓冲区是同一块内存。接下来就是经典的重叠块改写通过新块把 C 的 name 指针改成指向某个可写地址最好直接覆盖 C 的整个 Item 结构把 C 的回调函数指针改成 system 的地址在 name 字段指向的内存中写入字符串 /bin/sh触发 show(C)程序调用回调函数传入参数指向 /bin/sh执行 system由于程序是 64 位的函数调用第一个参数会通过 RDI 寄存器传递。我当时复盘发现show 功能调用函数指针时恰好把 Item 结构体指针作为参数传进去所以如果我把 Item 结构体本身的第一字段改成 /bin/sh 的地址再在适当位置放上 system 地址调用时参数就会正确指向要执行的命令。这里有一个常见坑system 函数的参数要求是字符串指针而直接塞数字地址进去往往不可靠。我的做法是先把 /bin/sh 写入一个可控堆地址再把回调时的参数指针改为那个堆地址。这比直接尝试用 name 指针作为命令字符串要稳定得多。整个利用链跑通之后远程就能拿到交互式 shell。但赛题要求继续写防御分析所以我又把视角切回了蓝队方向这部分见下一节。5. 蓝队视角分析完漏洞之后该做什么5.1 从攻击侧回到防御侧的思维切换在 CTF 里拿到 shell 的那一刻很多人就觉得题目结束了。但这类蓝队防线赛题不同后半程的脑力消耗其实比打利用链更大因为你要重新审视自己刚才的攻击路径把每一步还原成防御者能看的检测信号。我当时的分析报告从利用链反推总结了三个关键防御观测点第一次 free 之后仍然有指针指向该内存后续还发生了读操作。检测这个行为需要让开发框架在释放后自动清空指针或者在调试器层面跟踪 free 后访问编辑操作的长度校验和实际拷贝长度不一致。这个属于代码审计可以直接发现的逻辑漏洞检测思路是在 memcpy 前后对 src 长度和 dest 大小做二次校验堆上出现了异常的大块合并。堆管理器的日常统计信息里不应该频繁出现超常规的大块合并如果监控到对应信号大概率有人在尝试恶意构造这三个观测点分别对应了程序逻辑缺陷、运行时行为异常和堆结构突变。它们单拎出来都不足以说明被攻击了但组合起来就是一条值得告警的链。5.2 面向利用链的加固措施对这道题的利用链来说最直接有效的加固是修掉两个漏洞本身这同时也是最容易被忽略的点。防御报告里如果只写建议使用更安全的编程语言这种话价值很低。真正可靠的答案是把修复落到位delete 之后不要只清 magic而是整个 g_items[idx] 置空并禁止对已释放对象再调用任何接口edit 功能的边界检查改用与 memcpy 实际使用值完全相同的 len 进行校验对堆对象修改增加操作日志保留调用栈方便事后审计作为纵深防御还可以考虑给关键对象增加 cookie 校验字段比如在 Item 结构中加一个随机 magic每次使用前校验校验失败直接终止使用带安全增强的 glibc 构建版本开启 tcache 指针保护也就是 safe-linking 特性在关键函数指针调用前增加间接跳转目标检查类似 CFI 的思路防止函数指针被写成任意地址用 seccomp 限制子进程可用的系统调用即使攻击者拿到代码执行权限也不能直接执行 execve 获取 shell这几个措施是阶梯式的第一层是消除漏洞第二层是破坏利用原语第三层是增大利用成本第四层是即使利用成功也降低危害。防御者不能只挑其中一个而应该全部叠加。这个思路也同样适用于现实中的任何一类内存破坏漏洞。5.3 行为检测与监控方案除了代码加固行为监控也能在攻击发生时提供及时的告警。针对本题的利用链我建议在安全产品规则层面关注free 之后继续读写的对象调用序列尤其是同一地址在短时间内的 malloc/free/read 序列异常堆分配器中大块合并频率突然升高可通过插桩统计 unsorted bin 中块大小的极端值分布关键函数指针、回调地址的变更情况可对用户态代码段进行交叉验证检查被调用地址是否落在合法代码范围内当然现阶段直接在商业产品里实现这些检测并不容易但在自研安全产品、攻防演练平台、主机安全 Agent 中这些方向都是完全可以落地的。我个人觉得把 CTF 里学到的利用技术与主机安全的检测规则结合起来是大多数安全从业者能把刷题能力变成职业技能的关键路径。6. 常见问题与排错经验6.1 利用链调试排错速查表这条利用链在调试时容易出现的报错我按自己踩坑的经验整理成了一张速查表方便你直接对照查阅。报错或现象常见原因排查思路corrupted size vs. prev_size伪造的 prev_size 与相邻 chunk 实际大小不一致在 gdb 中用 x/gx 查看前一个 chunk 头核对 prev_size 和 size 字段malloc(): unsorted double linked list corruptedunsorted bin 的 fd/bk 指针被破坏检查伪造 chunk 的 fd/bk 是否指向合法堆地址并确认链表完整性free(): invalid pointerfree 的指针不在堆范围内或者指向已经被合并的中间区域用 heap 命令确认 free 的地址确实是一个 chunk 起始位置本地能打通远程打不通glibc 版本、偏移、环境变量不一致严格使用题目自带 libc 和 ld验证每个偏移版本匹配show 读出的内容不是期望指针泄漏时对象不在目标 bin 链中用 bins 命令查看当前 bin 链情况调整分配大小和释放顺序system 调用后无反应或崩溃参数没有正确落到 RDI或者 /bin/sh 不在预期地址在 gdb 中调用前 break检查 RDI 寄存器和栈布局这张表不是只能用在疯狂之屋上其他堆题遇到同类报错前三条基本可以直接拿来参考。6.2 三个容易栽进去的实操坑第一个坑不固定 libc 版本就开始干。堆利用对 libc 的敏感度极高同一套代码在 glibc 2.31 和 2.35 上表现完全不同。我见过不少同行本地打得好好的一打远程就挂仔细一查发现本地的 libc 根本不是题目指定的版本。解决方案就是用 patchelf 先把依赖固定下来再从这一个基准点出发做所有地址计算。第二个坑堆布局还没看清就急着写脚本。很多利用脚本的第一版都死在一个很小的问题上相邻关系判断错误。我建议第一次操作某个未知程序时先写一个只做 add / delete 的小脚本在 gdb 里手动走一遍把每步的 chunk 地址打出来。磨刀不误砍柴工这个花十分钟做出来的堆地址小地图后面会帮你省两个小时的瞎猜时间。第三个坑在调试状态下能打通的脚本脱离调试器后不稳定。这种现象非常常见因为调试器会改变堆的初始布局特别是 tcache 的计数行为在调试器下和正常运行下不一样。我当时的做法是在脚本里显式地填满 tcache、控制每次 malloc 的数量让堆布局尽量与调试时观察到的对齐避免依赖那些脆弱的隐式状态。6.3 分析心态层面的建议最后想聊聊做这类题目时的心态。这道题有个很有意思的地方它打起来很像一次小型的红蓝对抗攻击是你防御也是你你既要充分理解漏洞的利用价值又要站在如果我是管理员我该怎么拦住我自己的角度去想问题。我做这类题得到的一个重要体会是拿到一个内存破坏漏洞后不要急着写满漏洞利用脚本。先花十分钟画一张漏洞到控制流的关系图把每一步攻击需要的原语列清楚比如我需要泄漏什么我需要重叠哪两块内存我最终要改写哪个对象。有了这张图再回过去写代码成功率会高很多而且这张图本身就是一份很好的防御分析报告底稿。说到底House of Madness这个题目名字起得很贴切。堆管理器在这种攻击下确实像发了疯一样它会相信你伪造的 size 字段会按照你预设的大小去切分一块本不该切分的区域会毫无防备地把内存交还给你。但真正的安全防御者恰恰需要成为那个不配合这场疯戏的角色——在系统的每一个关键决策点上多问一句这里真的应该这么做吗我自己在复现这道题时第一次跑通利用链用了大概一个晚上但真正让我觉得有收获的是后面花了一整天把整个利用链拆开对照防御手段写清楚哪里能挡、哪里挡不住、为什么挡不住。这也是我希望你用本文做到的事情别把目标停在 getshell多走一步把攻击和防御放在同一张地图里看。