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。它在进程地址空间里创建一个匿名映射,独立于主堆。freemunmap 立即把页面还给内核——没有碎片问题。

代价:mmapbrk 贵(页表操作、munmap 时的 TLB 刷新)。但如果你的程序频繁分配释放大缓冲区,会看到 sys 时间偏高。通过 mallopt()M_MMAP_THRESHOLD 可以缓解,但也见过有人设太高导致 brk 堆失控增长。


二、Arena 机制与并发

为什么需要 arena

早期 malloc 实现只有一个全局锁。多线程场景下,线程大部分时间在等分配器。ptmalloc2arena(独立内存池,各自有锁)解决这个问题。

  • 主 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 已分配时,fdbk 的空间被重用为用户数据。malloc() 返回的指针实际上指向 fd 的位置——头部之后两个机器字。chunk 被释放时,分配器在那里写入 fdbk

这意味着:如果 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 Freefree(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) 时分配器的顺序:

  1. Fast bin——大小匹配?试试 fast bin。
  2. Small bin——小请求?试 exact small bin。
  3. Unsorted bin——扫描一次。精确匹配返回,其余排序到对应 bin。
  4. Large bin——通过 binmap 搜索,最佳匹配,必要时分割。
  5. Top chunk——从 top 切。
  6. 扩展 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() 宏从双向链表里移除一个 chunk:

#define unlink(P, BK, FD) { \
    FD = P->fd;             \
    BK = P->bk;             \
    FD->bk = BK;            \
    BK->fd = FD;            \
}

早期 glibc 没有任何完整性检查。如果通过堆溢出控制了被释放 chunk 的 fdbk,就可以向任意地址写任意值:FD->bk = BKBK 的值写到 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 还在,变的只是利用路径。

(全文完)