Linux 堆内存管理深入分析:基础机制到核心漏洞利用
深度剖析 Linux glibc (ptmalloc2) 堆内存管理——从 Arena、Chunk、Bin 到 Tcache,以及每层数据结构如何成为攻击面。
Linux 堆内存管理深入分析:基础机制到核心漏洞利用
做系统级开发、C/C++ 性能优化或底层安全的人,迟早要啃堆内存管理。栈溢出已经被 canary 和 NX 压制了,但堆漏洞——use-after-free、double free、堆溢出——仍然高度可利用。现代的 ASLR、PIE、RELRO 只是让攻击者多费点功夫,并不能消灭漏洞本身。
堆管理器既是内存管理者,也是程序和内核之间的缓存层。本文覆盖 ptmalloc2(glibc 的分配器),从系统调用接口一路讲到 arena、chunk、bin、tcache,以及每层结构对应的攻击手法。
一、malloc 背后的系统调用
在讲分配器内部之前,先搞清楚分配器自己怎么从内核拿内存。ptmalloc2 根据分配大小使用两种系统调用。
brk / sbrk
内核加载 ELF 时,进程在数据段末尾有一个 program break 指针。sbrk 把这个指针向上移(分配)或向下移(释放)。它很快——就是移动一个指针——而且分配的内存是连续的。
问题在于:你只能释放堆末尾的内存。如果分配了 A、B、C 然后释放 B,你不能 brk 往回退——C 还在那里。释放的 B 卡在中间,内核要等 B 之后所有的内存都释放了才能回收页面。所以 brk 主要用于小内存分配,分配器预期会在内部复用这些空间。
mmap / munmap
大内存分配(默认阈值 128KB,由 MMAP_THRESHOLD 控制)走 mmap。它在进程地址空间里创建一个匿名映射,独立于主堆。free 时 munmap 立即把页面还给内核——没有碎片问题。
代价:mmap 比 brk 贵(页表操作、munmap 时的 TLB 刷新)。但如果你的程序频繁分配释放大缓冲区,会看到 sys 时间偏高。通过 mallopt() 调 M_MMAP_THRESHOLD 可以缓解,但也见过有人设太高导致 brk 堆失控增长。
二、Arena 机制与并发
为什么需要 arena
早期 malloc 实现只有一个全局锁。多线程场景下,线程大部分时间在等分配器。ptmalloc2 用 arena(独立内存池,各自有锁)解决这个问题。
- 主 arena:绑定到
brk堆,进程启动时创建。 - 线程 arena:基于
mmap段,按需创建。
Arena 数量限制
每个线程建一个 arena 太浪费地址空间,glibc 设了上限:
- 32 位:
2 × CPU 核心数 + 1 - 64 位:
8 × CPU 核心数 + 1
4 核 64 位机器最多 33 个 arena。超过后,线程通过 mutex_trylock 共享已有 arena。我排查过一个线上服务,瓶颈不在业务逻辑而在分配器——128 核机器上 1025 个 arena 听起来很多,但几千个短生命周期线程还是能造成可测量的竞争。
观察 arena 分配
以下程序展示线程 arena 在内存映射中的样子:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
void* threadFunc(void* arg) {
printf("Thread %ld before malloc\n", (long)arg);
getchar();
char* addr = (char*) malloc(1000);
printf("Thread %ld after malloc\n", (long)arg);
getchar();
free(addr);
printf("Thread %ld after free\n", (long)arg);
getchar();
return NULL;
}
int main() {
pthread_t t1;
printf("Main PID: %d\n", getpid());
printf("Before any malloc\n");
getchar();
char* addr = (char*) malloc(1000);
printf("After main-thread malloc\n");
getchar();
free(addr);
printf("After main-thread free\n");
getchar();
pthread_create(&t1, NULL, threadFunc, (void*)1);
pthread_join(t1, NULL);
return 0;
}
运行后在每个 getchar() 暂停时查看 /proc/<pid>/maps:
malloc 前——没有堆段:
0804a000-0804b000 rw-p 00001000 08:01 539624 /home/user/arena_test
主线程 malloc 后——[heap] 出现:
0804b000-0804c000 rw-p 00000000 00:00 0 [heap]
只要了 1000 字节,但 glibc 分配了 132KB(默认 arena 大小)。
线程 malloc 后——线程 arena 出现(通过 mmap,地址不与数据段相邻):
b7500000-b7520000 rw-p 00000000 00:00 0 (thread heap)
free 后——内存没有还给内核。glibc 保留在内部空闲链表里。这是常见的混淆点:free() 不等于"内存还给系统",而是"内存回到 glibc 的池子里"。RSS 不变。
三、分配器数据结构
heap_info
线程 arena 可能由多个 mmap 段组成,glibc 用 heap_info 追踪:
typedef struct _heap_info {
mstate ar_ptr; /* 所属 arena */
struct _heap_info *prev; /* 前一个堆段 */
size_t size;
size_t mprotect_size;
char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];
} heap_info;
这个结构把段链回 arena。一般不会直接碰它,但分析崩溃转储时有用——这个结构被破坏会导致 arena 查找解引用到垃圾地址。
malloc_state(arena 头)
每个 arena 的状态存在 malloc_state 里:锁、bin 链表、top chunk、fastbin 追踪。这是堆攻击的常见目标——破坏它就能控制后续分配。
struct malloc_state {
mutex_t mutex;
int flags;
mfastbinptr fastbinsY[NFASTBINS];
mchunkptr top;
mchunkptr last_remainder;
mchunkptr bins[NBINS * 2 - 2];
unsigned int binmap[BINMAPSIZE];
struct malloc_state *next;
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};
malloc_chunk
每个分配或空闲块都是一个 malloc_chunk。结构和分配/空闲状态共用——只是哪些字段有效不同:
struct malloc_chunk {
INTERNAL_SIZE_T prev_size; /* 前一个 chunk 的大小(仅当前一个空闲时有效) */
INTERNAL_SIZE_T size; /* 本 chunk 大小 + 标志位 */
struct malloc_chunk* fd; /* 前向指针——仅空闲时有效 */
struct malloc_chunk* bk; /* 后向指针——仅空闲时有效 */
struct malloc_chunk* fd_nextsize; /* 仅 large bin 使用 */
struct malloc_chunk* bk_nextsize; /* 仅 large bin 使用 */
};
最关键的设计:chunk 已分配时,fd 和 bk 的空间被重用为用户数据。malloc() 返回的指针实际上指向 fd 的位置——头部之后两个机器字。chunk 被释放时,分配器在那里写入 fd 和 bk。
这意味着:如果 use-after-free,你从悬空指针读到的数据其实是分配器元数据(bin 指针);你往悬空指针写的数据就是在破坏这些指针。这是绝大多数堆漏洞利用的根源。
Chunk 格式的演化
当前格式经历了四个阶段:
第一阶段:隐式空闲链表
最早的分配器只有一个 size 字段。地址对齐的低位(8 字节对齐时始终为 0)被用作 PREV_INUSE 标志。找下一个 chunk:当前地址加 size。合并需要扫描整个堆。
第二阶段:Knuth 边界标记
Knuth 在每个 chunk 末尾加了一个 footer(size 副本)。这样就能向后合并了——从当前地址减 prev_size 找到前一个 chunk 的 footer。代价:每个 chunk 都有 header + footer 开销。
第三阶段:优化边界标记
只有空闲 chunk 才需要 footer,所以 glibc 在当前 chunk 的 size 字段里存 PREV_INUSE 位,表示前一个 chunk 是否已分配。如果前一个是已分配的,那 prev_size 的内存属于前一个 chunk 的负载。这就是今天用的格式。
第四阶段:多线程标志 又加了两个标志位:
IS_MMAPPED (bit 1)——chunk 通过mmap分配NON_MAIN_ARENA (bit 2)——属于线程 arena
三个标志在 size 的低 3 位,因为所有 chunk 大小都是 8 字节(64 位为 16 字节)对齐的:
| size | bit 0: PREV_INUSE (P)
| | bit 1: IS_MMAPPED (M)
| | bit 2: NON_MAIN_ARENA (N)
Top chunk
arena 高地址端的最后一个 chunk。没有 bin 能提供合适的空闲 chunk 时,分配器从 top chunk 切。如果 top chunk 太小,arena 扩展(主 arena 用 sbrk,线程 arena 用 mmap)。
为什么攻击者喜欢它:破坏 top chunk 的 size(设为 -1)就可以实现 House of Force 攻击。申请一个巨大的分配,top 指针绕回,下一次分配就落在攻击者选择的位置。这个攻击在现代 ASLR 下更难了,但在 CTF 和老嵌入式系统上仍然有效。
Last remainder chunk
分配器从 unsorted bin 切出一个 chunk 用于小请求时,剩余部分成为 last remainder chunk,留在 unsorted bin 里。后续的小分配继续从它切,确保分配的内存相邻——有利于缓存局部性。纯粹是优化,平时根本注意不到。
四、Bin:空闲 chunk 的组织方式
分配器不会线性扫描整个堆。空闲 chunk 按大小分到 bin 里。malloc_state 里两个数组:
mfastbinptr fastbinsY[NFASTBINS]; // fast bin(单向链表)
mchunkptr bins[NBINS * 2 - 2]; // 普通 bin(双向链表)
其中 NBINS = 128:
- bin 1:Unsorted bin
- bins 2–63:Small bins(62 个)
- bins 64–126:Large bins(63 个)
Fast bins
大小范围:32 位最大 80 字节,64 位最大 160 字节(步进 8/16)。
单向链表,LIFO。不合并——释放的 fast chunk 保留 PREV_INUSE,不与相邻空闲 chunk 合并。这样快,但会导致碎片。
安全问题:因为 fast bin 是单向链表,早期 glibc 对前向指针没有任何完整性检查。经典 Fastbin Double Free:free(A); free(B); free(A) 形成循环 A → B → A。下一个 malloc 返回 A——它还在链表里。写一个伪造的 fd 指针,再下一次 malloc 就返回任意地址。
Unsorted bin
临时缓冲区。释放的 chunk(非 fast、非 mmap)先到这里。malloc 时分配器扫描 unsorted bin:
- 精确匹配 → 直接返回。
- 太大 → 分割,剩余留下。
- 否则 → 移到正确的 small/large bin。
这样最近释放的 chunk 有机会立即复用,不用花排序的代价。
Small bins
62 个 bin,每个固定大小(不需分割)。FIFO(从尾部取,从头部放)。释放时会和相邻 chunk 合并。
Large bins
63 个 bin,每个覆盖一个范围,步长递增:
| 范围 | 步长 | 数量 |
|---|---|---|
| 512–576 | 64 字节 | 32 |
| 1024–… | 512 字节 | 16 |
| … | 4096 B | 8 |
| … | 32768 B | 4 |
| … | 262144 B | 2 |
| 剩余 | — | 1 |
每个 bin 内部按大小降序排列。fd_nextsize/bk_nextsize 指针可以直接跳到最合适的 chunk。
位图(binmap)标记哪些 bin 非空——分配器 O(1) 跳过空 bin。
分配优先级
malloc(size) 时分配器的顺序:
- Fast bin——大小匹配?试试 fast bin。
- Small bin——小请求?试 exact small bin。
- Unsorted bin——扫描一次。精确匹配返回,其余排序到对应 bin。
- Large bin——通过 binmap 搜索,最佳匹配,必要时分割。
- Top chunk——从 top 切。
- 扩展 arena——
sbrk(主)或mmap(线程)。
五、堆漏洞利用:元数据如何成为攻击面
所有堆攻击都遵循同一个模式:破坏分配器元数据,让下一次 malloc 返回攻击者控制的内存。
Use-After-Free(UAF)
free(p) 之后通过 p 读写。被释放的 chunk 在一个 bin 里,它的 fd/bk 是分配器元数据。读 → 泄漏堆/libc 地址(绕过 ASLR)。写 → 破坏 bin 链表。
常见模式:分配对象 A,释放。fd 指向另一个空闲 chunk(或 null)。如果你能通过悬空指针写,就可以重定向 bin 链表。下一次 malloc 返回 A(从 bin)。再下一次 malloc 返回你写进 fd 的地址。
Double free
两次释放同一指针,中间没有分配。fast bin:free(A); free(B); free(A) 形成 A → B → A。分配两次拿到 A,覆盖它的 fd 为目标地址,第三次分配就返回那个目标。
现代 glibc 会检测同一指针的 double free(检查 fastbin 头部),绕过方法是在中间释放一个不同的 chunk。
Unlink 攻击
unlink() 宏从双向链表里移除一个 chunk:
#define unlink(P, BK, FD) { \
FD = P->fd; \
BK = P->bk; \
FD->bk = BK; \
BK->fd = FD; \
}
早期 glibc 没有任何完整性检查。如果通过堆溢出控制了被释放 chunk 的 fd 和 bk,就可以向任意地址写任意值:FD->bk = BK 把 BK 的值写到 FD + offsetof(bk)。
Safe-unlink(glibc 2.27+):检查 FD->bk == P && BK->fd == P。攻击者不能再放任意地址了——必须找到已经指向 P 的位置(比如 .bss 段的全局指针)。这迫使利用方向转向劫持已知指针,而不是伪造新指针。
House of 系列
命名攻击模式,针对特定结构:
- House of Force:破坏 top chunk size → top 指针绕回 → 在任意地址分配。
- House of Spirit:在栈或 BSS 上 fake 一个 chunk 然后 free → 下一次
malloc返回栈地址 → 覆盖返回地址。 - House of Lore:破坏 small/large bin 的
bk指针 →malloc返回任意地址。
大部分在现代 glibc 下已被缓解,但 CTF 和旧系统上仍然可见。
六、现代缓解措施
Tcache(glibc 2.26+)
每个线程的缓存,用于常用 chunk 大小。减少锁竞争:释放的 chunk 先进 tcache(不需要锁),从 tcache 分配也跳过 arena 锁。
早期 tcache 几乎没有安全检查:没有完整性校验,没有 double-free 检测。覆盖前向指针,下一次分配就返回你的目标。这使 tcache poisoning 成为好几个 glibc 版本的首选攻击方式。
Safe-linking(glibc 2.32+)
对单向链表(fastbin、tcache)的前向指针做 XOR 编码:
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
存的指针是 (pos >> 12) ^ real_ptr。攻击者需要堆地址泄漏才能伪造有效指针。这显著提高了门槛——光有 UAF 不够了,还得知道 chunk 在哪。
关键结论
堆漏洞利用和缓解的猫鼠游戏一直在继续。每个 glibc 版本封一个技术,社区就找到新的。不变的是:bug 还在(UAF、double free、溢出),变的只是利用路径。
七、调试堆问题
Valgrind
valgrind --leak-check=full ./program。模拟每条指令,追踪每个字节的影子状态。能抓到 off-by-one 写入(分配之间的 redzone)。慢(10-50 倍)但非常彻底。我用来做初步排查,日常调试很少用。
AddressSanitizer
-fsanitize=address -g。编译时插桩:分配周围加 redzone,每次内存访问做检查。能抓到 UAF、堆溢出、栈溢出、全局变量溢出。运行时开销 ~2 倍(不是 Valgrind 的 10-50 倍)。这是我最常用的堆 bug 工具。
glibc 内部监控
malloc_stats():打印 arena 统计到 stderr。mallinfo2():返回分配器状态(已分配空间、mmap 用量、fastbin 数量)。
对于长时间运行的服务,定期采样 mallinfo2() 可以揭示 RSS 监控看不到的堆增长模式。我在线上用它排查过一种情况——应用本身没有泄漏,是 glibc 没有把页面还给内核。
MALLOC_CHECK_ 与 glibc 调试
设置 MALLOC_CHECK_=3(或通过 tunables 设 glibc.malloc.check=3)启用额外的堆完整性检查。double-free 或损坏时 glibc 会中止并输出诊断信息而不是静默继续。生产环境不合适(拖慢所有分配器操作),但调试时非常好用。
八、总结
glibc 的 ptmalloc2 需要在速度、碎片、并发、向后兼容之间做平衡。Arena 减少锁竞争。Chunk 元数据支持合并。Bin 按大小组织空闲 chunk 以实现快速分配。同样的数据结构也使得堆漏洞利用成为可能——每个元数字段都是潜在的破坏目标。
理解了 arena、chunk、bin、tcache 和主要缓解措施,你就能调试分配器行为、排查崩溃、阅读漏洞利用分析,而不觉得它们像魔法。Bug 还在,变的只是利用路径。
(全文完)