第一季

内存布局

  • 一个程序典型的内存布局如下图所示:从高地址到低地址分别为内核空间、栈、空闲区域、堆、bss、data、text。
  • 栈是自顶向下增长
  • 堆是自底向上增长
    2026-04-16T14:50:42.png
  • 图片来自iyheart BLOG

brk(sbrk)和mmap

  • 在 Linux 进程的虚拟内存空间中,glibc 的 malloc 作为用户态的内存分配器,其实自身并没有物理内存。当它发现内部的空闲链表不够用时,必须通过System Call向操作系统内核申请虚拟内存。
  • brk(包括其包装函数 sbrk)和 mmap 就是唯二用于扩展进程虚拟内存的系统调用。

    brk 与 sbrk:操纵程序断点Program Break

  • 堆的最高地址边界被称为 Program Break

    • int brk(void *addr);:将 Program Break 直接设置为 addr 指定的绝对地址
    • void *sbrk(intptr_t increment);:将 Program Break 增加 increment 个字节。increment 为正数时扩充堆,为负数时收缩堆。
    【1. 申请前】                   【2. 调用 brk(新地址) 后】
    
          高地址                             高地址
         +---------+                        +---------+
         |         |                        |         |
         | 未映射的 |                        | 未映射的 |
         | 空闲区域 |                        | 空闲区域 |
         |         |                        +---------+ <--- 新 brk 指针 (新堆顶)
         +---------+ <--- 原 brk 指针        |\\\\\\\\\\| 
         | 堆区    |                         |\新分配的\|
         | (Heap)  |                        |\堆内存\\\|
         +---------+                        +---------+ <--- 原 brk 指针 (原堆顶)
         | .bss /  |                        | 堆区    |
         | .data   |                        | (Heap)  |
         +---------+                        +---------+
          低地址                             | .bss /  |
                                            | .data   |
                                            +---------+
                                             低地址
  • 申请过程同理

mmap:创建独立的内存映射

  • Mmap的第一种用法是映射磁盘文件到内存中;第二种用法是匿名映射,不映射磁盘文件,而向映射区申请一块内存
  • Malloc使用的是mmap的第二种用法(匿名映射)
  • Munmap函数用于释放内存
  • 调用形式(简化):mmap(NULL, length, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
  • 它不依赖于连续的 Heap 区域,而是在虚拟内存空间中的 Memory Mapping Segment(内存映射段,通常位于堆和栈之间,动态链接库 .so 也加载于此) 中,寻找一块足够大的连续虚拟地址范围,创建一个全新的 VMA

VMA:虚拟内存
虚拟内存是操作系统内核与底层硬件(CPU 里的 MMU)联合制造的一个“内存寻址抽象层”。它让每一个运行的进程都以为自己独占了一块连续的、极其庞大的内存地址空间,而实际上,这些地址只是逻辑上的代号,它们被切成小块,散落在真实的物理内存(RAM)甚至硬盘(Swap)中
当你往虚拟内存里写入东西时,底层发生的真实情况是:你的写入指令,直接穿透了虚拟层,被硬件重定向到了真实的内存条引脚上。
所以往虚拟内存里写入的东西实际是写入的物理内存
缺页中断(Page Fault)与延迟分配:
当你 malloc(1GB) 时,系统只是在你的虚拟内存里划出了 1GB 的合法地址范围,但页表里并没有给它们分配真实的物理页(物理内存一点都没变少)。

当你的程序第一次尝试往这 1GB 的某个地址写入数据时,CPU 的 MMU 查页表发现:“哎?这个虚拟页没有对应的物理页啊!”。

此时,MMU 会立刻向 CPU 抛出一个硬件级别的异常,叫作缺页中断(Page Fault)。

操作系统内核捕获这个中断,暂停你的程序,跑去物理内存里找一页真实的空闲内存(4KB),把它清零,然后在页表里把你的虚拟地址和这块物理地址连起来。处理完后,让你的程序继续执行。你的程序完全感受不到中间发生的停顿。

  • mmap()和brk()/sbrk()这两种不同方式申请的堆内存是互相独立的,各自管理不同的内存区域,使用mmap时并不会自动调整brk指针。

匿名映射:

  • 创建一段没有关联任何文件 inode 的 VMA,这段虚拟内存完全由物理内存(RAM)或交换分区(Swap)支撑,虚拟内存有点类似于指针,往虚拟内存里写入的东西实际是写入的物理内存
  • 简单来说:操作系统给你分配了一段虚拟内存,但它不和任何硬盘文件绑定。它连向的是真实的物理内存条上一块完全空白、被清零的区域
  • 当程序结束或者调用 free(底层触发 munmap)时,这块内存里的数据会立刻丢失,物理内存被系统收回。它绝对不会像文件映射那样保存到硬盘上。
  • 过程:
【模式一:匿名映射 (Anonymous Mapping)】
        —— 场景:malloc 申请 >128KB 的大内存

   进程虚拟内存空间                       真实的物理内存
+--------------------+               +--------------------+
|     栈 (Stack)     |               |                    |
+--------------------+               |                    |
|       ...          |               |                    |
|                    |     映射      |                    |
| [ 新增的匿名内存 ]  | ============> [   全新物理页(清零)  ]
| (纯粹的空白飞地)    |               |                    |
|                    |               |                    |
|       ...          |               |                    |
+--------------------+               |                    |
|     堆 (Heap)      |               |                    |
+--------------------+               +--------------------+

文件映射:

  • 将进程虚拟地址空间中的一段 VMA(虚拟内存区域)与磁盘上某个文件的 inode 及物理数据块建立逻辑关联
  • 简单来讲:操作系统把一段虚拟内存地址,直接和硬盘上的某个文件(比如 /lib/x86_64-linux-gnu/libc.so.6)绑死在一起连线的另一头是:硬盘上的具体文件。
  • 几乎所有动态链接库(比如 libc.so)被加载到内存时,用的都是文件映射。
  • 当你往这段虚拟内存里写入数据时(如果是共享映射),操作系统会在后台自动把你的修改保存到硬盘的那个文件里。
       【模式二:文件映射 (File-backed Mapping)】
        —— 场景:程序读取大文件,或加载 libc.so

   进程虚拟内存空间                       硬盘 / 文件系统
+--------------------+               +--------------------+
|     栈 (Stack)     |               |                    |
+--------------------+               |                    |
|       ...          |               |                    |
|                    |     映射      |                    |
| [ 新增的文件内存 ]  | ============> [  硬盘上的文件数据   ]
| (文件直通缓冲区)    |               | (比如 libc.so)     |
|                    |               |                    |
|       ...          |               |                    |
+--------------------+               |                    |
|     堆 (Heap)      |               |                    |
+--------------------+               +--------------------+

chunk结构

chunk里面的数据:

  • prev_size: 如果前面一个块是空闲的,该区域表示前一个chunk的大小,如果前一个chunk不空闲,该域无意义
  • size:当前chunk的大小,并且记录了当前chunk和前一个chunk的一些属性(记录在size的最后3个位中),包括前一个chunk是否在使用中,当前chunk是否通过mmap获得的内存,当前chunk是否属于非主分配区
  • fd和bk:chunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下:

    • fd 指向下一个(非物理相邻)空闲的 chunk。
    • bk 指向上一个(非物理相邻)空闲的 chunk。
  • fd_nextsize和bk_nextsize:只有 chunk 空闲的时候才使用,不过其用于较大的 chunk(large chunk)。

    • fd_nextsize 指向前一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
    • bk_nextsize 指向后一个与当前 chunk 大小不同的第一个空闲块,不包含 bin 的头指针。
  • 一般空闲的 large chunk 在 fd 的遍历顺序中,按照由大到小的顺序排列。这样做可以避免在寻找合适 chunk 时挨个遍历。

请输入图片描述
图片来源:wiki.wgpsec.org

malloc_chunk&Allocated chunk&Free chunk

特性malloc_chunkAllocated chunkFree chunk
概念层面glibc 源码中的 C 语言 struct 定义运行时被程序使用的内存状态运行时被系统回收的内存状态
Header 占用包含所有字段的定义最小只占 8 字节(仅保留 size)至少 16 字节(包含 size + 被覆写的 fd/bk)
fd 和 bk 指针存在于结构体定义中绝对不存在(被用户数据覆盖)绝对存在(覆盖了用户旧数据)
操作者是谁编译器与底层开发者你的代码(通过指针对用户数据区进行读写)系统内存管理器(操作 Header 和 fd/bk 维护链表)
  • 简单来说:

    • malloc_chunk:结构体定义
    • Allocated chunk:已分配块
    • Free chunk:已释放块
  • 在 glibc 的核心源码 malloc/malloc.c中,堆块的结构体是这样定义的:
struct malloc_chunk {
  INTERNAL_SIZE_T      mchunk_prev_size;  /* 前一个空闲块的大小 */
  INTERNAL_SIZE_T      mchunk_size;       /* 当前块的大小 (带有 AMP 标志位) */
  
  struct malloc_chunk* fd;                /* 指向下一个空闲块的指针 */
  struct malloc_chunk* bk;                /* 指向前一个空闲块的指针 */
  
  /* 只有大型空闲块 (Large Bin) 才会用到的指针 */
  struct malloc_chunk* fd_nextsize; 
  struct malloc_chunk* bk_nextsize;
};

chunk 的标志位(A/M/P 位)

在 malloc_chunk 结构体中,mchunk_size 字段不仅记录当前 chunk 的大小。由于 Linux 下堆内存分配需要按 8 字节或 16 字节对齐,size 的最低 3 个 bit 永远为 0。

glibc 利用这低 3 位作为当前 chunk 的状态标志位(从低到高依次为 P、M、A):

位宏 / 含义取值语义
PPREV_INUSEP = 1物理相邻的前一个 chunk 正在被使用(Allocated)。此时当前 chunk 的 prev_size 字段无效(可能被前一个 chunk 用作数据区)。
PPREV_INUSEP = 0物理相邻的前一个 chunk 是空闲的(Free)。此时当前 chunk 的 prev_size 记录前一个空闲 chunk 的大小;堆块合并(Coalescing)依赖该位。
MIS_MMAPPEDM = 1当前 chunk 通过 mmap 系统调用独立分配(mmap 区域)。
MIS_MMAPPEDM = 0当前 chunk 属于常规 heap(由 brk / sbrk 扩展)。
ANON_MAIN_ARENAA = 0当前 chunk 属于主分配区 main_arena(主线程堆)。
ANON_MAIN_ARENAA = 1当前 chunk 属于非主分配区(thread arena / 子线程 arena)。

堆内存管理核心:main_arena 与 bins

当一个 chunk 被 free 后,为了高效复用,glibc 会将其挂载到特定的数据结构中进行管理。主线程的管理器称为 main_arena(类型为 struct malloc_state),位于 libc.so 的数据段。

main_arena 通过内部的指针数组维护多种空闲链表,这些链表统称为 bins。

空闲链表bins

  • ptmalloc中定义了malloc_state结构体用来统一管理bins。(而不是分别声明fast_bin、unsorted_bin),这就可以使他们在内存上是线性关系的。
  • 当用户使用free函数释放掉内存,ptmalloc并不会马上将其交给操作系统,而是被ptmalloc本身的空闲链表bins管理起来。
  • 这样当下次进程需要使用malloc申请堆内存的时候,ptmalloc就会从空闲的bins上寻找一块合适大小的内存块分配给用户使用。可以避免频繁系统调用,提高程序运行效率,降低内存分配开销
  • malloc将相似大小的chunk用双向链表链接起来,这样的一个链表被称为一个bin。ptmalloc一共维护了128个bin(也就是说维护了128个双向链表)。每个bins都维护了大小相近的双向链表chunk。根据chunk大小分为以下几种bins:

    • Fast bin
    • Unsorted bin
    • Small bin
    • Large bin
  • 保存这些数据结构为

    • fastbinsY:这个数组以保存fast_bins
    • bins: 这个数组以保存unsorted、small以及large_bins,共计可容纳126个

    当用户调用malloc的时候,能很快找到用户需要分配的内存大小是否在维护的bin上,如果在某一个bin上,就可以通过双向链表去查找合适的chunk内存块给用户使用

  • fast_bins中的chunk,size最后一位始终置1,这是为了防止fast_bin中chunk的内存合并
+---------------------------+
|       malloc_state        |
|                           |
| +-----------------------+ |
| |       fastbinsY       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |     unsorted_bin      | |
| | +-----+              | | 
| | | * ->|-> NULL       | | 
| | +-----+              | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |       smallbins       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
| +-----------------------+ |
| |       largebins       | |
| | +-----+  +-----+     | | 
| | | * ->|->| * ->|->...| | 
| | +-----+  +-----+     | |
| +-----------------------+ |
|                           |
+---------------------------+

其实就是一个邻接表(太好了,是图论,我们有救了()

1. tcache(Thread Local Cache)

  • 引入版本:glibc 2.26 及以后
  • 机制原理:为提升多线程分配效率,每个线程维护独立缓存结构(线程本地)
  • 数据结构:单向链表(仅使用 fd 指针),遵循 LIFO(后进先出),头插
  • 容量限制:默认支持最大 0x410 字节的 chunk;每种大小的链表最多存放 7 个 chunk
安全特性:tcache 操作几乎没有强校验(早期实现对 double free 检查不足),且分配时优先从该链表取用,是现代堆利用(tcache poisoning)的常见目标。

2. fast bins(fastbinsY)

  • 物理位置:main_arena.fastbinsY 数组
  • 机制原理:管理较小尺寸的空闲块(默认最大约 0x80 字节)。当 tcache 满载或不可用时,小块可能进入此处
  • 数据结构:单向链表(仅 fd 指针),LIFO(后进先出)

核心逻辑(不合并机制)

  • 当一个 chunk 被放入 fast bin 时,系统不会修改其物理相邻的下一个 chunk 的 P(PREV_INUSE)标志位(仍保持 P = 1)
  • 因此,处于 fast bin 中的 chunk 不会与相邻空闲 chunk 发生合并(Coalescing)

安全检查(典型)

  • 仅检查当前放入的 chunk 是否与链表头的第一个 chunk 相同(可防范连续 double free)
  • 不防范交替释放:free(A) -> free(B) -> free(A)

fast bin 链表示例(单向)

main_arena.fastbinsY[0] (size = 0x20) ---> [Chunk B] --fd--> [Chunk A] --fd--> NULL

3. unsorted bin

  • 物理位置:main_arena.bins 数组的第 1 个元素(bins[1])
  • 机制原理:空闲块的“中转分拣站”。当被释放的 chunk 大小超过 fast bin 限制且不进入 tcache 时,通常会先进入 unsorted bin
  • 数据结构:双向循环链表(使用 fd 与 bk 指针)

分拣逻辑(发生在后续 malloc)

  • 当 malloc 无法从 tcache 与 fast bins 满足需求时,会遍历 unsorted bin
  • 若找到大小精确匹配的 chunk:直接分配给用户
  • 若大小不匹配:将该 chunk 从 unsorted bin 摘除,并按真实大小整理(sort)到 small bins 或 large bins

4. small bins

  • 物理位置:main_arena.bins[2] 到 main_arena.bins[63]
  • 机制原理:管理常规大小的空闲块(通常 < 1024 字节,具体以实现为准)
  • 数据结构:双向循环链表,遵循 FIFO(先进先出):新释放的插入头部,分配时从尾部取
  • 特征:每个 small bin 索引对应的链表中,chunk 大小唯一且相等(例如 bins[2] 中只存在 0x20 大小的 chunk)

5. large bins

  • 物理位置:main_arena.bins[64] 到 main_arena.bins[126]
  • 机制原理:管理大尺寸空闲块。由于尺寸跨度大,一个索引通常对应一个大小范围(range)
  • 数据结构:双向循环链表

特征(排序机制)

  • 同一 large bin 内含不同大小的 chunk,放入时通常按从大到小排序
  • 会使用 fd_nextsize 与 bk_nextsize 指针跳过相同大小的 chunk,以加速遍历查找

堆分配(malloc)与释放(free)的宏观顺序

下述为便于理解的“宏观优先级”,不同 glibc 版本与配置可能存在细节差异。

malloc(size) 寻址优先级

  1. 检查 tcache 是否有对应大小的空闲块
  2. 检查 fast bins
  3. 检查 small bins(若 size 符合)
  4. 遍历 unsorted bin(并可能触发分类整理到 small bins / large bins)
  5. 检查 large bins(寻找可满足需求的最小 chunk,必要时切割)
  6. 切割 top chunk(堆区最高地址处的未分配区域)
  7. 若 top chunk 不足,触发 sysmalloc(调用 brk 扩展 top chunk,或大内存直接 mmap)

free(ptr) 回收优先级

  1. 尝试挂入 tcache(若未满)
  2. 若 size 符合且 tcache 已满:头插挂入 fast bins
  3. 若大于 fast bins 限制:触发合并(Coalescing)逻辑

    • 检查物理相邻前一个块是否空闲(通过 P 位),若空闲则合并
    • 检查物理相邻后一个块是否空闲,若空闲且不在 fast bin 中,则合并
    • 将合并后的 chunk 以双向链表形式挂入 unsorted bin,并清除物理相邻下一个块的 P 位

第二季

  • 由于上学的时候太忙碌了,没有太多时间学堆,所以学着学着弃坑了(,现在放假了,跑去公司实习,终于有时间来摸鱼学堆了(

堆基础

关于malloc_chunk的基础

  • 被分配后的堆块:
struct malloc_chunk_allocated {
    size_t prev_size;   // 偏移 0x00:如果物理相邻的**上一个**块是空闲的,这里记录上一个块的大小。
                        // (注意:如果上一个块在使用中,这 8 字节会被上一个块借用存用户数据!)
    size_t size;        // 偏移 0x08:当前块的大小。最低的 3 个比特位用作标志位(如 PREV_INUSE)。
    char user_data[];   // 偏移 0x10:用户真正读写的数据区域(malloc 返回的就是这里的地址)。
};
  • 而其地址的排布顺序是这样的:
+-----------------------+
| 用户数据 (User Data)  | 
| (比如你的结构体内容)  | 
+-----------------------+ <-- malloc 返回的指针指向这里 (偏移 0x10)
| size + 标志位 (A|M|P) | <-- 记录当前块大小,最低位 P(PREV_INUSE) 记录上一个块是否在使用
+-----------------------+
| prev_size             | <-- 只有上一个块空闲时才有效;否则存的是上个块的用户数据
+-----------------------+
  • 空闲的堆块:
struct malloc_chunk_free {
    size_t prev_size;   
    size_t size;        
    /* [主体 - 原本的 user_data 区域变成了链表指针] */
    struct malloc_chunk* fd; // 偏移 0x10:Forward pointer,指向链表中的下一个空闲块
    struct malloc_chunk* bk; // 偏移 0x18:Backward pointer,指向链表中的上一个空闲块
    // (如果是 Large Bin,下面还有 fd_nextsize 和 bk_nextsize)
};
  • 而其地址的排布顺序是这样的:
+-----------------------+
| (未被覆盖的残留数据)  | 
+-----------------------+
| bk (后向指针)         | <-- 原本存放 user_data 的地方,现在存了上一个空闲块的地址
+-----------------------+
| fd (前向指针)         | <-- 原本存放 user_data 的地方,现在存了下一个空闲块的地址
+-----------------------+ <-- (偏移 0x10)
| size + 标志位 (A|M|P) | 
+-----------------------+
| prev_size             | 
+-----------------------+
  • malloc_chunk 的源码如下:
struct malloc_chunk {
INTERNAL_SIZE_T prev_size;  /*前一个chunk的大小*/
INTERNAL_SIZE_T size;       /*当前chunk的大小*/
struct malloc_chunk * fd;   /*指向前一个释放的chunk*/
struct malloc_chunk * bk;   /*指向后一个释放的chunk*/
}
  • 下面是一个经典的堆申请语句:
v3 = (const char **)malloc(0x10u);
*v3 = (const char *)malloc(size);
v3[1] = (const char *)size;
  • 意思是先在堆上分配一个 16 字节的堆块,然后再在堆上分配一个 size 大小的“数据块”,并把它的地址存进刚才分配的那个块里,*v3 = (const char *)malloc(size);实际是新申请了一个空间用于存放数据,然后返回了地址值,由于 v3 是指向那个 16 字节块的指针,*v3 就代表这 16 字节空间里的最开头 8 个字节。
  • 在这段代码中, *v3 放到了官方结构体里 fd 的位置上,而 v3[1] 放到了官方结构体里 bk 的位置上

在 glibc 里,一个完整的堆块是从 prev_size 开始的。但 malloc 函数不会把 prev_size 的地址返回给用户。为了保护头部不被普通程序员不小心改掉,malloc 总是把跳过 16 字节头部后的地址(也就是 fd 的所在位置)返回给用户。
所以,当执行 v3 = malloc(0x10u); 时,v3 拿到的指针指向了官方结构体里的 fd。

  • 于是,当申请完堆空间后,实际是这样的:
struct malloc_chunk {
    /* ------ glibc 隐藏的头部 (16 字节) ------ */
    size_t prev_size;  
    size_t size;       // 值为 0x21 (因为 0x10 用户数据 + 0x10 头部 = 0x20,加标志位变成 0x21)

    /* ------ 返回给 v3 的用户空间 (16 字节) ------ */
    
    // v3 指向这里!所以 *v3 就是对这 8 字节赋值
    // 官方定义的 fd,现在存放了数据块的真实指针
    struct malloc_chunk* fd;   <====== 对应代码:*v3 = (const char *)malloc(size); 

    // v3[1] 指向这里!(相当于 v3 加上 8 字节)
    // 官方定义的 bk,现在存放了用户输入的 size
    struct malloc_chunk* bk;   <====== 对应代码:v3[1] = (const char *)size; 
};
+=======================================+ 
  | prev_size                             | <-- 底层真正的 Chunk 起始地址
  +---------------------------------------+ 
  | size = 0x21                           | 
  +=======================================+ <== v3 的指针指向这里!
  | 存放着数据块的指针 (比如 0x55555577030) | <-- 原本是 fd 的位置 (*v3)
  +---------------------------------------+ 
  | 存放着具体的数字 (比如 size = 0x100)    | <-- 原本是 bk 的位置 (v3[1])
  +=======================================+

堆块的排布

  • 在 glibc 中,堆的生长方向是从低地址向高地址生长的。程序最先 malloc 出来的内存块在最下面(低地址),后来分配的块依次往上叠(高地址),最上方则是尚未分配的广阔空间Top Chunk。
  • like this:
  • 图中显示的是内存中连续相邻的三个堆块:使用中(Chunk A) -> 空闲(Chunk B) -> 使用中(Chunk C)。
【高地址】 (High Memory Addresses)
  ▲
  |   +=======================================+ <== Chunk C (使用中 / Allocated) 结束
  |   |                                       |
  |   |           Chunk C 的用户数据区        | 
  |   |                                       |
  |   +---------------------------------------+ 
  |   | size = 0x...0 (P位=0, 记录下方块空闲) | <-- 因为 Chunk B 空闲,所以 P位=0
  |   +---------------------------------------+ 
  |   | prev_size = Chunk B 的大小            | <-- 指针 p (Chunk C 的头部起始位置)
  |   +=======================================+ <== Chunk B (空闲状态 / Free) 结束
  |   |                                       |
  |   |          (未使用的剩余空闲空间)       |
  |   |                                       |
  |   +---------------------------------------+ <-- 偏移 0x18
  |   | struct malloc_chunk* bk               | <-- 【官方定义】指向上一个同等大小的空闲块
  |   +---------------------------------------+ <-- 偏移 0x10
  |   | struct malloc_chunk* fd               | <-- 【官方定义】指向下一个同等大小的空闲块
  |   +---------------------------------------+ <-- 偏移 0x08
  |   | size = 0x...1 (P位=1, 记录下方块在用) | <-- 记录 Chunk B 自己的总大小
  |   +---------------------------------------+ <-- 偏移 0x00 
  |   | prev_size (无效, 被 Chunk A 借用)     | <-- 指针 p (Chunk B 的头部起始位置)
  |   +=======================================+ <== Chunk A (使用中 / Allocated) 结束
  |   | [Chunk A 的用户数据最后 8 字节]       | 
  |   | (强行写在了 Chunk B 的 prev_size 里)  | <-- 空间复用
  |   +---------------------------------------+ 
  |   |                                       |
  |   |        用户实际输入的数据内容         |
  |   |                                       |
  |   +---------------------------------------+ <-- 偏移 0x18 (原 bk 的位置)
  |   | 用户数据 (覆盖了官方定义的 bk 位置)   |
  |   +---------------------------------------+ <-- 偏移 0x10 (原 fd 的位置)
  |   | 用户数据 (覆盖了官方定义的 fd 位置)   | <-- ★ malloc 返回给程序员的地址 (mem)
  |   +---------------------------------------+ <-- 偏移 0x08
  |   | size = 0x...1 (P位=1)                 | 
  |   +---------------------------------------+ <-- 偏移 0x00
  |   | prev_size                             | <-- 指针 p (Chunk A 的头部起始位置)
  |   +=======================================+ <== 堆底 (Heap Base)
  ▼
【低地址】 (Low Memory Addresses)

一个堆块的起始指针 p 永远指向 prev_size;而给到程序员的指针 mem(即 malloc 返回的值)永远指向 fd 所在的那个位置

prev_size 借用机制(会导致 Off-By-One 漏洞)

看Chunk A 和 Chunk B 的交界处。
官方结构体里确实定义了 prev_size。但是,如果当前块(Chunk B)前一个相邻的块(Chunk A)正在被使用,那么当前块的 prev_size 就是没有意义的,既然没有意义,glibc 就不会浪费 8 个字节。于是,系统允许 Chunk A 把它的用户数据一直写到 Chunk B 的 prev_size 空间里。

于是,如果往 Chunk A 写数据时,刚好发生了 1 个字节的越界写(Off-By-One),就会直接覆盖掉 Chunk B 的 size 字段最低位(P位),从而欺骗系统,造成堆块重叠(Chunk Overlapping)

Fastbin 的 LIFO 机制

  • 像之前说的一样,Bins 分为很多种(Fastbin, Unsorted bin, small bin, Large bin 等)
  • 而对于Fastbin来说,他具有以下特点:

    • 大小限制:专门回收比较小的 Chunk(通常小于 0x80 字节)。
    • 数据结构:单向链表。它只用 fd 指针,不用 bk。
    • 先进后出 (LIFO):最后被 free 的 chunk,会在下一次 malloc 时被最先分配出去。

    -假设系统现在是干净的,链表头部指向 NULL:Head -> NULL

    • 1.free(A):把 A 放进去,A 的 fd 指向 NULL. => Head -> A -> NULL
    • 2.free(B):把 B 放进去,B 的 fd 指向当前的头部 A. => Head -> B -> A -> NULL
  • 下次 malloc 时,系统直接去 Head 取出 B,然后把 Head 更新为 B 的 fd(也就是 A)。

double free

  • 当free掉一个堆块后,会被挂会bins链,由于 Chunk 处于 Free 状态时,由于 User Data 区域里的用户数据已经没用了,glibc 会直接把这块区域拿来存放链表指针 fd (Forward Pointer) 和 bk (Backward Pointer)。
  • like this:
+-------------------------+
| prev_size (8 bytes)     | 
+-------------------------+
| size (8 bytes)          | 
+-------------------------+
| fd (8 bytes)            | <- 覆写了原本 User Data 的前 8 字节!指向下一个空闲 Chunk
+-------------------------+
| bk (8 bytes)            | <- 覆写了原本 User Data 的第 9-16 字节!
+-------------------------+
| 剩下的空闲空间...          |
+-------------------------+
  • 漏洞形成的原因是 Double Free 导致链表成环
  • 假设我们连续申请了两个 Chunk:A 和 B。

1. 释放 A

free(A);

Fastbin 状态: Head -> A -> NULL

2. 释放 B

(这一步是为了绕过 glibc 的基础保护:Fastbin 不允许连续释放同一个位于头部的 Chunk)

free(B);

Fastbin 状态: Head -> B -> A -> NULL

3. 再次释放 A (Double Free)

free(A);
  • 系统看到当前头部是 B,不是 A,所以它认为 A 是合法的。它把 A 重新放到头部,并让 A 的 fd 指向当前的头部 B。

Fastbin 状态: Head -> A -> B -> A -> B -> ...

  • 这就是一切灾难的根源:单向链表变成了一个环形链表。

漏洞利用:指针投毒 (Fastbin Dup):

第一步:申请出成环的 Chunk

void *ptr1 = malloc(0x20); // 申请拿到了 A
  • 此时:
    • ptr1 指向了 A 的 User Data 区域。
    • Fastbin 状态更新为: Head -> B -> A -> B...

第二步:篡改 fd 指针

  • 此时,A 对程序来说,是“已经分配”的可用内存;但对于 Fastbin 来说,A 仍然在空闲链表里(因为排在 B 后面)
  • 然后通过 ptr1 写入数据来篡改:

    *ptr1 = 0x601000; // 往 A 里写数据
  • 此时 A 的 User Data 前 8 个字节,在 glibc 眼里正是 fd 指针
  • 这一步写数据,在底层直接把 A 的 fd 改成了目标地址 0x601000(比如 GOT 表地址)。
  • 此时的 Fastbin 已经被污染:
  • Fastbin 状态: Head -> B -> A -> 0x601000

第三步:拿到目标地址

malloc(0x20); // 拿到了 B。此时 Fastbin 状态:Head -> A -> 0x601000
malloc(0x20); // 拿到了 A。此时 Fastbin 状态:Head -> 0x601000

第四步:大功告成

void *target = malloc(0x20); 

bins链

  • 2026-08-12T07:16:38.png
  • 以上是不同的bins链可以容纳的不同chunk大小,free掉不同大小的chunk,chunk就会被挂入不同的bin链。
  • **如果上一次free掉的chunk和我这一次申请的chunk大小差不多,则会分配返回上一次free掉的那个chunk的起始地址。

    • 上一次free掉的和这一次申请的大小一样:LIFO机制,很显然会返回刚刚被挂入bins链的chunk
    • 上一次free掉的比这一次申请的大:会进行切割。
释放 chunk (0x110 含元数据)
       ↓
申请 0x90 (实际需要 0xA0 含元数据)
       ↓
从 0x110 中切出 0xA0 返回
       ↓
剩下 0x70 (0x110 - 0xA0) 成为新的空闲块
  • 详细过程:
* 场景:释放 0x100 字节,申请 0x90 字节
 * ========================================
 */

/*
 * 步骤 1:原始状态 - 释放的 chunk
 * ========================================
 * 地址: 0x603000
 * 用户申请: 0x100 字节
 * 实际 chunk 大小: 0x110 (含 0x10 字节元数据)
 * 
 * 内存布局:
 * +------------------+ <-- 0x603000 (chunk 起始)
 * | prev_size: 0     |    (前一个 chunk 的大小,0 表示无)
 * +------------------+
 * | size: 0x111      |    (0x110 + PREV_INUSE 标志)
 * +------------------+ <-- 0x603010 (用户数据起始)
 * |                  |
 * | 用户数据区        |
 * | (0x100 字节)     |
 * |                  |
 * |                  |
 * +------------------+ <-- 0x603110 (chunk 结束)
 */

/*
 * 步骤 2:申请 malloc(0x90)
 * ========================================
 * 用户申请: 0x90 字节
 * 实际需要: 0x90 + 0x10 (元数据) = 0xA0
 * 对齐到 16 字节: 0xA0 (已是 16 的倍数)
 */

/*
 * 步骤 3:分割过程
 * ========================================
 * 从 0x110 中切出 0xA0,剩余 0x70
 * 
 * 分割后内存布局:
 * +------------------+ <-- 0x603000 (原 chunk 起始)
 * | prev_size: 0     |
 * +------------------+
 * | size: 0xA1       |    (0xA0 + PREV_INUSE 标志)
 * +------------------+ <-- 0x603010 (返回给用户的指针)
 * |                  |
 * | 新 chunk 数据区   |
 * | (0x90 字节)      |
 * |                  |
 * |                  |
 * +------------------+ <-- 0x6030A0 (第一个 chunk 结束)
 * | size: 0x71       |    (0x70 + PREV_INUSE 标志)
 * +------------------+ <-- 0x6030A0 (剩余 chunk 起始)
 * |                  |
 * | 剩余空闲区        |
 * | (0x60 字节)      |
 * |                  |
 * +------------------+ <-- 0x603110 (原 chunk 结束)
 */
  • 剩余 chunk 会根据大小而被决定放入什么bin链
  • 上一次free掉的比这一次申请的小:会查找其他 bin 中更大的空闲块,如果找不到,从 Top Chunk 切割,必要时向操作系统申请新内存。

__malloc_hook

  • __malloc_hook 是 glibc 中一个全局函数指针变量,用于在调用 malloc() 时进行回调。
// glibc 源码中的定义
void *(*__malloc_hook)(size_t size, const void *caller) = NULL;
  • 位于 libc.so 的数据段(.data 或 .bss 段)
  • 地址固定偏移(相对于 libc 基址)
  • 可以通过 libc.symbols['__malloc_hook'] 获取
  • 正常情况下,里面存的是 0x0。但是每次程序调用 malloc 分配内存时,系统都会先看一眼 __malloc_hook,如果它是 0x0:正常分配内存。如果它里面有一个地址就直接跳转去执行那个地址。
用户调用 malloc(size)
        ↓
┌───────────────────────────────────────┐
│  glibc 的 malloc() 函数入口           │
└───────────────────────────────────────┘
        ↓
┌───────────────────────────────────────┐
│  检查 __malloc_hook 是否为 NULL?     │
└───────────────────────────────────────┘
        ↓
    ┌───┴───┐
    │       │
   YES      NO
    │       │
    │       ↓
    │   ┌─────────────────────────┐
    │   │ 调用 __malloc_hook 指向  │
    │   │ 的函数                   │
    │   │ hook(size, caller)      │
    │   └─────────────────────────┘
    │       ↓
    │   ┌─────────────────────────┐
    │   │ hook 函数执行           │
    │   │ (可能是攻击者控制的代码) │
    │   └─────────────────────────┘
    │       ↓
    │   ┌─────────────────────────┐
    │   │ 返回结果给用户           │
    │   └─────────────────────────┘
    │
    ↓
┌───────────────────────────────────────┐
│  执行真正的内存分配                    │
│  __libc_malloc(size)                 │
└───────────────────────────────────────┘
        ↓
┌───────────────────────────────────────┐
│  返回分配的内存地址给用户              │
└───────────────────────────────────────┘

Top chunk

  • 是位于堆内存最高地址处的一块巨大且连续的未分配内存。
  • 当调用 malloc(size) 申请内存时,glibc 会优先去fastbin、unsortedbin、smallbins 等里找有没有刚好合适的 free chunk。如果都是空的,或者里面没有足够大的块能满足要求,glibc 就会把从 Top Chunk 顶部切下需要的 size 作为申请而得的chunk,然后把剩下的部分更新为新的 Top Chunk。
  • 为了防止内存碎片化,glibc 有一个合并机制:除了进入 fastbin 的小块之外,任何被 free 的 chunk 如果在物理内存上与 Top Chunk 相邻,就会与 Top Chunk 合并。
  • 至于为什么会与Top Chunk 相邻,可以去看看上面的堆排布部分,这也是为什么要创建隔离块。
  • 在 glibc 的底层设计中:main_arena 单向记住 Top Chunk,在 main_arena 结构体中,有一个专门的指针叫 top,它记录着当前 Top Chunk 在堆上的起始地址。

2026-08-18T08:26:32.png


main_arena

  • main_arena 是一个名为 malloc_state 的核心结构体实例。它的主要职责就是记录当前堆内存的各种状态。
  • main_arena 里就存储着:

    • Bins 数组的头指针: 所有的 fastbins、unsortedbin、smallbins、largebins 的链表头尾指针,全部存放在 main_arena 的内部数组里。
    • Top Chunk 的地址: 记录着当前 Top Chunk 从哪个地址开始。
    • 堆的统计信息: 比如当前一共向操作系统申请了多少内存等。
  • main_arena 是 libc.so.6 这个动态链接库里的一个全局静态变量(存放在 .data 或 .bss 段)。这意味着:main_arena 在 libc 内部的偏移量(Offset)是永远固定的。只要拿到了 main_arena 的真实运行地址,减去它在 libc 里的固定偏移,就能算出 libc 的基址。
  • Unsorted Bin,Small Bins,Large Bins 的链表机制会自动把main_arena中当前这个bins[i]的地址写进自己Chunk 的fd和bk里(关于什么是bins[i]可以看后面),而Fastbins不会。这是由数据结构决定的。这是因为它们需要维护一个双向循环链表,而链表的头地址存于main_arena。为了把链表连起来,Chunk 的 fd 和 bk 必须存 main_arena 的地址
  • malloc_state 的`bins 数组并不是这个结构体的第一个字段。在它之前,结构体中还依次存放着其他调度变量:

    • mutex(线程锁)
    • flags(状态标志位)
    • fastbinsY(Fastbins 的单向链表数组)
    • top(Top Chunk 的指针)
    • last_remainder(最近一次分割后剩余 chunk 的指针)
    • 因此,要指向 bins 数组,指针本身就必须带有跨越这些前置变量的物理偏移量。这也是为什么Unsorted Bin不能指向main_arena首地址,因为他们双向链表循环的建立是要建立为bins[i]<->A<->B<->bins[i]这样的,而不是指向main_arena首地址。main_arena的结构可以看前面。因此,Unsortedbins指向的main_arena+offset中的offset一般是固定的,因为它指向固定的bins[1],像Small / Large Bins那些就不是了。

glibc Bins 机制

  1. 双向循环链表(Unsorted / Small / Large Bins)

    • 结构:双向循环链表,表头存在 main_arena 的 bins 数组中([1]为Unsorted,[2-63]为Small,[64-126]为Large)。

bins[i]实际是一个链表的头节点,内部只是存放了两个指针fd 和 bk。故无论释放了多少个 chunk,它们都会通过自身的 fd 和 bk 互相串联起来,挂在这个锚点上,形成main_arena中的具体某一个bins[i] <-> Chunk A <-> Chunk B <-> Chunk C ... <-> main_arena中的具体某一个bins[i]

Small Bins(数组下标 2-63)和 Large Bins(数组下标 64-126)之所以需要这么多数组元素,是因为它们是严格按尺寸分类的(例如 bins[2] 专放 0x20,bins[3] 专放 0x30)。而 Unsorted Bin的设计就是“不分类”。所有刚被释放的、大于 fastbin 的块,无论尺寸大小,都挂进 bins[1] 这同一条链表里。

  • 指针特征:为闭合链表,唯一或首尾 chunk 的 fd 和 bk 必然指向 main_arena,从而留下 libc 地址。
  • Unsorted Bin 中的 chunk 只是一个临时缓存区:当下一次调用 malloc 申请内存时,glibc 会强制遍历这条链表,如果遇到的 chunk大小恰好匹配,则直接分配给用户。如果遇到的 chunk 大小不匹配,glibc 不会把它留在 Unsorted Bin 里,而是把它摘下来放入对应的 Small Bins 或 Large Bins 数组中。但不是第一次无论多大都是放进unsortedbins
  • 进入 Unsorted Bin 必须同时避开情况:

      1. 如果chunk 的大小在 fastbin 阈值范围内(在 glibc 2.23 中是 < 0x80 字节),free 后会进Fastbins`
      1. 挨着堆底的块会被 Top Chunk 合并。
      1. 极其巨大的块会直接还给操作系统。如果申请的内存极大(默认通常大于 128KB),glibc 当初是通过 mmap 直接向操作系统要的内存。free 这种块时,会调用 munmap 直接把内存还给系统,不走堆管理器的 bins 机制。
  1. 单向链表(Fastbins / Tcache)

    • 结构:单向链表,按 LIFO规则运行。
    • 指针特征:放入的第一个 chunk fd 指向NULL,后续 chunk 的 fd 仅指向前一个 chunk 的起始位置,因此无需维系双向链表,故无需存main_arena。

Glibc 堆内存合并机制

  • 合并发生在新块被 free 但还未挂入 bins 链的时候,具体来说是在 free() 函数执行过程中的 "合并阶段",这个阶段在 "挂入链表阶段" 之前。"合并阶段"中,如果发现物理相邻的空闲块(已经在某个 bin 中),会先 unlink 它们,合并完成后,新的合并块作为一个整体,才被放入Unsorted Bin。

物理合并 (Consolidation)

  • 针对内存地址连续的两个或多个 Chunk 进行大小相加、合并为一个大 Chunk 的物理操作

链表解绑 (Unlink)

  • 针对双向链表(Small/Large/Unsorted Bin)的指针操作
  • 当一个已在链表中的空闲块需要参与物理合并时,必须先通过 unlink 宏将其从当前所在的双向链表中移除,以防止合并后破坏链表结构

一、free(P) 的合并流程

  • 当程序调用 free(P) 释放一个不属于 Fastbin 大小范围的 Chunk 时,Glibc 堆管理器会严格按照以下顺序执行:

1. 向前合并 (Backward Consolidation)

堆管理器首先检查 P 物理内存起始位置之前的 Chunk 是否空闲。

状态检查:

  • 堆管理器读取 P 自身 Size 字段的最低位(PREV_INUSE 标志位)

执行逻辑:

条件操作
PREV_INUSE == 1前一个块正在使用,跳过向前合并
PREV_INUSE == 0前一个块(记为 Prev_Chunk)是空闲的,且目前必然挂在某个双向链表中

合并操作:

  1. 读取 P 头部的 prev_size 字段,计算出 Prev_Chunk 的首地址
  2. 调用 unlink(Prev_Chunk),将其从当前所在的双向链表中摘除
  3. 将合并后新 Chunk 的起始地址指针,指向 Prev_Chunk 的首地址
  4. 计算新大小:Size = Prev_Chunk->Size + P->Size

2. 向后合并 (Forward Consolidation)

处理完前驱块后,堆管理器继续检查物理内存紧随其后的 Chunk 是否空闲。

定位后驱块:

  • 使用 P 的首地址加上 P->Size,定位到下一个物理相邻块(记为 Next_Chunk)

状态检查:

  • 读取 Next_Chunk 的下一个物理相邻块的 PREV_INUSE 标志位

执行逻辑:

条件操作
标志位为 1Next_Chunk 正在使用,跳过向后合并
标志位为 0Next_Chunk 是空闲的,且挂在双向链表中(Top Chunk 除外)

合并操作:

  1. 判断 Next_Chunk 是否为 Top Chunk

    • 如果是:直接将当前合并块与 Top Chunk 融合,更新 Top Chunk 的首地址和大小,流程结束
    • 如果不是:调用 unlink(Next_Chunk) 将其从当前双向链表中摘除
  2. 计算新大小:Size = Size + Next_Chunk->Size

3. 更新元数据并挂入链表 (Relinking)

如果合并后的 Chunk 没有并入 Top Chunk,堆管理器需要将其作为一个完整的空闲块重新处理。

更新自身元数据:

  • 将步骤 1 和 2 计算得出的最终 Size 写入这个合并块的 Size 字段

更新相邻块元数据:

  • 找到合并块物理位置后的下一个 Chunk,将其 prev_size 字段更新为合并块的总大小
  • 将其 Size 字段的 PREV_INUSE 标志位清零

插入新链表:

  • 将这个最终生成的合并块,统一插入到 Unsorted Bin 的双向链表头部

二、特殊情况补充

  1. Fastbin 的不合并机制

机制描述:

  • 当释放的 Chunk 属于 Fastbin 范围(通常为 0x20 - 0x80)时,上述合并流程被强制旁路

处理方式:

  • 堆管理器直接将该 Chunk 插入对应的 Fastbin 单向链表头部
  • 物理相邻的下一个 Chunk 的 PREV_INUSE 标志位保持为 1,系统视其为仍在使用的状态,从而避免合并

后续处理:

  • 当系统触发 malloc_consolidate()(如分配大内存或 malloc_trim)时,会将 Fastbin 中的 Chunk 逐一取出
  • 清除其 PREV_INUSE 假象,并严格按照上述的向前/向后合并流程进行处理,最终归入 Unsorted Bin
  1. 堆布局中的隔离机制 (Guard Chunk)

机制描述:

  • 在漏洞利用或实际程序分配中,如果两个相同大小的 Chunk 在物理内存中被一个处于 "使用中" 状态的 Chunk 隔开,物理合并的条件将无法满足

实际表现:

  • 释放这两个 Chunk 时,它们会分别独立走完释放流程
  • 最终作为独立的节点挂入同一个 Bin 链表中(例如 Small Bin 或 Unsorted Bin)
  • 这就形成了逻辑上相连、物理上分散的内存布局

堆保护机制

  • 通常称为堆完整性检查,有以下几种:
  1. 链表完整性检查 (Link Integrity Checks) - 验证指针是不是被篡改了。

    • Safe Unlinking(安全卸载):防御的是双向链表的 fd 和 bk 被恶意篡改。是 Glibc 历史上最著名的一次安全升级(约在 2004 年加入),结束了上古时代最泛滥的“Unlink 任意地址写”攻击。
    • Fastbin Double Free 检查:当你连续两次 free 同一个 Fastbin 块时,系统会检查链表头部的那个块是不是你现在要释放的这个。如果是,直接报错 double free or corruption (fasttop)。防止把同一个块两次挂进链表,造成环形引用。
  2. 内存块元数据检查 (Chunk Metadata Checks) - 验证这个块有没有被造假。

    • Fastbin Size 检查:提取伪造块的 Size,算索引看匹不匹配。防止用假地址骗内存,也就是防针对fd指向的任意写。
    • Next Size 检查:在释放一个块或者合并块的时候,系统有时会顺便看一眼下一个物理相邻块的 Size,看看它的大小是否合法。
    • Top Chunk 完整性检查:Top Chunk的 Size 必须是合法的,且必须包含某些特定的标志位。如果不小心覆盖了 Top Chunk 的 Size 把它改小了,下一次分配内存时系统就会崩溃。
  3. 指针级加密保护 (Pointer Protection) - Glibc 2.32 以后引入

    • Safe-Linking(安全链接):对于 Glibc 2.32 及以上的版本,Fastbin Attack无效。因为开发者在 fd 指针里加入了异或加密机制。fd 里存的不再是明文地址,而是 地址 ^ (自身地址 >> 12)。

Safe Unlinking

  • 针对 Small Bin、Large Bin 和 Unsorted Bin 的指针篡改。它的触发时机通常是在 free() 发生时,系统试图将相邻的两个空闲块合并,需要把其中一个块从双向链表中摘除(Unlink),也就是上文链表解绑时。
触发场景:当系统准备把堆块 P 从双向链表中摘出来时。

流程

1. 抓取前后驱指针

  • 读取它的前驱指针 FD = P->fd 和后驱指针 BK = P->bk。

2. 前向交叉验证

  • if (FD->bk != P)则报corrupted double-linked list

3. 后向交叉验证

  • if (BK->fd != P)则报corrupted double-linked list

4.放行

  • 只有当两边都不符合时才FD->bk = BK;BK->fd = FD;

Fake Unlink:
假设你有一个已知地址的全局变量 ptr 指向 P,你就把 P->fd 设为 &ptr - 0x18,把 P->bk 设为 &ptr - 0x10。这样系统去验证时,读取到的恰好是 ptr 里的 P 的地址,能骗过交叉验证,实现 ptr = &ptr - 0x18 的劫持

Fastbin Double Free Check

  • Fastbin 因为是单向链表,没有 bk 指针,所以做不了 Safe Unlink。但为了防止“连续释放同一个块”导致链表成环,Glibc 加入了这个检查。
触发场景:当你调用 free(P),且 P 的大小属于 Fastbin 范围时。

执行流程

  1. 定位归属链表
    系统读取正在释放的块 P 的 Size,计算出它应该进入哪个 Fastbin 数组(例如大小为 0x70 会定位到 fastbins[5])。
  2. 提取链表头部
    系统去 fastbins[5] 的数组格子里看一眼,找出目前排在链表最开头的那个 Chunk 的地址,将其记作 Old_Top。
  3. 比对顶部指针
    看看当前要释放的p是否等于Old_Top(if (P == Old_Top))
  4. 判决
    如果相等:说明刚刚释放了 P,现在紧接着又要释放 P。报错 double free or corruption (fasttop) 并引发崩溃。
    如果不相等:验证通过,系统将 P 塞进链表,使其成为新的 Top。

ABA 攻击:
先 free(A)(此时头部是 A);
再 free(B)(此时头部是 B,检查 A!=B,放行);
再 free(A)(此时头部是 B,检查 A!=B,放行)。
这样,同一个 A 被挂入链表两次,造出Fastbin 环

Fastbin Size Check

触发时机:当程序调用 malloc(size),且系统计算出该 size 属于 Fastbin 范围时,堆管理器会前往对应的 Fastbin 链表,准备将链表头部的 Chunk 分配给用户。
  • 在真正移交头指针之前,系统执行 Size 检查。

流程

  1. 抓取目标内存块
  2. 动作:堆管理器读取当前 Fastbin 链表头部存放的指针(无论这个指针是合法的,还是被篡改的 Fake 目标)。
  3. 状态:系统将该指针强行转换为一个 Chunk 结构体指针 P,并认为 P 就是这个内存块的绝对起始地址。
  4. 读取原始 Size 字段
  5. 定位:系统跨过 Chunk 头部前 8 个字节的 prev_size 字段。
  6. 读取:直接读取 P + 8 位置的 8 个字节(在 64 位系统中),将其作为该 Chunk 的原始 Size 数据提取出来。
  7. 去除标志位,提取纯净大小
  8. 机制描述:Chunk 的 Size 字段的最低 3 个比特位(Bit 0, 1, 2)并不表示大小,而是分别代表 PREV_INUSE (P)、IS_MMAPPED (M) 和 NON_MAIN_ARENA (N) 标志位。
  9. 底层操作:源码调用 chunksize(P) 宏,本质上是执行一个位掩码(Bitwise AND)操作:Raw_Size & ~0x7。
  10. 结果:强制将最后 3 位清零,得出这个块的“纯净物理大小”(Physical Size)。
  11. 示例:如果读取到的原始数据是 0x7f (二进制 0111 1111),清零后三位后,计算出的纯净大小为 0x78 (二进制 0111 1000)。
  12. 计算 Chunk 所在索引
  13. 机制描述:系统需要知道,这个读取出来的纯净大小,究竟应该属于 Fastbin 数组里的哪一个格子(Index)。
  14. 底层操作:源码调用 fastbin_index(sz) 宏。在 64 位系统中,核心算法是将纯净大小右移 4 位,然后减 2。
  15. 结果:得出一个整型的索引值。
  16. 示例:纯净大小 0x78 右移 4 位变为 7,7 - 2 = 5,即索引为 5。
  17. 预期索引验证
  18. 提取预期索引:系统核对本次 malloc 请求。例如用户申请的是 0x60 的数据空间,加上 0x10 头部,系统预期需要一个大小为 0x70 的 Chunk。系统将 0x70 代入同样的公式(0x70 >> 4 - 2),算出预期索引为 5。
  19. 比对:

    • if (实际计算索引 == 预期索引)**:检查通过,系统认为该 Chunk 合法,将其从单向链表中弹出,交接给用户。
    • if (实际计算索引 != 预期索引)**:检查失败,系统触发安全异常,抛出 malloc(): memory corruption (fast) 错误并终止程序。

错位伪造:
由于 Fastbin Size Check 依赖于右移 4 位的运算,这在二进制层面抹平了部分数值差异,导致了致命的错位伪造漏洞:

在 64 位环境下,大小为 0x70(二进制 0111 0000)和大小为 0x78(二进制 0111 1000)的 Chunk,在右移 4 位后,得出的数值完全相等(均为 0111,即 7)。
利用:攻击者在内存中寻找包含 0x7f(如 libc 中的某些地址高位)的字节,通过将 fd 错位指向该字节之前。
堆管理器读取到 0x7f,去除标志位后得到 0x78,计算出的索引与 0x70 的预期索引一致。至此,即可骗过分配器,将系统指针(如 __malloc_hook)的内存区域申请出来。

Next Chunk Sentinel

  • 用于防范堆溢出攻击

Safe-Linking

触发场景: 把块放入单向链表(写 fd 时),或从单向链表拿出块(读 fd 时)。

执行流程

  1. 获取随机掩码
  2. 系统并不需要生成密钥,它直接利用了 ASLR 机制。
  3. 它取当前内存块自身的地址,将其向右移 12 位(Address >> 12)。
  4. 因为 ASLR 每次运行时的堆基址都在变,所以这个右移后的值每次都是一个不可预测的随机密钥。
  5. 加密写入
  6. 当系统要把块 P 的 fd 指针指向下一个空闲块 Next 时:
  7. 底层操作:不再存储明文的 Next 地址,而是存储 (P >> 12) ^ Next(即右移后的地址与 Next 进行 XOR 异或运算)。
  8. 现象:你在内存里用 GDB 查看此时的 fd,看到的是乱码
  9. 解密读取
  10. 当系统要把 P 分配给你,需要顺着 fd 去找下一个空闲块时:
  11. 底层操作:取出那串乱码,将其与 (P >> 12) 再次进行一次 XOR 异或运算。
  12. 原理:利用异或运算的自反特性(A ^ B ^ B = A),系统还原出 Next 地址。

漏洞泄露堆基址:
拿到了堆基址,就可以算出 (P >> 12) 这个掩码。然后在伪造 fd(比如想指向 __malloc_hook)的时候,用 Python 在脚本里手动做一次异或:payload = p64(target_addr ^ (heap_base >> 12))。把加密后的假指针写进去,系统解密时,就会还原成想要的 target_addr