堆损坏类 CoreDump 问题定位指南
堆损坏类 CoreDump 问题定位指南
堆损坏(Heap Corruption)是 C/C++ 程序中较难定位的问题之一。堆内存的元数据(chunk 头部、bin 链表、arena 结构)与用户数据交错存放,任何越界写、悬空指针或重复释放都可能破坏元数据,导致后续 malloc/free 调用被 glibc 检测到并主动 abort,产生 coredump。
本文档阐述 glibc 堆内存管理原理、堆损坏类 coredump 的特征,以及从 core 文件中定位堆损坏根源的方法。
一、glibc 堆内存管理原理
1.1 基本分配单元:chunk(malloc_chunk)
glibc(ptmalloc2)将堆内存划分为一个个 chunk(内存块)。每次 malloc 返回的都是一个 16 字节对齐的 chunk 结构(含头部)的用户数据区。
malloc 返回的指针指向 chunk 的 fd 字段起始处(即 chunk 起始地址 + 0x10):
chunk 起始地址 +----------------------+
| prev_size (8字节) | 前一个 chunk 的大小(仅当前一个 chunk 空闲时有意义)
+--------------------------------+
| size (8字节) | 当前 chunk 的大小,低 3 位为标志位
malloc返回值 -> +----------------------+
| fd (8字节) | 仅空闲 chunk 有意义:指向 bin 链表中下一个空闲 chunk
+----------------------+
| bk (8字节) | 仅空闲 chunk 有意义:指向 bin 链表中上一个空闲 chunk
+----------------------+
| 用户数据区 |
| ...... |
+----------------------+
size 字段低 3 位为标志位,实际大小需通过掩码去掉:
| 位 | 宏定义 | 含义 |
|---|---|---|
| bit0 | PREV_INUSE |
前一个 chunk 是否被使用(1=被占用) |
| bit1 | IS_MMAPPED |
当前 chunk 是否由 mmap 分配(大块内存,不参与普通堆管理) |
| bit2 | NON_MAIN_ARENA |
当前 chunk 是否属于非主 arena(子线程的堆) |
chunk 大小 = size & ~0x7。
正使用的 chunk,实际 payload 会占用下一个 chunk 的 prev_size 字段,因此 payload 大小 = chunk 大小 - 16 + 8。
chunk 之间存在两条一致性约束(堆损坏检测正是基于它们):
- 空闲 chunk 的 大小 与下一个 chunk 的
prev_size数值相同; - 空闲 chunk 的下一个 chunk 的
PREV_INUSE标志位必须为 0。
注意:prev_size 指内存地址相邻的上一个 chunk,而 fd/bk 指 bin 链表中前后相邻的空闲 chunk(内存地址一般不相邻)。
1.2 分配区:arena 与 malloc_state
glibc 用 arena(分配区)管理堆。主线程使用全局的 main_arena,每个子线程可拥有独立的 arena(NON_MAIN_ARENA=1 即属于非主 arena)。
arena 的核心结构是 malloc_state:
struct malloc_state {
mutex_t mutex; // 互斥锁,一般值为 0 或 1
int flags; // 标志位(0x1/0x2/0x4 相与的结果)
mfastbinptr fastbinsY[NFASTBINS]; // fast bins 数组(10 个,共 80 字节)
mchunkptr top; // top chunk 指针
mchunkptr last_remainder; // 最近一次分割后剩余的 chunk
mchunkptr bins[NBINS*2-2]; // 普通 bins 数组(127 个 bin,每 2 个指针表示一个 bin 的 fd/bk)
unsigned int binmap[BINMAPSIZE]; // bin 位图
struct malloc_state *next; // 指向下一个 arena
struct malloc_state *next_free; // 空闲 arena 链表
INTERNAL_SIZE_T system_mem; // 从系统申请的内存总量
INTERNAL_SIZE_T max_system_mem; // 历史最大申请量
};
x86-64 下关键字段的偏移量(用于在 core 中按偏移解析 arena 内容):
| 偏移 | 字段 | 说明 |
|---|---|---|
| +0x00 | mutex |
互斥锁,值一般为 0 或 1(识别 arena 指针的特征) |
| +0x04 | flags |
0x1/0x2/0x4 相与的结果(识别 arena 指针的特征) |
| +0x08 | fastbinsY[10] |
10 个 fast bin 链表头,共 80 字节 |
| +0x58 | top |
top chunk 指针 |
| +0x60 | last_remainder |
最近分割的剩余 chunk |
| +0x68 | bins[254] |
bins[0]~bins[1] unsorted bin;bins[2]~bins[127] small bins;bins[128]~bins[253] large bins |
| +0x858 | binmap[4] | bin 位图 |
| +0x868 | next | 指向下一个 arena,所有 arena 形成循环单向链表 |
| +0x870 | next_free | 空闲 arena 链表 |
| +0x878 | system_mem | 从系统申请的内存总量 |
| +0x880 | max_system_mem | 历史最大申请量 |
注:该布局基于 glibc 2.2x~2.3x 主流版本 x86-64。
1.3 空闲 chunk 的组织:bin
glibc 按 chunk 大小将空闲 chunk 放入不同的 bin(链表)。
一个 bin header 本身占 16 字节,存储 fd/bk 两个指针(如 bins[0]~bins[1] 是 unsorted bin header),fd指向头chunk、bk指向尾chunk,bin 的头尾 chunk 之间不互相指向,都指向 bin header。由于 bin header 是通过地址偏移"虚构"出来的 malloc_chunk 结构,所以 chunk1->bk 和 chunkN->fd 不指向 bin header 本身的地址,而是指向 bin header - 0x10 的位置:
bin header
/ \
fd bk
↓ ↓
chunk1 <-> chunk2 <-> ... <-> chunkN
| |
bk fd
\ /
> bin header <
| bin 类型 | 大小范围 | 链表结构 | 特点 |
|---|---|---|---|
| fast bins | ≤ 128 字节(默认启用,数组容量至 176 字节) | 单链表(LIFO) | 从头部插入/取出,仅用 fd 指针,bk 不使用;释放不合并、不置 PREV_INUSE,速度快 |
| unsorted bin | 任意大小(中转站) | 双链表(FIFO) | 从尾部分配,释放插入头部 |
| small bins | < 512 字节 | 双链表(FIFO),按 16 字节一档 | 共 63 个 bin,大小固定;从尾部分配,释放插入头部 |
| large bins | ≥ 512 字节 | 双链表,按区间分档 | 共 63 个 bin,同 bin 内按从小到大排序;分配时从头部向后查找合适大小,释放时按大小排序插入,遇同尺寸 chunk 插到第二个位置 |
| top chunk | 堆末尾的整块空闲区 | 不属于任何 bin | 所有 bin 均无合适 chunk 时,从 top chunk 切分 |
| tcache | - | 单链表(LIFO) | 线程私有小缓存,64 个 bin,每 bin 最多缓存 7 个同尺寸 chunk;释放时不检查相邻 chunk 元数据;glibc 2.26 及以后支持 |
1.4 分配与释放的基本流程
malloc(size) 简化流程:
- size < 512 字节:Tcache → Fastbins → Small Bin → Unsorted Bin(遍历或整理入 small/large bin)→ Small Bin → Large Bin → Top Chunk → 向操作系统申请新内存
- size ≥ 512 字节:Tcache → Fastbins(合并碎片入 Unsorted Bin)→ Unsorted Bin(遍历或整理入 small/large bin)→ Large Bin → Top Chunk → 向操作系统申请新内存
free(ptr) 简化流程:
- 由
ptr反推 chunk 地址(chunk = ptr - 0x10); - 若 chunk 属于 tcache 范围,直接放入 tcache 并返回;
- 尝试放入 fast bin,能放入则返回;
- 否则检查 chunk 的
size字段合法性(对齐、标志位、合理范围); - 检查前一个 chunk 是否空闲(看本 chunk 的
PREV_INUSE位),空闲则向前合并; - 检查后一个 chunk 是否空闲(看后一个 chunk 的
PREV_INUSE位),空闲则向后合并; - 合并后的 chunk 放入 unsorted bin;
- 向后合并时,若
nextchunk是top chunk则直接与其合并;释放与 top chunk 相邻的大块可能触发堆收缩。
关键点:每次 malloc/free 都要读改写 chunk 头部的元数据(size、prev_size、fd、bk)。元数据一旦被破坏,下一次 malloc/free 就可能触发 glibc 一致性检查失败而 abort。
1.5 堆元数据损坏的常见形式
| 损坏形式 | 典型场景 | 破坏对象 |
|---|---|---|
| 越界写(缓冲区溢出) | 数组/字符串写超出本 chunk 范围 | 相邻 chunk 的 size、prev_size、fd、bk |
| use-after-free(UAF) | 指针释放后仍继续写 | 空闲 chunk 的 fd/bk(链表被破坏) |
| double free(重复释放) | 同一指针被 free 两次 | bin 链表结构 |
| 非法指针 | 传入 free 的是非堆指针(栈/全局/已释放地址) | 无(直接触发 invalid pointer) |
| 大规模覆盖 | memcpy/strcpy 长度失控,覆盖整片区域 | arena 头部(malloc_state 结构) |
1.6 从 core 文件中读取堆数据的方法
1. 查询 glibc 报错信息
方法一:通过寄存器找错误信息字符串
若函数符号不完整,可借助 callee-saved 寄存器(rbx、rbp、r12-r15) 查找。调用 malloc_printerr 时,调用者会把错误信息地址保存在这些寄存器中,在崩溃帧查看这些寄存器值是否为可读地址:
# 切换到 malloc_printerr() 栈帧
(gdb) info reg rbx rbp r12 r13 r14 r15
rbx 0x7f2a8c3f5d20 0x7f2a8c3f5d20
...
(gdb) x/s 0x7f2a8c3f5d20
0x7f2a8c3f5d20: "free(): invalid next size (normal)"
方法二:若崩溃帧寄存器已被清空,切到 f 0 查看栈数据
(gdb) f 0
(gdb) x/128gx $rsp # 查看哪些值形似地址,再用 x/s 查看
背景知识:x86-64 调用约定下,前 6 个参数通过 RDI/RSI/RDX/RCX/R8/R9 传递,多余参数通过栈传递;RBX/RBP/R12-R15 为 callee-saved 寄存器,被调用函数使用前必须先压栈保存、返回前恢复。因此崩溃帧中这些寄存器往往残留着上层函数(malloc_printerr 的调用者)传入的地址值。
2. 查询 arena 的地址
主线程:main_arena 是全局符号,p &main_arena 可看到其地址:
(gdb) p &main_arena
$4 = (<data variable, no debug info> *) 0x7f7cac3d0640 <main_arena>
子线程:arena 指针在寄存器中,查看 callee-saved 寄存器中形似地址的值:
(gdb) t 2
(gdb) info reg rbx rbp r12 r13 r14 r15
查询所有 arena 地址:在 gdb 中使用 heap_arenas.gdb 脚本可遍历出所有 arena 地址。若安装了 gef 插件,可用 heap arenas 直接查询所有 arena。
3. 查询某个 arena 的所有 bin
使用脚本 heap_bins.gdb:
(gdb) source heap_bins.gdb
(gdb) heap_bins arena地址 bin类型 # 例如:heap_bins 0x7f862d055640 small
或使用 gef 插件:
heap set-arena <addr> # 手动设置当前 arena 地址
heap bins
4. 获得各 bin 的 header 地址
p/x arena地址 + bin的偏移 # bin 偏移值见 1.2 章
例如:unsorted bin header 地址 p/x arena + 0x68。
5. 查询某个 bin 中所有 chunk
使用脚本 heap_bin.gdb:
(gdb) source heap_bin.gdb
(gdb) heap_bin <bin_header地址>
(gdb) heap_bin_fast <fast_bin_header地址>
或使用 gef 插件:
heap set-arena <addr> # 手动设置当前 arena 地址
heap bins fast # 显示 fastbins 链表
heap bins tcache # 显示 tcache 链表(glibc 2.26+)
heap bins unsorted # 显示 unsorted bin 链表
heap bins small # 显示 small bins 链表
heap bins large # 显示 large bins 链表
6. 查询某个 chunk 的信息
使用脚本 heap_chunk.gdb:
source heap_chunk.gdb
heap_chunk <addr> # addr 为用户数据地址(chunk 首地址 + 0x10)
或使用 gef 插件 heap chunk <addr>,addr 同样为用户数据地址(chunk 首地址 + 0x10)。
7. 手动遍历 bin 中的 chunk
第一个 chunk 的地址
x/gx bin_header地址 # bin_header->fd
最后一个 chunk 的地址(对 fast bin 无效)
x/gx bin_header地址 + 0x8 # bin_header->bk
当前 chunk 的 prev_size 字段取值
x/gx chunk地址
当前 chunk 的 size 值(不含低 3 位 flags)
p/x (*(unsigned long*)(chunk地址 + 8)) & ~0x7
当前 chunk 的低 3 位 flags 取值
p/x (*(unsigned long*)(chunk地址 + 8)) & 0x7
当前 chunk 的 fd 字段取值(用户数据指针)
p/x *(unsigned long*)(chunk地址 + 0x10)
当前 chunk 的 bk 字段取值
p/x *(unsigned long*)(chunk地址 + 0x18)
查看整个 chunk 的内存
x/chunk_sizegx chunk地址
二、堆损坏问题的定位方法
通用反查手段:以下各节反复用到"数据特征反查"——查看相关 chunk 数据区是否残留明显特征(字符串、结构体字段、大段相同字节),据此反查写这些数据的代码;也可用 gef 的
grep命令搜索特定字符串或字节序列。详细步骤见 2.15。
2.1 "malloc(): memory corruption (fast)"
- 原因:fast bin 中 chunk 的 size 字段被改写。
- 定位:查看 fast bin 所有 chunk,若某 chunk 的 size 异常,对其数据区做特征反查(见 2.15)。
2.2 "malloc(): memory corruption"
- 原因:从 bin 取 chunk 时发现 bin 的 fd 指向不可信 chunk(链表被破坏)。
- 定位:查看各 bin 的所有 chunk,对 fd/bk 异常的 chunk 做数据特征反查(见 2.15)。
2.3 "malloc(): smallbin double linked list corrupted"
- 原因:small bin 中 chunk 的 fd/bk 被改写,导致链表断链。
- 定位:查看 small bin 所有 chunk,对异常的 chunk 做数据特征反查(见 2.15)。
2.4 "malloc(): corrupted unsorted chunks"
- 原因:unsorted bin 首个 chunk 的 bk 指针被改写(bk != bin header)。
- 定位:查看 unsorted bin 首个 chunk 的 fd/bk 附近内存,做数据特征反查(见 2.15)。
2.5 "free(): invalid pointer"
- 原因:待释放指针被覆盖成非法值。
- 判断依据:
- 指针不可读:
x/gx p结果Cannot access memory; - 指针接近地址空间顶端:
p/x (uintptr_t)p > (uintptr_t)(-((*(long*)((char*)p-8))&~0x7))结果为 1; - 指针未按 16 字节对齐:
p/x (uintptr_t)p & 0xF结果非 0。
- 指针不可读:
- 定位:
- 指针变量被错误赋值 → 检查对其赋值的代码;
- 指针变量被相邻内存覆盖 → 查看
&p附近数据特征,反查写入点(见 2.15)。
2.6 "free(): invalid size"
- 原因:相邻前一块 chunk 越界写,覆盖了本 chunk 的 size 字段。
- 判断依据:
- chunk size < MINSIZE(32):
p/x (*(long*)((char*)p-8)) & ~0x7结果小于 32 或未按 16 字节对齐; - 低 3 位标志位非法(值为 6、7):
p/x (*(long*)((char*)p-8)) & 0x7。
- chunk size < MINSIZE(32):
- 定位:查看待释放指针附近数据特征,反查写入点(见 2.15)。
2.7 "free(): invalid next size (fast/normal)"
- 原因:本 chunk 缓冲区越界写,覆盖了下一个 chunk 头部的 size 字段。
- 判断依据:
p/x *(long*)((char*)p-16 + ((*(long*)((char*)p-8)) & ~0x7) + 0x8) & ~0x7 # 下一个 chunk 的 size < 32 或未对齐
p/x *(long*)((char*)p-16 + ((*(long*)((char*)p-8)) & ~0x7) + 0x8) & 0x7 # 低 3 位标志位非法(值为 6、7)
- 定位:
- 检查代码中对待释放对象赋值处是否存在越界;
- 计算下一个 chunk 起始地址
p/x (char*)p-16 + ((*(long*)((char*)p-8)) & ~0x7),查看其附近数据特征,反查写入点(见 2.15)。
若无法获取正被释放的内存地址 p,可切到 _int_free 栈帧,从 callee-saved 寄存器中取当前 chunk 地址:
(gdb) info reg rbx rbp r12 r13 r14 r15
(gdb) x/gx $rbx # 当前 chunk 指针
2.8 "double free or corruption (fasttop)"
- 原因:当前释放的 chunk 指针等于当前 fastbin 头部 chunk 指针,说明该 chunk 已被释放过并放入 fastbin。
- 定位:排查代码中是否存在重复释放。
2.9 "double free or corruption (top)"
- 原因:当前释放的 chunk 指针等于 top chunk 指针,堆管理状态错乱。
- 定位:
- 排查代码中是否存在重复释放;
- 检查正被释放 chunk 的内容(size 及内部数据)是否异常;
- 检查 arena 结构内存是否被破坏。
2.10 "double free or corruption (out)"
- 原因:当前释放的 chunk 的 size 偏大,导致计算出的下一个 chunk 地址越过堆尾。可能是上一个 chunk 越界写覆盖了本 chunk 的 size 字段。
- 定位:查看正被释放 chunk 附近数据特征,反查写入点(见 2.15)。
2.11 "double free or corruption (!prev)"
- 原因:当前释放 chunk 的 nextchunk 的
PREV_INUSE == 0,说明当前 chunk 已释放过,或 nextchunk 的PREV_INUSE位被覆盖成 0。 - 定位:
- 排查代码中是否存在重复释放;
- 排查待释放内存赋值处是否存在越界;
- 查看 next chunk 数据特征,反查写入点(见 2.15)。
2.12 "invalid fastbin entry (free)"
- 原因:fast bin 首个 chunk 的 size 异常,可能被相邻前一个 chunk 越界写坏。
- 定位:查看 fast bin 首个 chunk 数据特征,反查写入点(见 2.15)。
2.13 "free(): corrupted unsorted chunks"
- 原因:unsorted bin 首个 chunk 的 bk 指针被改写(bk != bin header)。
- 定位:查看 unsorted bin 首个 chunk 的 fd/bk 附近内存,做数据特征反查(见 2.15)。
2.14 "corrupted size vs. prev_size"
- 原因:
_int_free向后合并时发现本 chunk 的PREV_INUSE=0(前一个 chunk 被标记为空闲),于是用prev_size定位前一个 chunk,但前一个 chunk 的size与当前 chunk 的prev_size对不上。
三种典型原因:
- 前一个 chunk 越界写,覆盖了当前 chunk 的
size字段,将PREV_INUSE(bit0)清成 0; - 前前个 chunk 越界写,覆盖了前一个 chunk 的
size,导致合并计算错误; - 前一个 chunk 释放后被 UAF,覆盖了当前 chunk 的
prev_size。
定位方法:
- 通过崩溃栈确认被释放 chunk(当前 chunk)地址;
- 计算前一个 chunk:
前一个 chunk = 当前 chunk - prev_size; x/16gx同时查看前一个、当前、下一个 chunk 的头部,核对:- 当前 chunk 的
size低 3 位是否正确(PREV_INUSE 是否被误清 0); - 前一个 chunk 的
size与当前 chunk 的prev_size是否一致; - 下一个 chunk 的
PREV_INUSE与当前 chunk 的size位是否自洽;
- 当前 chunk 的
- 根据被破坏的字段及相邻 chunk 数据区残留的特征,反查对应的越界写或 UAF 代码。
2.15 通过数据特征反查写入点(核心手段)
堆损坏定位的核心逻辑:崩溃点是受害者,不是凶手。写坏堆的代码早已执行完毕,只能靠现场数据特征反查。
特征反查步骤:
- 用
x/32gx(或x/64gx)完整查看被破坏 chunk 及其相邻 chunk 的数据区; - 识别数据特征:
- 可见字符串:
x/s读出可读文本,在源码中搜索该字符串或写它的代码; - 结构体特征:出现成组的指针、计数器、ID 等,对应某个结构体类型,反查该结构体的写方;
- 特征字节:大段
0x00、0xff、0x41('A')等,对应 memset/memcpy 的特定长度; - 链表指针:fd/bk 指向某个合法 chunk,说明该 chunk 在 bin 链表中,可能被 UAF 修改;
- 可见字符串:
- 结合相邻 chunk 的分配大小推断越界写的缓冲区边界;
- 将嫌疑代码与崩溃时刻的调用栈、日志(若有)交叉验证。
2.16 辅助手段:ASan
ASan(AddressSanitizer,最有效)
ASan 能在越界写发生的第一现场报错并给出精确调用栈,远胜于从 coredump 反推:
# 编译时开启 ASan
g++ -fsanitize=address -g -O1 your_code.cpp -o your_program
# 运行
./your_program
ASan 检测到堆越界写时输出形如:
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
WRITE of size 4 at 0x... thread T0
#0 0x... in WriteData your_file.cpp:123 <-- 真正的写入点
#1 0x... in main your_file.cpp:456
推荐路径:凡能复现的堆损坏,优先用 ASan 定位到具体写入行;无法复现的,再回到 coredump 手工分析。
三、定位流程总结
收到堆损坏类 coredump
│
▼
① 确认信号与错误信息(SIGABRT + malloc_printerr 的具体文本)
│
▼
② 切到 _int_malloc / _int_free 栈帧,找 arena 与相关 chunk
│
▼
③ 按错误类型定向检查:
├─ malloc(): memory corruption → arena+0x70 查 unsorted bin fd,检查 chunk 元数据
├─ free(): invalid pointer → 查 free 调用点与指针来源
├─ double free or corruption → 搜同一指针的所有 free 路径 / 查 fd/bk 是否被篡改
├─ free(): invalid next size → 检查下一个 chunk 的 size 字段,定位越界写
├─ corrupted size vs. prev_size → 核对相邻 chunk 的 size/prev_size/PREV_INUSE
└─ 其他 corrupt 错误 → 检查对应 bin 链表的 chunk 头部
│
▼
④ x/32gx 查看 chunk 数据区特征(字符串/结构体/特征字节)
│
▼
⑤ 根据数据特征反查写入代码(越界写 / UAF / double free)
│
▼
⑥ 无法定位时:使用 ASan 复现到精确行号
核心要点:
- 堆损坏 coredump 的崩溃点(最后一次 malloc/free)≠ 写坏堆的位置,必须通过堆数据特征反查;
- glibc 错误信息是第一线索,决定定向检查的方向;
- 熟练使用
x/gx、x/32gx、x/s与 callee-saved 寄存器(rbx/rbp/r12-r15)在 core 中还原堆现场; - 能复现的问题优先使用 ASan,把"反推"变成"直接定位"。

浙公网安备 33010602011771号