引言
如果说栈溢出是「线性地覆盖返回地址」,那么堆利用就是「在分配器的数据结构里做手术」。它没有一个像返回地址那样明确的目标,而是通过操纵 glibc malloc 内部的 chunk 元数据、bin 链表与空闲块复用逻辑,让两次本不相干的分配指向同一块内存,或让一个被释放的指针仍能读写——最终把「内存破坏」转化成「任意地址读写」。这种间接性正是堆题难度的来源,也是它比栈题更接近真实漏洞形态的原因。
堆题的工程难点集中在三处。第一是布局不可控:堆上的分配顺序、对齐填充、top chunk 的切分位置都由分配器内部逻辑决定,同一份代码在不同 glibc 版本下可能得到完全不同的内存布局,必须靠「堆风水」主动塑造。第二是版本决定手法:tcache 在 glibc 2.26 引入后大幅降低了堆利用门槛,而 2.31 加入的 key 字段与 2.32 加入的 safe-linking 又反过来把最经典的 double free 逐层堵死,同一道题换个 libc 版本,解法可能完全不同。第三是调试困难:堆上的对象没有符号、没有类型,崩在 malloc 内部是常态,需要专门的堆调试工具才能看清 bin 链表的真实形态。
本文按「机制 → 原语 → 工程」的顺序组织。第 1 到第 4 节讲机制:chunk 结构、size 字段的低三位标志、五类 bin 与 arena、分配与释放时的合并拆分、以及 tcache 从引入到加固的完整演进。第 5 到第 7 节讲原语:UAF 与 double free、off-by-one 与 chunk overlap、unlink 与 unsorted bin attack,以及支撑它们的堆风水技巧。第 8、9 节落到工程:glibc 版本差异、题目常见交互形态、泄漏思路、pwndbg 调试命令,以及从 glibc 加固史延伸出的检测与防御体系。
与前一篇一样,本文全部内容面向 CTF 题目与自建靶场,代码示例是自写 demo 与教学最小复现,用于理解分配器行为与缓解机制。真实生产环境中的堆漏洞处置应走授权的漏洞研究与补丁流程,防御侧的工程实践可结合本站企业安全方向阅读。栈侧基础不牢的读者建议先读 Pwn 方向栈溢出与 ROP 链 。
目录
- glibc 分配器基础与 chunk 结构
- bins 体系:fastbin、tcache、unsorted、small、large 与 arena
- 分配与释放的合并与拆分逻辑
- tcache 机制与其加固演进
- 经典利用原语:UAF 与 double free
- off-by-one、chunk overlap 与 unlink
- 堆风水与布局可控性
- glibc 版本差异与题目交互形态
- 调试手段与检测防御体系
1. glibc 分配器基础与 chunk 结构
glibc 的 malloc 以 chunk 为最小管理单位。每个 chunk 在内存里由三部分组成:头部(prev_size 与 size)、用户数据区、以及下一个 chunk 的头部(被复用为 fd/bk 指针,仅在空闲时有效)。用户拿到的指针指向数据区起点,也就是头部之后 16 字节(64 位下)。
/* malloc_chunk 结构(简化,仅用于教学理解布局) */
struct malloc_chunk {
size_t prev_size; /* 前一个 chunk 空闲时,存其大小(物理相邻合并用) */
size_t size; /* 本 chunk 大小 | 三个标志位 */
struct malloc_chunk *fd; /* 空闲时使用:前向指针 */
struct malloc_chunk *bk; /* 空闲时使用:后向指针 */
};
size 字段的低三位是标志位,这是堆利用里最重要的一条常识:
| 位 | 名称 | 含义 |
|---|---|---|
| bit 0 | PREV_INUSE (P) | 前一个 chunk 正在使用中,prev_size 无效 |
| bit 1 | IS_MMAPPED (M) | 该 chunk 由 mmap 直接分配,不在堆区 |
| bit 2 | NON_MAIN_ARENA (A) | 该 chunk 属于非主线程的 arena |
因为 chunk 大小必然按 16 字节对齐,低三位永远为 0,所以可以安全地拿来当标志位。请求 malloc(0x18) 时,实际得到的 chunk 大小是 0x20(0x18 数据 + 8 字节 prev_size 复用 + 8 字节 size)。理解这个「向上取整到 16 的倍数再加 8」的规则,是后面所有偏移计算的前提。
一段连续的堆内存实际长这样,注意「用户指针并不等于 chunk 起始地址」这个错位:
低地址
+------------------+ <- chunk A 起始(chunk 头)
| prev_size (8B) | 前一块空闲时才有意义
| size = 0x51 | 0x50 大小 + P 位 = 1
+------------------+ <- malloc 返回给用户的指针指向这里
| 用户数据 0x40 B |
| ... |
+------------------+ <- chunk A 结束 / chunk B 起始
| prev_size (8B) | A 在使用中,此字段被 A 的数据复用
| size = 0x61 |
+------------------+ <- 用户拿到的第二个指针
| 用户数据 0x50 B |
+------------------+
| ... |
+------------------+ <- top chunk 起始
| prev_size |
| size = 0x20f41 | top chunk 大小随分配动态变化
+------------------+
高地址
从这个图里能直接读出两个重要推论:其一,chunk A 的 prev_size 字段被前一个 chunk 的数据区占用,只有当 A 的前一块空闲时这个字段才有效,这就是「伪造 prev_size」类手法的物理基础;其二,用户指针与 chunk 头之间固定差 0x10,因此「从用户指针反推 chunk 头」永远是 ptr - 0x10,这是所有堆脚本里最频繁出现的计算。
检测与防御:size 字段的完整性是分配器的信任根基,glibc 在 _int_malloc 与 unlink_chunk 中大量检查 size 的合法性(对齐、是否小于 MINSIZE、是否超过 av->system_mem)。2.29 之后新增的 unlink_chunk 双向链表校验与 malloc_consolidate 中的 chunk 大小比对,都是针对「伪造 size」的直接反制。
2. bins 体系:fastbin、tcache、unsorted、small、large 与 arena
被释放的 chunk 不会立即还给操作系统,而是按大小挂进不同的「bin」里等待复用。理解 bin 的分类是理解一切堆利用手法的地图:
| bin 类型 | 大小范围(64 位) | 链表结构 | 复用策略 |
|---|---|---|---|
| tcache | 0x20 ~ 0x410(64 个 bin) | 单链表(next) | 精确匹配、LIFO、最快 |
| fastbin | 0x20 ~ 0x80(10 个 bin) | 单链表(fd) | 精确匹配、LIFO |
| unsorted bin | 全部 | 双向循环链表 | 首次遍历、可被拆分 |
| small bin | 0x20 ~ 0x3f0 | 双向循环链表 | 精确匹配 |
| large bin | ≥ 0x400 | 双向 + fd_nextsize | 范围匹配、排序 |
arena 是分配器管理堆的上下文(malloc_state),主线程使用 main_arena,它内嵌在 libc 的 .data 段里,因此 main_arena 的地址可以直接用来反推 libc 基址——这是堆题最重要的泄漏来源之一。每个线程若争用主 arena 会创建独立的非主 arena,NON_MAIN_ARENA 标志位由此而来。
tcache 的元数据集中存放在堆起始处的一块 tcache_perthread_struct 里,这也解释了为什么「堆地址泄露」常常就发生在堆的开头附近:
/* tcache_perthread_struct 的简化结构(64 位,共 0x290 字节左右) */
struct tcache_perthread_struct {
uint16_t counts[64]; /* 每个大小档位的当前块数,上限 7 */
tcache_entry *entries[64]; /* 每个大小档位的单链表头 */
};
/* 大小档位索引 = (chunk_size - 0x20) / 0x10,故 0x20~0x410 共 64 档 */
top chunk 是堆顶剩余的那一大块,它不属于任何 bin。当所有 bin 都找不到合适块时,malloc 从 top chunk 切分;当 top chunk 太小,则用 brk 扩展堆或 mmap 新映射。house of force 就是通过把 top chunk 的 size 改成一个极大值,让后续任意大的分配都能从 top 切出,从而把返回指针落到任意地址——但 glibc 2.29 加入了对 top chunk size 的合法性检查,这一手法基本失效。
检测与防御:bin 链表的指针是「空闲内存里的数据」,天然可被 UAF 类漏洞改写。防御上,tcache 在 2.31 引入的 key 字段就是专门用来校验「这个块是否真的已被释放」,属于对 bin 完整性的直接加固。
3. 分配与释放的合并与拆分逻辑
理解合并(consolidate)与拆分(split)的触发条件,才能预测堆布局。释放一个 chunk 时,分配器会看它的前后邻居是否空闲:若前一个空闲(PREV_INUSE 为 0),就把它合并进来;若后一个是 top chunk 或空闲块,也合并。合并后的块根据大小进入 unsorted bin 或 small/large bin。
释放触发合并的两种情形(物理相邻,非 bin 链表相邻):
[ 已释放 A ][ 正在释放 B ] -> A 与 B 合并(P 位为 0)
[ 正在释放 B ][ 空闲 C ] -> B 与 C 合并
[ 正在释放 B ][ top chunk ] -> B 并入 top chunk(不进入 bin)
malloc 的查找顺序(简化):
tcache -> fastbin -> small bin -> unsorted bin -> large bin -> top chunk
拆分发生在「找到的空闲块比请求大得多」时:分配器把它切成「返回给用户的部分」与「剩下的新空闲块」,后者重新挂回 bin。这个切分点由请求大小决定,因此只要控制请求大小,就能控制下一个 chunk 的起始地址——这是堆风水最核心的手段。
unsorted bin 有个特殊行为值得记住:第一次 malloc 遍历它时,若找到的块大小正好匹配就返回,否则把块「归位」到 small/large bin 再继续找。这个过程会把该块的 fd/bk 指向 main_arena 中的对应链表头,于是只要有一个 chunk 进了 unsorted bin,它的 fd 就泄露了 libc 地址。这就是经典的 unsorted bin 泄漏。
检测与防御:合并逻辑里对 prev_size 的信任是历史漏洞的重灾区。glibc 2.29 起在 unlink_chunk 中增加了 chunksize(p) != prev_size(next) 的校验,2.32 的 safe-linking 又给 fd/bk 加了指针混淆,两者共同提高了「伪造空闲块」的成本。
4. tcache 机制与其加固演进
tcache(thread cache)是 glibc 2.26 引入的每线程缓存,本意是提升小内存分配的并发性能,却意外成了 CTF 出题人与选手的「新大陆」。它的结构极简:每个大小对应一个 tcache_perthread_struct 里的计数与单链表头,释放时优先放进 tcache,分配时优先从 tcache 取。由于 tcache 的插入与取出都不做任何完整性校验,早期版本里一个 double free 就能直接把同一个 chunk 挂两次,取出后得到两个指向同一地址的指针。
它的加固史几乎就是一部堆利用的攻防编年史:
| glibc 版本 | 加固内容 | 对利用的影响 |
|---|---|---|
| 2.26 | tcache 首次引入,无任何校验 | double free 极为容易 |
| 2.27 | 修正部分边界检查 | 手法基本不变 |
| 2.29 | unlink 增加 size 校验、top chunk 检查 | house of force、部分 unlink 失效 |
| 2.31 | tcache 加入 key 字段(指向 tcache_perthread_struct) | 直接 double free 被检测 |
| 2.32 | 引入 safe-linking,fd/bk 经 PROTECT_PTR 混淆 | 需先泄露堆地址才能伪造 |
| 2.34 | 移除 __malloc_hook/__free_hook | 转向 FSOP、_IO_list_all |
2.31 的 key 校验逻辑是「释放时把 e->key 写成 tcache 变量地址,再次释放前检查 e->key == tcache」,因此只要在两次 free 之间改写 key 字段(比如用 UAF 写一个别的值),校验就会通过,double free 依然可行:
# 教学片段:2.31 下绕过 key 校验完成 double free(需 UAF 写原语)
add(0, 0x50) # chunk A
free(0) # A 入 tcache,A->key = &tcache
edit(0, p64(0) * 8) # UAF 写:把 A 的 next 与 key 一起清成 0
free(0) # 校验 A->key != tcache,通过,A 被挂两次
add(1, 0x50) # 取回 A
add(2, 0x50) # 再次取回 A —— 两个指针指向同一块
2.32 的 safe-linking 则把指针存储改为 ptr ^ (addr >> 12),由于要伪造合法的 fd 必须先知道堆地址,利用门槛被显著抬高,但仍可通过堆地址泄露绕开:
# safe-linking 的编码与解码(教学演示)
def protect(ptr, pos): # 写入链表时
return ptr ^ (pos >> 12)
def demangle(enc, pos): # 读取时还原
return enc ^ (pos >> 12)
# 注意 pos 是「存储该指针的那个 chunk 的地址」,不是目标地址
检测与防御:key 与 safe-linking 是「让利用变难」而非「让利用不可能」的典型例子。真正的工程防御不依赖这些细节,而是靠 ASan 的 detect_double_free、MALLOC_CHECK_=3 的运行时校验,以及从语言层面消除手动内存管理。
5. 经典利用原语:UAF 与 double free
UAF(Use-After-Free) 是所有堆利用的原型:程序 free 了一块内存却没把指针置空,之后仍通过该指针读写,此时这块内存可能已经被重新分配给另一个对象。UAF 的威力在于它给了你一个「读写已释放内存」的原语,而这块内存正好可能落在 bin 链表上,于是你能改写链表指针。
/* uaf_demo.c —— 本地靶场教学,展示 free 后未置空导致的类型混淆 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char *a = malloc(0x40);
strcpy(a, "AAAA");
free(a);
/* 漏洞点:a 未置 NULL,后续仍可写 */
char *b = malloc(0x40); /* 极可能复用同一地址 */
strcpy(a, "BBBB"); /* 通过悬垂指针改写 b 的内容 */
printf("%s\n", b); /* 输出 BBBB —— 类型混淆成立 */
return 0;
}
double free 是 UAF 的特例:同一块内存被释放两次,导致 bin 链表里出现两个相同节点,之后连续两次 malloc 会返回同一个地址,形成「两个指针指向同一块内存」。利用它可以把某个 bin 的 fd 改写成目标地址,实现「下一次分配到任意地址」(tcache poisoning)。
# 教学片段:tcache poisoning 的思路(需先有 double free 或 UAF 写原语)
add(0, 0x40) # 分配 A
free(0) # A 进 tcache
edit(0, p64(target)) # UAF 改写 A 的 fd 为 target
add(1, 0x40) # 取回 A
add(2, 0x40) # 从 target 处切出,返回 target
关键点在于 tcache 的 next 指针存放在用户数据区的前 8 字节,也就是释放后原用户数据的位置——这解释了为什么几乎所有 tcache 利用都要先有一次 UAF 写。
检测与防御:UAF 的根因是「释放后未置空」,防御上靠编码规范(free(p); p = NULL;)、静态分析(Coverity、clang 的 -Wuse-after-free)、以及运行期的 ASan(会把已释放块隔离在 quarantine 区,让 UAF 立即报错)。语言层面,Rust 的所有权模型与 Go 的 GC 从根本上消除了这类缺陷。
6. off-by-one、chunk overlap 与 unlink
off-by-one 指的是写入了超出边界恰好一个字节,在堆上它通常由「循环上界写成 <=」或「字符串函数没算终止符」造成。它之所以危险,是因为一个字节足以改写相邻 chunk 的 size 字段最低字节,从而篡改 PREV_INUSE 标志或把 chunk 大小改成另一个合法值。
chunk overlap 是 off-by-one 的典型后果:把 chunk B 的 size 改大,那么 B 就会「覆盖」到相邻的 chunk C;释放 B 后再分配一个同样大的块,得到的指针就能读写 C 的内部,实现「一个指针控制两块内存」。它常被用来伪造一个「在目标地址上的 chunk」,为后续的任意写铺路。
overlap 的构造过程(size 从 0x51 被改写成 0x91):
初始: [ A 0x50 ][ B 0x50 ][ C 0x50 ]
off-by-one: 改写 B.size 低字节 -> [ A 0x50 ][ B 0x90 ][ C 0x50 ]
free(B): B 覆盖到 C,且 C 被当作 B 的一部分
malloc(0x80): 返回指向 B 的指针,实际可读写到 C 的内部
结果: 一个指针同时覆盖 B 与 C —— overlap 成立
重叠之后最常见的用法是「在 C 的用户数据区里伪造一个 chunk 头」,再释放 C 让它以伪造的大小进入 tcache 或 unsorted bin,从而得到一个指向任意地址的分配结果。
unlink 是双向链表删除节点时的标准操作,伪代码如下:
/* glibc 中的 unlink(教学简化版) */
p->fd->bk = p->bk;
p->bk->fd = p->fd;
/* 若 p 可控,则等价于 *(p->fd + 0x18) = p->bk 的任意写 */
在 glibc 2.23 时代,若能在 chunk 里伪造 fd/bk 并触发 unlink,就能写出 *(fd + 0x18) = bk 的任意地址写。2.29 之后 unlink_chunk 加入了 p->fd->bk == p && p->bk->fd == p 的双向校验与 size 合法性检查,纯粹的伪造 unlink 基本被堵死,但配合 UAF 改写真实节点的 fd/bk 仍然可行。
unsorted bin attack 则是利用「把 chunk 放进 unsorted bin 时写入 bk->fd = p」这一步,实现把 p 的地址写到任意位置(通常是 global_max_fast),把 fastbin 的判定范围撑到极大,从而让后续的 free 落入 fastbin 处理路径。2.31 之后该手法因 bk->fd 校验加强而基本失效。
检测与防御:这一节的三个原语都依赖「改写元数据后仍被信任」。防御上,编译期可用 -D_FORTIFY_SOURCE=2 拦截部分越界写,运行期可用 ASan 的 redzone 检测越界一字节,而 glibc 自身则通过逐版本收紧的校验逻辑提高门槛。
7. 堆风水与布局可控性
堆风水(heap grooming / feng shui)指的是:通过精心安排的分配与释放序列,让目标 chunk 落在预期的地址关系上。因为堆布局不是随机而是确定性的(同一 libc、同一输入序列必然得到同一布局),选手可以通过「先占位、再释放、再分配」把目标块推到 top chunk 附近或某个已知偏移处。
常见手法包括:
- 填满 tcache:每个大小先分配 7 个并释放,把 tcache 填满,逼后续释放进入 fastbin 或 unsorted bin。
- 隔离目标块:在目标块前后各分配一个「哨兵块」并保持占用,防止释放时被合并进 top chunk。
- 构造地址关系:利用 top chunk 的切分位置,让两个 chunk 的地址差固定,从而在只知道相对偏移时也能定位。
- 清除 key:在 2.31 之后,用 UAF 写把 tcache 项的
key改掉,让 double free 的校验通过。
一个稳定的堆风水脚本通常先做「预热」——用一批无关的分配把堆状态拉到可预测的基线,再开始真正的利用序列。这也解释了为什么堆题脚本的第一段往往是几十行看起来毫无意义的 add/free:
# 教学片段:把 tcache 的 0x50 档位填满,逼后续释放进入 unsorted bin
for i in range(7):
add(i, 0x40) # 请求 0x40 -> chunk 大小 0x50
for i in range(7):
free(i) # 7 个块填满 tcache[0x50],第 8 个才会走 fastbin/unsorted
add(100, 0x40) # 哨兵块,防止目标被合并进 top
free(100) # 此时释放直接进入 unsorted bin,fd 指向 main_arena
上面的序列结束后,show(100) 就能读出 main_arena 附近的地址,libc 基址随之到手。这段代码几乎是所有「需要泄露 libc」的堆题脚本的标准开头。
检测与防御:堆风水利用的是「布局确定性」这一特性。防御上可通过在关键分配前引入随机化(如 glibc 的 MALLOC_PERTURB_、随机化 arena 选择)来削弱可预测性,但真正的兜底仍是消除漏洞本身。
8. glibc 版本差异与题目交互形态
glibc 版本是堆题的「第二道 checksec」。拿到题目后,除了 checksec,还要确认 libc 版本(ldd --version 或读题目附带的 libc.so.6),因为它直接决定哪些手法可用:
| 版本 | 可直接用的经典手法 | 已失效的手法 |
|---|---|---|
| 2.23 | unlink、unsorted bin attack、__malloc_hook | — |
| 2.27 | tcache poisoning、fastbin dup | 部分 unlink 校验加强 |
| 2.31 | UAF 改 key 后 double free | 直接 double free、unsorted bin attack |
| 2.35 | FSOP、_IO_list_all、house of apple | __free_hook、__malloc_hook 已移除 |
CTF 堆题的功能菜单高度模板化,通常由四个操作组成,理解它们与漏洞的对应关系比背解法更重要:
1. add(size, content) -> 产生 malloc,控制请求大小即控制 chunk 大小
2. delete(idx) -> 产生 free,是布局控制与 UAF 的入口
3. edit(idx, content) -> 产生写,长度未校验时是堆溢出/off-by-one 的来源
4. show(idx) -> 产生读,是泄露 libc 与堆地址的唯一途径
值得注意的是,这四个操作与漏洞的对应关系是「一对多」的:edit 既可能是长度校验缺失导致的堆溢出,也可能是「允许对已释放的块编辑」导致的 UAF;delete 既可能漏了置空导致悬垂指针,也可能漏了防重放导致 double free。做题时应当把每个操作都当成一个独立的信息泄露或破坏原语来评估,而不是只看题目「明面上」给的漏洞。
泄漏思路通常是:先制造一个 unsorted bin chunk,用 show 读出它的 fd(指向 main_arena + 0x60 附近)算出 libc 基址;再制造一个 tcache chunk,读出被 safe-linking 混淆的 next,用 next ^ (chunk_addr >> 12) 还原出堆地址。有了这两个基址,后续的 tcache poisoning 与 GOT/FSOP 覆写才有确定目标。
检测与防御:版本差异说明「加固是渐进的」。工程上应保持 libc 与内核及时更新,并用 checksec 式的检查脚本(hardening-check)纳入 CI,确保发布产物的 RELRO、PIE、FORTIFY 等开关不被误关。
9. 调试手段与检测防御体系
堆题调不动就没法做。pwndbg 与 gef 提供了专门的堆命令,把不可见的 bin 链表变成可读视图:
pwndbg> heap # 列出所有 chunk,标注 inuse / freed 与归属 bin
pwndbg> bins # 按 fastbin / tcache / unsorted / small / large 分组展示
pwndbg> vis_heap_chunks # 十六进制可视化,直接看到 fd/bk 与 size 标志位
pwndbg> tcachebins # 仅看 tcache 各大小链表的当前状态
pwndbg> p main_arena # 打印 arena 结构,观察 top chunk 与各 bin 头
在 CI 与测试环境里,几个环境变量能零成本提升可观测性:
MALLOC_CHECK_=3 ./chall # 开启堆一致性检查,捕捉部分元数据破坏
MALLOC_PERTURB_=165 ./chall # 释放时用 0xA5 填充,UAF 读到就暴露
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./chall # 关掉 tcache 便于观察
另一个强力手段是 MALLOC_TRACE 与 mtrace():在程序里插入 mtrace(),运行时记录每次 malloc/free 的调用栈,用 mtrace ./chall mtrace.log 分析,能精确定位「哪一行分配、哪一行释放、哪一行又用了」。glibc 还提供 MALLOC_CHECK_=3 环境变量,开启后会启用轻量级的堆一致性检查(类似 mcheck),能捕捉部分 double free 与元数据破坏。
工程上的防御体系应分四层建设:
| 层级 | 手段 | 覆盖的漏洞类型 |
|---|---|---|
| 语言/运行时 | Rust、Go、带 GC 的语言 | 从源头消除 UAF/双释放 |
| 编译期 | -D_FORTIFY_SOURCE=2、-fsanitize=address | 越界写、UAF(测试阶段) |
| 分配器 | glibc key、safe-linking、MALLOC_CHECK_ | double free、伪造指针 |
| 系统层 | seccomp 白名单、容器最小权限、linux 隔离 | 降低利用成功后的影响面 |
其中 seccomp 与容器隔离的价值常被低估:即便攻击者完成了堆利用并劫持了控制流,若进程被限制在只能调用 read/write 的沙箱里,也无法 execve 拉起 shell,实际危害被压缩到「信息泄露」级别。关于隔离机制的原理可参考 Linux 容器与隔离
,把 ASan 与 libFuzzer 接进 CI 则能在漏洞上线前就暴露问题。
检测与防御:堆利用的检测难度远高于栈溢出,因为它的破坏行为分散在大量合法的 malloc/free 调用中。可行手段包括:hook malloc/free 做调用栈审计(LD_PRELOAD 一个记录器)、用 MALLOC_PERTURB_ 在释放时填充可识别模式以捕捉 UAF 读、以及在生产环境启用 glibc 的 tcache 完整性检查。长期看,把关键组件迁移到内存安全语言,是唯一能系统性消除这类漏洞的路径。
权衡取舍
堆利用的手法选择高度依赖版本与题目给的原语。下面这张表是实战中的决策参考:
| 已有条件 | 首选手法 | 理由 | 代价 |
|---|---|---|---|
| UAF 写 + tcache 可用 | tcache poisoning | 一次写即可任意分配 | 2.31 后需先改 key |
| 仅 double free(2.26/2.27) | fastbin dup | 无需额外写原语 | 2.31 后失效 |
| off-by-one 改 size | chunk overlap | 一个字节控制两块 | 需精确的相邻布局 |
| unsorted bin 有块 | unsorted bin 泄漏 | 直接泄露 libc 基址 | 2.31 后写入受限 |
| 有任意写 + 2.35 | FSOP / house of apple | hook 已移除后的唯一路 | 构造复杂、依赖 IO 结构 |
| 有任意写 + ≤2.31 | __free_hook 覆写 | 一次写直接控制 | 2.34 起不可用 |
| 布局不可控 | 先做堆风水 | 把确定性拿回来 | 脚本冗长、需反复试 |
核心权衡是「原语强度 vs 版本限制」:tcache poisoning 是最强的原语(一次 UAF 写换任意分配),但受 key 与 safe-linking 限制;FSOP 几乎不受版本限制,但构造复杂度高、调试困难。经验法则是先确认 libc 版本再选手法,因为在错误的版本假设下写出的脚本会以各种「莫名其妙的段错误」告终,排查成本远高于一开始就读准版本。
常见坑清单
- libc 版本搞错:按 2.27 的直觉在 2.31 上做 double free,被
key校验拦截;做题前先strings libc.so.6 | grep "GNU C Library"确认版本。 - 请求大小算错 chunk 大小:以为
malloc(0x20)得到 0x20 的块,实际是 0x30;记住「向上取整到 16 的倍数,再加 8 字节头部」。 - 忘记 tcache 上限是 7:第 8 次释放同一大小的块才进 fastbin 或 unsorted bin;想构造 unsorted bin 泄漏必须先填满 tcache。
show遇\x00截断:libc 地址高字节含\x00,puts/printf("%s")读不全;用write(1, buf, 8)或读固定长度。- safe-linking 未还原:2.32 之后读到 tcache 的
next是混淆值,直接当地址用必然崩;必须next ^ (chunk_addr >> 12)。 - 释放后被合并进 top chunk:目标块后面没有哨兵块,
free时直接并入 top,导致布局失效;前后各占一个块隔开。 - 改
size后没同步prev_size:伪造的 chunk 触发unlink时因prev_size不匹配被 2.29+ 的校验拦截;两者必须成对设置。 - 误以为
__free_hook永远可用:2.34 起该符号已被移除,脚本里libc.symbols["__free_hook"]会直接抛异常;改用 FSOP。 - 调试环境与远程 libc 不一致:本地跑通远程崩,多半是 libc 或
ld不同;用patchelf让本地链接题目附带的那份 libc。 - 堆风水序列被编译器优化打乱:debug 与 release 下分配顺序可能不同;题目一般给 release 二进制,脚本必须按二进制的真实行为来,而非按源码直觉。
小结
堆利用的知识结构比栈溢出更「立体」:栈上是线性的覆盖,堆上是一张由 chunk、bin、arena、top chunk 构成的图,每个节点都可以被漏洞改写,每个改写都会在后续的分配/释放中被放大。理解这张图的关键是把三件事连起来——chunk 的物理布局(谁挨着谁)、bin 的逻辑归属(谁在哪个链表)、版本的校验强度(哪些改写会被拦下)。这三者一旦同时清楚,看到 add/free/edit/show 四个菜单就能在脑子里预演出整个利用流程。
学习路径上,建议按 glibc 版本从旧到新刷题:先用 2.23 的题目把 unlink 与 unsorted bin 的机制吃透,再用 2.27 体会 tcache 带来的便利,最后用 2.31 与 2.35 的题目适应 key、safe-linking 与 hook 移除后的新手法。每一道题都应当用 pwndbg 的 heap/bins 命令逐步验证自己的布局预期,而不是靠「脚本跑通了就行」——后者在换一道题后必然失效。工具与调试习惯可参考 CTF 工具链与攻击视角下的防御
,动态调试技巧可补 Reverse 方向动态调试与脱壳
。
最后要强调的是视角的切换。堆利用在 CTF 里是「用两个漏洞原语撬动任意读写」,在工程里则是「一次 UAF 就可能导致远程代码执行」。前者训练的是对内存模型的直觉,后者提醒我们为什么 glibc 会逐版本加固、为什么 Rust 会流行、为什么 ASan 与 fuzzing 应当进 CI。把栈与堆放在一起读,你会看到同一条主线:漏洞形态在变,但「让攻击者无法预测、无法改写、无法执行」这三条防御原则始终不变。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。