以下笔记引用并借鉴了以下文章的内容,图片等:
- 1.https://wiki.wgpsec.org/knowledge/ctf/basicheap.html
- 2.https://blog.csdn.net/2302_79542511/article/details/146163015
- 3.iyheart BLOG
- 感谢orz orz orz
内存布局
- 一个程序典型的内存布局如下图所示:从高地址到低地址分别为内核空间、栈、空闲区域、堆、bss、data、text。
- 栈是自顶向下增长
- 堆是自底向上增长

- 图片来自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_chunk | Allocated chunk | Free 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):
| 位 | 宏 / 含义 | 取值 | 语义 |
|---|---|---|---|
P | PREV_INUSE | P = 1 | 物理相邻的前一个 chunk 正在被使用(Allocated)。此时当前 chunk 的 prev_size 字段无效(可能被前一个 chunk 用作数据区)。 |
P | PREV_INUSE | P = 0 | 物理相邻的前一个 chunk 是空闲的(Free)。此时当前 chunk 的 prev_size 记录前一个空闲 chunk 的大小;堆块合并(Coalescing)依赖该位。 |
M | IS_MMAPPED | M = 1 | 当前 chunk 通过 mmap 系统调用独立分配(mmap 区域)。 |
M | IS_MMAPPED | M = 0 | 当前 chunk 属于常规 heap(由 brk / sbrk 扩展)。 |
A | NON_MAIN_ARENA | A = 0 | 当前 chunk 属于主分配区 main_arena(主线程堆)。 |
A | NON_MAIN_ARENA | A = 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 binUnsorted binSmall binLarge bin
保存这些数据结构为
fastbinsY:这个数组以保存fast_binsbins: 这个数组以保存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--> NULL3. 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) 寻址优先级
- 检查
tcache是否有对应大小的空闲块 - 检查
fast bins - 检查
small bins(若size符合) - 遍历
unsorted bin(并可能触发分类整理到small bins/large bins) - 检查
large bins(寻找可满足需求的最小chunk,必要时切割) - 切割
top chunk(堆区最高地址处的未分配区域) - 若
top chunk不足,触发sysmalloc(调用brk扩展top chunk,或大内存直接mmap)
free(ptr) 回收优先级
- 尝试挂入
tcache(若未满) - 若
size符合且tcache已满:头插挂入fast bins 若大于
fast bins限制:触发合并(Coalescing)逻辑- 检查物理相邻前一个块是否空闲(通过
P位),若空闲则合并 - 检查物理相邻后一个块是否空闲,若空闲且不在
fast bin中,则合并 - 将合并后的
chunk以双向链表形式挂入unsorted bin,并清除物理相邻下一个块的P位
- 检查物理相邻前一个块是否空闲(通过
评论已关闭