堆损坏类 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 之间存在两条一致性约束(堆损坏检测正是基于它们):

  1. 空闲 chunk 的 大小 与下一个 chunk 的 prev_size 数值相同;
  2. 空闲 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) 简化流程:

  1. 由 ptr 反推 chunk 地址(chunk = ptr - 0x10);
  2. 若 chunk 属于 tcache 范围,直接放入 tcache 并返回;
  3. 尝试放入 fast bin,能放入则返回;
  4. 否则检查 chunk 的 size 字段合法性(对齐、标志位、合理范围);
  5. 检查前一个 chunk 是否空闲(看本 chunk 的 PREV_INUSE 位),空闲则向前合并;
  6. 检查后一个 chunk 是否空闲(看后一个 chunk 的 PREV_INUSE 位),空闲则向后合并;
  7. 合并后的 chunk 放入 unsorted bin;
  8. 向后合并时,若 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。
  • 定位:查看待释放指针附近数据特征,反查写入点(见 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 对不上。

三种典型原因:

  1. 前一个 chunk 越界写,覆盖了当前 chunk 的 size 字段,将 PREV_INUSE(bit0)清成 0;
  2. 前前个 chunk 越界写,覆盖了前一个 chunk 的 size,导致合并计算错误;
  3. 前一个 chunk 释放后被 UAF,覆盖了当前 chunk 的 prev_size。

定位方法:

  1. 通过崩溃栈确认被释放 chunk(当前 chunk)地址;
  2. 计算前一个 chunk:前一个 chunk = 当前 chunk - prev_size;
  3. x/16gx 同时查看前一个、当前、下一个 chunk 的头部,核对:
    • 当前 chunk 的 size 低 3 位是否正确(PREV_INUSE 是否被误清 0);
    • 前一个 chunk 的 size 与当前 chunk 的 prev_size 是否一致;
    • 下一个 chunk 的 PREV_INUSE 与当前 chunk 的 size 位是否自洽;
  4. 根据被破坏的字段及相邻 chunk 数据区残留的特征,反查对应的越界写或 UAF 代码。

2.15 通过数据特征反查写入点(核心手段)

堆损坏定位的核心逻辑:崩溃点是受害者,不是凶手。写坏堆的代码早已执行完毕,只能靠现场数据特征反查。

特征反查步骤:

  1. 用 x/32gx(或 x/64gx)完整查看被破坏 chunk 及其相邻 chunk 的数据区;
  2. 识别数据特征:
    • 可见字符串:x/s 读出可读文本,在源码中搜索该字符串或写它的代码;
    • 结构体特征:出现成组的指针、计数器、ID 等,对应某个结构体类型,反查该结构体的写方;
    • 特征字节:大段 0x00、0xff、0x41('A') 等,对应 memset/memcpy 的特定长度;
    • 链表指针:fd/bk 指向某个合法 chunk,说明该 chunk 在 bin 链表中,可能被 UAF 修改;
  3. 结合相邻 chunk 的分配大小推断越界写的缓冲区边界;
  4. 将嫌疑代码与崩溃时刻的调用栈、日志(若有)交叉验证。

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 复现到精确行号

核心要点:

  1. 堆损坏 coredump 的崩溃点(最后一次 malloc/free)≠ 写坏堆的位置,必须通过堆数据特征反查;
  2. glibc 错误信息是第一线索,决定定向检查的方向;
  3. 熟练使用 x/gx、x/32gx、x/s 与 callee-saved 寄存器(rbx/rbp/r12-r15)在 core 中还原堆现场;
  4. 能复现的问题优先使用 ASan,把"反推"变成"直接定位"。
posted @ 2026-09-29 10:05  CJXUNOO  阅读(1)  评论(0)    收藏  举报