第一季

内存布局

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

brk(sbrk)和mmap

  • 在 Linux 进程的虚拟内存空间中,glibcmalloc 作为用户态的内存分配器,其实自身并没有物理内存。当它发现内部的空闲链表不够用时,必须通过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指针。

匿名映射:

  • 创建一段没有关联任何文件 inodeVMA,这段虚拟内存完全由物理内存(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和bkchunk 处于分配状态时,从 fd 字段开始是用户的数据。chunk 空闲时,会被添加到对应的空闲管理链表中,其字段的含义如下:

    • fd 指向下一个(非物理相邻)空闲的 chunk
    • bk 指向上一个(非物理相邻)空闲的 chunk
  • fd_nextsizebk_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 的最低 3bit 永远为 0

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

宏 / 含义取值语义
PPREV_INUSEP = 1物理相邻的前一个 chunk 正在被使用(Allocated)。此时当前 chunkprev_size 字段无效(可能被前一个 chunk 用作数据区)。
PPREV_INUSEP = 0物理相邻的前一个 chunk 是空闲的(Free)。此时当前 chunkprev_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_arenabins

当一个 chunkfree 后,为了高效复用,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: 这个数组以保存unsortedsmall以及large_bins,共计可容纳126个

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

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

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

1. tcacheThread Local Cache

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

2. fast binsfastbinsY

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

核心逻辑(不合并机制)

  • 当一个 chunk 被放入 fast bin 时,系统不会修改其物理相邻的下一个 chunkPPREV_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
  • 数据结构:双向循环链表(使用 fdbk 指针)

分拣逻辑(发生在后续 malloc

  • malloc 无法从 tcachefast bins 满足需求时,会遍历 unsorted bin
  • 若找到大小精确匹配的 chunk:直接分配给用户
  • 若大小不匹配:将该 chunkunsorted bin 摘除,并按真实大小整理(sort)到 small binslarge 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_nextsizebk_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 AChunk B 的交界处。
官方结构体里确实定义了 prev_size。但是,如果当前块(Chunk B)前一个相邻的块(Chunk A)正在被使用,那么当前块的 prev_size 就是没有意义的,既然没有意义,glibc 就不会浪费 8 个字节。于是,系统允许 Chunk A 把它的用户数据一直写到 Chunk Bprev_size 空间里。

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

FastbinLIFO 机制

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

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

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

    • 1.free(A):把 A 放进去,Afd 指向 NULL. => Head -> A -> NULL
    • 2.free(B):把 B 放进去,Bfd 指向当前的头部 A. => Head -> B -> A -> NULL
  • 下次 malloc 时,系统直接去 Head 取出 B,然后把 Head 更新为 Bfd(也就是 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 导致链表成环
  • 假设我们连续申请了两个 ChunkAB

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 重新放到头部,并让 Afd 指向当前的头部 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 里写数据
  • 此时 AUser Data 前 8 个字节,在 glibc 眼里正是 fd 指针
  • 这一步写数据,在底层直接把 Afd 改成了目标地址 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掉不同大小的chunkchunk就会被挂入不同的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_hookglibc 中一个全局函数指针变量,用于在调用 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 的小块之外,任何被 freechunk 如果在物理内存上与 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_arenalibc.so.6 这个动态链接库里的一个全局静态变量(存放在 .data.bss 段)。这意味着:main_arenalibc 内部的偏移量(Offset)是永远固定的。只要拿到了 main_arena 的真实运行地址,减去它在 libc 里的固定偏移,就能算出 libc 的基址。
  • Unsorted Bin,Small Bins,Large Bins 的链表机制会自动把main_arena中当前这个bins[i]的地址写进自己Chunkfdbk里(关于什么是bins[i]可以看后面),而Fastbins不会。这是由数据结构决定的。这是因为它们需要维护一个双向循环链表,而链表的头地址存于main_arena。为了把链表连起来,Chunkfdbk 必须存 main_arena 的地址
  • malloc_state`bins 数组并不是这个结构体的第一个字段。在它之前,结构体中还依次存放着其他调度变量:

    • mutex(线程锁)
    • flags(状态标志位)
    • fastbinsYFastbins 的单向链表数组)
    • topTop 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_arenabins 数组中([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] 这同一条链表里。

  • 指针特征:为闭合链表,唯一或首尾 chunkfdbk 必然指向 main_arena,从而留下 libc 地址。
  • Unsorted Bin 中的 chunk 只是一个临时缓存区:当下一次调用 malloc 申请内存时,glibc 会强制遍历这条链表,如果遇到的 chunk大小恰好匹配,则直接分配给用户。如果遇到的 chunk 大小不匹配,glibc 不会把它留在 Unsorted Bin 里,而是把它摘下来放入对应的 Small BinsLarge 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,后续 chunkfd 仅指向前一个 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 单向链表头部
  • 物理相邻的下一个 ChunkPREV_INUSE 标志位保持为 1,系统视其为仍在使用的状态,从而避免合并

后续处理:

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

机制描述:

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

实际表现:

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

堆保护机制

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

    • Safe Unlinking(安全卸载):防御的是双向链表的 fdbk 被恶意篡改。是 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 ChunkSize 必须是合法的,且必须包含某些特定的标志位。如果不小心覆盖了 Top ChunkSize 把它改小了,下一次分配内存时系统就会崩溃。
  3. 指针级加密保护 (Pointer Protection) - Glibc 2.32 以后引入

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

Safe Unlinking

  • 针对 Small BinLarge BinUnsorted 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. 定位归属链表
    系统读取正在释放的块 PSize,计算出它应该进入哪个 Fastbin 数组(例如大小为 0x70 会定位到 fastbins[5])。
  2. 提取链表头部
    系统去 fastbins[5] 的数组格子里看一眼,找出目前排在链表最开头的那个 Chunk 的地址,将其记作 Old_Top
  3. 比对顶部指针
    看看当前要释放的p是否等于Old_Topif (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 位变为 77 - 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. 当系统要把块 Pfd 指针指向下一个空闲块 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