首页
/
行业洞察
/
正文
INDUSTRY INSIGHT · 深度
堆溢出原理与防护:从chunk元数据到safe-linking的全面剖析
📅 2026/10/19 6:26:37
✍️ 爱科研究院
👁 阅读 3,247
1. 从一段可疑的崩溃日志说起堆溢出凭什么比栈溢出更难缠记得有次我在内部测试环境里跑一个自研的小工具往一个函数里塞了一段超长输入进程直接 aborted报错内容也很奇怪corrupted size vs. prev_size而不是常见的Segmentation fault。当时我第一反应是数组越界但用 gdb 看调用栈愣是没找到特别明显的违规位置。后来顺着 heap 方向的报错提示反查才意识到问题根本不在崩溃那一行而是更早的某次拷贝写坏了堆管理器的元数据一直到释放堆块时才被 malloc 内部检查发现。这就是堆溢出的典型特征爆发点往往离破坏点十万八千里。我身边不少同事刚接触这类问题时会习惯性按栈溢出的思路排查结果往往卡壳。1.1 栈上“一跳就到”堆上“绕了半天才爆发”要理解两者的差异先看内存布局上栈和堆的定位。栈是函数调用时自动分配的一段连续内存从高地址向低地址生长局部变量、返回地址、调用参数都在里面。栈溢出通常意味着你往一个局部数组里写太多数据一路把返回地址给覆盖了函数一返回就直接“跳到攻击者可控的代码地址”。整个过程是直接、明确的你拿到崩溃现场时基本能通过寄存器或回溯看到是哪个函数被劫持。堆就不一样了。堆是程序运行时通过malloc、free动态管理的一块区域从低地址向高地址增长。堆溢出的操作对象是“堆上分配的缓冲区”它相当于你在储物柜里塞东西结果柜子不够大你的货物硬生生挤穿了隔板破坏了隔壁柜子的门锁。等隔壁柜子的主人另一个堆块被释放时系统才发现门锁坏了。打个更生活化的比喻栈溢出就像一个人往前猛冲直接把走廊尽头的告示牌撞碎了你一眼就看见碎在哪。堆溢出更像你把自家仓库的货物堆得太高塌下来压弯了隔壁仓库的货架隔壁仓库管理员过两天来取货时才发现货架变形了。所以堆溢出排查起来更考验耐心你要从“道路尽头的事故”倒推回去找到“最初堆货太满”的那个操作。1.2 堆溢出的本质写坏的是“邻居家的地契”堆溢出最底层的原理其实很朴素程序向一个堆缓冲区写入数据时写入长度超出了该缓冲区实际可用的边界导致相邻堆块的数据或元数据被覆盖。但真正关键的地方在于堆区块和栈的数组结构不同。堆里的每个“柜子”前面都挂着两块门牌——prev_size和size尺寸、状态、前后关系全记在里面。你溢出的不只是一段普通数据而是在篡改堆分配器维持全局秩序所依赖的“地契信息”。只要理解了这一点你就明白为什么堆溢出能做那么多效果远超预期的事情。它能伪造堆块大小、伪造链表指针、伪造释放状态直到让 malloc 把某个地址当成“合法的堆块”返回给你。这一步完成后攻击者事实上获得了超出原本权限的写能力后续怎么发展就取决于程序本身的逻辑和所在运行环境了。我最初看资料时总觉得“堆溢出”像是某种黑魔法后来自己搭建实验环境、写下发触发代码亲眼看一次崩溃才真正明白背后就是“把不可信的数据写进了不该写的元数据区域”这个朴素的道理。2. 堆分配器的地盘规则chunk、bins 与 malloc/free 的日常想搞懂堆溢出光知道“溢出会写坏邻居”是不够的还得知道邻居是怎么登记户口、怎么搬家的。这一段我把 glibc 里最常用的 ptmalloc2 分配器讲清楚。2.1 每个堆块头部的“领土宣言”在 glibc 中所有堆内存都以malloc_chunk为基本单位管理。一个被分配的堆块用户可用的数据区前面有一个 16 字节的头部struct malloc_chunk { size_t prev_size; // 前一个chunk的大小仅当前一个chunk空闲时有意义 size_t size; // 当前chunk的大小包含头部并带有标志位 struct malloc_chunk *fd; // 空闲时才存在的指针 struct malloc_chunk *bk; // 空闲时才存在的指针 };size字段的低 3 位都是标志位不是大小的一部分PPREV_INUSE为 1 表示前一个 chunk 正在使用MIS_MMAPPED为 1 表示该 chunk 通过mmap获得NNON_MAIN_ARENA为 1 表示该 chunk 不归属主线程的 arena。你调用malloc(0x80)时系统实际分配的 chunk 大小通常不是 0x80而是要加上头部 0x10 再对齐实际 chunk_size 是 0x90。为什么因为头部本身需要空间同时内存块必须满足对齐要求便于分配器做合并与切割。这个“大小要加头部再对齐”的细节很多初次接触的人会忽略。但堆溢出的经典手法之一恰恰是精准计算相邻 chunk 的偏移确保覆盖了两个关键的 size 字段后系统依然能通过前期的初步检查。2.2 free 之后去哪bin 链表的插入与取出当一个 chunk 被free释放时它并不会立刻还给操作系统而是根据大小和状态进入不同的链表队列glibc 里管这些队列叫 bintcacheglibc 2.26 之后引入的线程本地缓存每个线程有一组桶每个桶最多存 7 个 chunk适合小块的快速分配与释放fastbin存放更小型的 chunk释放时不合并分配时直接取用速度极快unsorted bin其它较小/中等块释放后的临时缓冲区新释放的 chunk 会先到这里下次分配时再按需归类small bin / large bin按大小分段管理的链表集中存放长时间不释放的 chunktop chunk堆区末端的大块剩余空间所有 bin 都找不到合适内存时会从 top chunk 里切割。每个空闲 chunk 的头部其实同时被复用为链表指针--------------------------------------------- | prev_size (8 bytes) | size (8 bytes) | --------------------------------------------- | fd (8 bytes) | bk (8 bytes) | --------------------------------------------- | user data ... | ---------------------------------------------fd指向同一条 bin 链里的下一个空闲块bk指向前一个空闲块。分配时从链表头取出释放时再把块链回链表头。这里有个关键操作叫unlink当free一个 chunk 时如果系统发现它前面或后面的 chunk 也是空闲状态就会把它们从链表中摘下来进行合并。摘取过程中会执行类似这样的指针操作FD P-fd; BK P-bk; FD-bk BK; BK-fd FD;看到没有这里涉及两个写操作而写入目标地址的内容都由 P 的 fd 和 bk 决定。早期版本对这两个指针几乎不加校验于是“控制 fd/bk 的值 获得一次任意地址写”成为了堆溢出最基本的利用武器。当然现在的 glibc 版本里填了很多检查后面我会专门讲。2.3 关键认知堆不是简单连续的小隔间很多人以为堆就是一段干干净净的连续内存这其实是个误区。程序运行时主线程的main_arena管理着一整段堆区里面既有正在使用的 chunk也有散落在各 bin 里的空闲 chunk还有最顶端的 top chunk。而多线程程序还会为每个线程创建各自的 arenammap分配的大块也都穿插在这段地址空间里。所以堆块与堆块的“邻居关系”不是一个纯数组下标那样的绝对关系它更像是一个小区里不同楼栋的房号。当你修改了某个 chunk 的 size 字段申请释放时系统就可能认为前面一个 chunk 是空闲的、后面一个 chunk 是不存在的从而触发合并、切割等一系列连锁反应。堆溢出能“四两拨千斤”正是因为这些反应都建立在我们可控的元数据之上。如果你先花半小时用 gdb 看看自己程序里malloc后的堆布局再来读本文后续的路径分析会顺很多。3. 从“越界写”到“任意写”覆盖元数据的两条经典路径理解了分配器的管理规则接着看堆溢出到底怎么从“写坏数据”变成“拿到数据”。这里我讲两个最经典的利用路径重点在原理完整代码我就不铺开了。3.1 路径一unlink 时的“狸猫换太子”假设堆里有三个 chunkA、B、C。A 在使用B 在使用C 在使用。攻击者向 A 写入超长数据覆盖了 B 的头部。这时如果 B 的fd字段被写成了假的指针bk也被改掉而程序恰好在之后free(B)分配器会进入 unlink 流程。正常情况下 unlink 会把 B 从空闲链表中摘除更新的只是 B 相邻块的前后指针。但如果我们能控制 B 的 fd 和 bk那么FD P-fd; // 伪造的 fd BK P-bk; // 伪造的 bk FD-bk BK; // 向伪造fd指向地址的偏移处写入 BK-fd FD; // 向伪造bk指向地址的偏移处写入这意味着我们能对两个任意地址进行写操作接下来再配合一些“往特定偏移写入特定值”的技巧就能逐步改写到关键控制结构上。但要注意这个路径的前提是 B 必须处于“空闲”状态或者在 free 时被认为空闲。所以攻击者通常还会伪造 B 的size字段把 PREV_INUSE 位清零让分配器误以为 B 的前一个 chunk 也是空闲的从而触发合并逻辑。glibc 现在对 unlink 加了多项检查最重要的两个FD-bk P且BK-fd P要求链表回指正确prev_size size_of_previous_chunk用于校验边界一致性。这些检查的加入基本堵死了直接伪造 fd、bk 做 unlink 的路线。但原理上我们仍需要知道 unlink 为什么曾是杀手锏——它说明“堆溢出能影响内存管理而不只是缓冲区内容”。3.2 路径二写在空闲链表指针上的“钓鱼”如果说 unlink 是把户口本改得一团糟那么第二类路径更像是直接在空闲链表上“钓鱼”。当程序释放一个 chunk 时它被插入到 tcache 或 fastbin 的链表头部。如果攻击者能通过溢写覆盖该空闲 chunk 的fd指针就可以把链表头指向任意地址。想想 malloc 的分配过程调用malloc(size)时分配器会从对应 bin 的链表头取出第一个 chunk然后更新链表头为fd所指向的地址。如果这个 fd 是攻击者伪造的下一次 malloc 就会返回攻击者指定的地址从而让程序把该地址当作正常堆块使用我们实质上是获得了一个任意地址的 malloc 结果。路径本身不算复杂真正难的是伪造 fd 指针。在开启了 ASLR、PIE 和 safe-linking 的情况下fd 指针在被存储前还要经过一轮位置相关的位运算不是随手填个目标地址就能生效的。这个我会在防护一节展开。3.3 为什么这些路径现在越来越难走这两条路径放到十年前几乎就是堆溢出入门的必修课用今天的眼光看已经连“半成品”都算不上。一个很重要的原因是现代 glibc 对堆的“自洽性”越来越敏感。以前系统大约会容忍某些不一致等到真正用到时才 crash现在则在free、malloc、realloc的入口处就做大量校验任何不一致都可能直接malloc_printerr并中止进程。另一个原因是堆地址随机化和安全指针技术的普及。在 glibc 2.32 后safe-linking 被引入它让即使是 fastbin 里的 fd 也不再是原始指针而是与堆块自身地址相关的加密值。攻击者没有堆泄露就无法正确伪造链表。等一下我会专门说这个机制到底在算什么它让我在调试时拍了好几次桌子。4. 最小复现实验让堆溢出在眼前真实发生一次原理讲得再多都不如自己动手看一次崩溃现场。下面这个 demo 是我当初学习时反复用的小例子编译和运行都非常简单适合作为起点。4.1 写一个能触发堆溢出的最小程序#include stdio.h #include stdlib.h #include string.h void vulnerable_copy(char *input) { char *chunk_a malloc(0x80); char *chunk_b malloc(0x80); printf(chunk_a: %p\n, chunk_a); printf(chunk_b: %p\n, chunk_b); strcpy(chunk_a, input); printf(chunk_b after copy: %s\n, chunk_b); free(chunk_a); free(chunk_b); } int main(int argc, char **argv) { char buf[512]; if (argc 1) { strcpy(buf, argv[1]); vulnerable_copy(buf); } return 0; }这里我用strcpy模拟经典的不安全拷贝。真实工程里它可能是memcpy配合错误的 size 计算也可能是recv、read时没有严格限制长度总之原理一致写入了超出chunk_a用户数据范围的数据。编译时加调试信息gcc -g -o demo_vuln demo_vuln.c然后正常传一个较短的参数程序可以顺利跑完。一旦传入足够长的数据比如 200 个字符以上它就会在某个版本上触发 abort。如果你用的 glibc 版本比较新建议启用环境变量加重检查MALLOC_CHECK_3 ./demo_vuln $(python3 -c print(A*0x100))4.2 用 gdb 观察溢出前后堆块怎么变了运行 gdb 后首先在vulnerable_copy处下断点gdb ./demo_vuln (gdb) break vulnerable_copy (gdb) run AAAAAAAA到断点后分别打印两个 chunk 的地址并查看其头部内容(gdb) print chunk_a (gdb) print chunk_b (gdb) x/4gx chunk_a - 0x10 (gdb) x/4gx chunk_b - 0x10你会看到类似下面的输出0x602010: 0x0000000000000000 0x0000000000000091 0x602020: ...这个0x91就是 chunk_a 的 size 字段其中最低位的1表示 PREV_INUSE 置位。再看chunk_b - 0x10它头部也是一串0x0和0x91。接着用超长输入重新运行在free之前检查(gdb) run $(python3 -c print(A*0x100)) (gdb) x/40gx chunk_a - 0x10这一次你就能清楚地看到chunk_b的头被 A 填满了原本的prev_size和size都被覆盖。尤其要注意如果你输入长度正好是 0x80strcpy还会多写一个\0终止符把chunk_bsize 字段的最低字节变成0x00也就是0x91变成0x90——PREV_INUSE 位被清零。这就是著名的 off-by-one 场景。4.3 崩溃现场如何定位被覆盖的目标当free(chunk_a)执行时分配器发现 chunk_a 的后一个块即 chunk_b的 P 位显示为“空闲”就会尝试把这两个块合并。于是它去读 chunk_b 的prev_size字段结果读到的是我们填入的A此时前后不一致glibc 直接打印corrupted size vs. prev_size后 abort。排查这种问题的思路比栈溢出要多一步先用 gdb 看 abort 时的调用栈找到深入分配器的路径打印崩溃时正在 free 的 chunk 指针以该指针为基准往回查看相邻 chunk 的头部确认哪个字段被写坏了再到代码里查这个相邻 chunk 是从哪里被极端写入的缩小范围。我第一次排查时因为把print chunk_a当成了全部盯着输出看半天没发现异常后来才发现问题出在chunk_b的头。这个误区值得提醒堆溢出的受害者是邻居不是你正在操作的那个 chunk。5. 堆上的“安全带”现代防护机制如何层层加码搞懂原理之后必须了解如今堆上的防御。这些特性不只是“挡攻击的墙”更是理解现代堆溢出声东击西逻辑的必要语境。5.1 tcache既是给程序员的缓存也是给攻击者的限制tcache 是 glibc 2.26 起默认开启的线程本地缓存。它把小块释放和分配的速度拉得极快但也带来了新的利用面如果程序存在 double free同一个 chunk 被释放两次攻击者很容易让 tcache 的桶里出现两个相同地址的 chunk之后 malloc 两次大概率拿到同一块内存再用这块内存去覆盖其他堆块元数据。官方后续给 tcache 加了 key 字段用于快速检测 double freefree时会把 chunk 的 key 写入一个线程相关的值再次free同一个 chunk 时就发现 key 不匹配并报错。所以说 tcache 既是提速器又是新的靶子同时还在不断打补丁攻防关系非常动态。5.2 safe-linking让伪造指针变成“薛定谔的猫”glibc 2.32 引入的 safe-linking 是近几年堆防护里最值得关注的一笔。它的思路非常简单bin 链表节点中存储的next指针不再直接保存原始地址而是保存“地址与自身地址右移 12 位异或”得到的结果。具体来说#define PROTECT_PTR(pos, ptr) \ ((__typeof__(ptr)) ((((size_t) pos) 12) ^ ((size_t) ptr)))当你从链表中取节点时再用同样的方式还原。这带来了什么效果呢假设攻击者想伪造 fastbin 或 tcache 的 fd指向一个指定地址target。写入时系统会把这个伪造值先和当前链表头地址的右移 12 位异或然后再存进去。没有泄露堆地址攻击者算不出正确的存储值任何随手填的地址都会在还原那一瞬间被算成别的值导致分配器崩溃或链表越跑越偏。一句话总结没有堆地址泄露safe-linking 基本断绝了伪造 fastbin/tcache 指针的常规路径。这也是为什么现代堆溢出利用都极其依赖某个信息泄露漏洞来先把堆地址拿到手。5.3 其它几个让我调试时拍桌子的防御特性ASLR PIE每次运行程序基址、堆地址都随机化。同一个 demo 两次运行的 chunk 地址都不一样复现分析时我习惯用setarch -R关闭地址随机化来简化观察但要记住真实场景中必须考虑随机化。Full RELRO把 GOT 表设为只读广电我们喜欢的 GOT 覆写就此失效Stack Canary虽然它主要保护栈但也迫使攻击者绕道堆结构Fortify Source编译期的安全检查会对某些函数如strcpy、memcpy、sprintf插入长度检查代码seccomp沙箱机制限制系统调用白名单即使堆块劫持成功想执行交互式 shell 也会被卡住。平时做漏洞分析我会先确认目标上哪些防护开启、glibc 是什么版本、是不是 tcache、gio版本是否启用 safe-linking。这些信息基本决定了分析路径。6. 踩坑记录调试堆溢出时我不再犯的错最后这段话算是我自己从各种怪问题里爬出来后总结的经验。如果你照着前面的内容动手大概也会遇到类似的情况。6.1 只盯崩溃点忽略相邻 chunk 的状态堆溢出的崩溃点经常和问题点隔了十万八千里。我踩过的第一个坑就是用栈溢出的思路去搜“崩溃那一行代码”结果搜到的是 glibc 内部的某个函数。后来学会了“顺着 malloc 内部报错倒推”——先找到报错时正在处理哪个 chunk再往前看哪个邻居的头部被破坏。这种调试习惯的转变相当于从“查车祸现场”变成“查车辆运输记录”思维方式上一定要跳出来。6.2 把 ASAN 报错当成全部真相AddressSanitizer 是个好工具能直接报出越界写是发生在哪一行、写入了多少字节。很多团队项目里碰到堆溢出我会首推 ASAN 定位。但它和 ptmalloc 的管理机制不完全相同ASAN 的堆布局、对齐规则都和 glibc 不一样。如果你只依赖 ASAN 定位问题可能在深入分析利用韧性时发现完全对不上。所以我现在的习惯是ASAN 用来快速发现“哪里有越界写”之后再用原生 glibc 编译、gdb 实测确认真实堆块里的元数据究竟被改成了什么。6.3 工具链和 glibc 版本会悄悄改变一切同一个 demo在 glibc 2.23 和 2.35 下的表现可能完全不同bin 结构不同、有没有 tcache、safe-linking 开不开、检查严格度差异都很大。我踩过的坑是本地分析时一切正常换到另一个环境的编译机上复现就“不崩了”一度以为是数据问题。后来发现是两边 glibc 版本不同默认检查策略也不同。建议准备一个可切换 glibc 的调试环境或者至少在笔记里记下目标环境的版本信息。关于调试环境我还推荐用 setarch 关闭 ASLR 来做对照实验setarch $(uname -m) -R ./demo_vuln $(python3 -c print(A*0x100))关闭随机化后每次运行堆地址固定分析覆盖偏移会轻松很多。6.4 最后说一点个人的体会学习堆溢出技巧倒在其次不能跳过的是“亲手建一个最小实验、亲眼观察一次崩溃”。只有当你看到那些漂亮的十六进制头部被自己的数据打乱时才会真正理解为什么说“堆溢出是写给内存管理器的伪装术”。我做这件事的初衷是给团队做代码安全加固把之前想当然以为“多写几个字节没事”的观念纠正过来。工程里真正值得做的是保证memcpy、strcpy、sprintf这类函数的长度计算严格可信同时把输入长度校验和边界判断放进代码审查清单。原理可以忘但这几条红线最好记得清清楚楚。
📌 标签:
工业官网
设计趋势
AI 建站
SEO
获取完整报告 →
RELATED ARTICLES
推荐阅读
2026/10/19 6:26:37
AI Coding Tools助力MATLAB转Python迁移:万字工程实战复盘
2026/10/19 6:21:36
机器学习模型调优实战:从泛化误差到集成学习
2026/10/19 6:21:36
AI大模型应用落地全解析:战略选型、技术路径与避坑指南
2026/10/19 8:36:53
open-code-review:基于CLI与Git钩子的上下文感知AI代码审查
2026/10/19 8:36:53
AI辅助毕业论文写作:10大工具功能拆解与实战工作流
2026/10/19 8:36:53
AI辅助教材编写全流程:从大纲到低查重的实战方法
2026/10/19 8:36:53
开源CLI代码评审工作流:基于Git Diff与本地LLM的可控AI实践
2026/10/19 8:36:53
WebRTC音视频对齐(唇同步)原理与调优实战
2026/10/19 8:31:52
开源代码审查新范式:本地化、可审计、零密钥的 CLI 实践
2026/10/19 0:01:13
频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现
2026/10/19 0:01:13
Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道
2026/10/19 0:01:13
HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统
2026/10/18 0:02:43
OpenResearch实战:Claude Code、Codex、OpenCode、Cursor 工作流搭建与故障排查
2026/10/19 2:04:59
知识管理核弹:用Obsidian构建第二大脑的工程化方案(TaoToken 统一 Key 接入版)
2026/10/18 0:02:43
金融AI落地指南:从调研报告到技术选型与工程实践
2026/10/17 10:15:50
我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
2026/10/16 18:46:48
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026/10/16 18:46:46
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)