刑侦剧里有个常识:发现尸体的地点,往往不是案发的地点。堆内存的 bug 也一样——你看到崩溃的那行代码,多半只是”尸体被发现的地方”,真正的案发第一现场,可能在几百条指令之前、在另一个线程里、甚至在几天前的一次写入。
这篇文章讲的就是这门”案侦学”:当堆内存被写坏之后,如何从崩溃现场倒推回案发点。它接着《你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应》往下走–那篇讲”为什么实时观察注定失败”,这篇讲”事后取证拿到证据之后怎么办案。文中 Windows 侧输出在 Windows 11 + MSVC 2026 实测,Linux 侧输出在 Ubuntu 24.04 + glibc 2.39 实测,代码全部可复现。
先看三份”报案记录”。同一类病——内存被越界或悬垂写坏——可以有三种完全不同的死法:
报案一:死在系统检查手里
HEAP CORRUPTION DETECTED: after Normal block (#92) at 0x0000013AC5BB4B60.
CRT detected that the application wrote to memory after end of heap buffer.
报案二:不崩,但账本悄悄错了
victim@000001AFEB6D4E60 stale@000001AFEB6D4E60
victim->hits = 0x7AB7AB7A <- killer's fingerprint
报案三:死在一行完全无辜的代码里
strlen (0x7ffb2c1a4d20) # 栈顶,libc 的字符串函数
process_data (main.cpp:141) # 你的代码,但这行只是路过摸了下尸体
第一种,报错出现在 free 那一行——但写坏内存的代码离它十万八千里。第二种,程序活得好好的,只是某个字段的值莫名变成了一个”没人写过”的数。第三种最迷惑人:栈顶是一个再普通不过的 strlen,你盯着自己那行 process_data 看半天,看不出任何毛病——因为它确实没有毛病,它只是碰巧摸到了伤口。
三种死法,一个凶手。这篇文章要回答的问题就一个:怎么从这三种现场,把真凶揪出来。
一、先校正一个直觉:崩溃栈指向的不是凶手,是受害者
绝大多数人学调试的第一课就是”看崩溃栈”。这个习惯本身没错,错的是背后的隐含假设:崩在哪里,bug 就在哪里。对空指针解引用这类”当场死亡”的 bug,这个假设成立。但对堆腐坏类 bug,它系统性地失效。
失效的原因值得说透。一次堆腐坏事故里有三个角色,三个位置:
| 角色 | 英文 | 是什么 | 位置 |
|---|---|---|---|
| 破坏点 | perpetrator | 真正写坏内存的那条指令 | 时间上游,任意远 |
| 受害点 | victim | 内存被写坏的那个对象 | 案发地点 |
| 崩溃点 | crash site | 程序终于撞上伤口的地方 | 时间下游,任意远 |
新手把崩溃点当成破坏点,于是出现本文开头的那一幕:在 strlen 上崩溃,就怀疑字符串处理逻辑;在 free 上崩溃,就怀疑内存管理代码;在 malloc 内部崩溃(glibc 用户的老朋友 malloc(): corrupted top size),就对着分配器干瞪眼。全错。崩溃栈上出现的大多数代码,是受害者,不是凶手。凶手写下那一笔的瞬间,可能没有任何异常,没有任何报错,程序安静地继续跑——它已经在逃逸了。
怎么判断崩溃栈上是”该直接查的”还是”只是受害者”?看两个特征:
- 栈上有没有你自己的代码,且那行代码是否在写内存。如果崩溃发生在你自己写的、正在做写入操作的语句上(赋值、memcpy、容器 push),那大概率就是第一现场,直接查。如果崩溃发生在 libc 的字符串函数、容器遍历、allocator 内部——那是受害者在报案,凶手不在场。
- 良民名单。有一类函数几乎永远无辜:
strlen、strcmp、memcpy、std::map/std::unordered_map的查找、std::string的各种操作、malloc/free的内部一致性检查。它们的共同点是:只读,或者只按契约操作内存。它们崩了,说明喂给它们的内存早就有问题。
这和《你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应》讲的困境是一体两面:海森堡 bug 的难点是”观察改变系统”,堆腐坏的难点是”因果链在时间上断裂”——崩溃栈只能告诉你 T3(报案时刻),而你真正要找的是 T0(案发时刻)。
二、沉默期:为什么堆腐坏必然延迟暴露
把一次堆腐坏的生命周期画在时间轴上:
T0 T1 T2 T3
案发 尸体被复用 受害者触碰伤口 崩溃/报错
(写坏内存) (伤口被覆盖或转移)(某段无辜代码) (你终于知道出事了)
|<-------------- 沉默期:系统带着伤正常跑 -------------->|
从 T0 到 T3 的距离可以有多远?任意远。几十万条指令、几百次函数调用、无数次线程切换、好几天,都有可能。这不是夸张,是堆这个数据结构的物理性质决定的。下面用实验说话。
实验:案发在循环里,报案在 free 上
完整程序(MSVC debug CRT),每一行都可以照抄复现:
// lag.cpp - 沉默期:案发在前,报案在后
#include <cstdio>
#include <cstring>
#include <crtdbg.h>
int main() {
// 把 CRT 报告重定向到 stderr(免得弹对话框干扰观察)
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);
char* a = new char[32]; // 破坏者将在这里越界
char* b = new char[32]; // 无辜邻居
char* c = new char[32]; // 另一个无辜邻居
strcpy_s(b, 32, "neighbor-B");
strcpy_s(c, 32, "neighbor-C");
// ---- T0 案发:a 的循环边界写错(<=),越界写 1 个字节
for (int i = 0; i <= 32; ++i) a[i] = 'X';
// ---- 沉默期:腐坏已经发生,系统看起来一切正常
printf("[silence] a overflow done, neighbors intact: b=[%s] c=[%s]\n", b, c);
for (int k = 0; k < 5; ++k) {
char* t = new char[32];
strcpy_s(t, 32, "busy-but-fine");
printf("[silence] alloc #%d ok\n", k);
delete[] t;
}
// ---- T3 报案:free 时 CRT 检查 no-man's land 才发现伤口
printf("[report ] about to delete a ...\n");
delete[] a; // <-- HEAP CORRUPTION DETECTED 在这里爆
printf("[report ] never reached\n");
delete[] b;
delete[] c;
return 0;
}
编译运行(命令行:cl /utf-8 /Od /EHsc /MDd /RTC1 lag.cpp;或者建一个 Debug 配置的空工程):
[silence] a overflow done, neighbors intact: b=[neighbor-B] c=[neighbor-C]
[silence] alloc #0 ok
[silence] alloc #1 ok
[silence] alloc #2 ok
[silence] alloc #3 ok
[silence] alloc #4 ok
[report ] about to delete a ...
HEAP CORRUPTION DETECTED: after Normal block (#92) at 0x0000013AC5BB4B60.
CRT detected that the application wrote to memory after end of heap buffer.
[report ] never reached
逐帧看这段录像:
- T0 在
for循环:i <= 32这个笔误让第 33 个字节(下标 32)写出了块外。一个字节的越界,正好打在 CRT debug 堆给每块内存垫的”护栏”上。 - 沉默期 6 行输出全部正常:邻居 b、c 的数据完好,中间 5 次分配释放全都成功。伤口存在,但没人碰它。
- T3 在
delete[] a:debug CRT 在释放时校验护栏,发现被写花,这才报HEAP CORRUPTION DETECTED。注意报错位置和案发位置隔了整整 10 行代码、6 次堆操作——而这已经是我为了演示刻意压缩过的距离。真实项目里,T0 和 T3 隔着几千行、不同的模块、不同的人写的代码,是常态。
(顺带一提:这一版 CRT 打印报告后居然继续执行了,所以你看到了 never reached 之后 b、c 还能正常释放。有的 CRT 版本会直接 abort。无论哪种,报告出现的那一行就是 T3,不是 T0。)
三个延迟机制
为什么堆腐坏”必然”延迟暴露?因为堆的结构决定了伤口几乎总是先于症状:
机制一:写坏的人不读,读的人不写。破坏者的代码写完就走了,受伤的对象在等下一个”恰好路过的人”——可能是一段日志格式化、一次容器遍历、一个回调。破坏者和受害者往往相距整个调用图。
机制二:堆是一个会自动毁尸灭迹的现场。这是最容易被忽视、也最致命的一条。malloc 的复用会直接顶替尸体——同一块地址转眼就分配给别人,伤口上的所有证据被新数据覆盖;free 的合并(coalescing)会把相邻空闲块粘成一块,伤口的形态都被改变了。时间不是中立的旁观者,时间在替凶手销毁证据。这就是为什么转储要第一时间取(见《转储文件实战全解》(暂未发布,上线后补链接)),也是为什么”崩了之后让程序继续跑着试试”是坏习惯。
机制三:结构性的伤口,只有 allocator 自己下场时才会爆。分配器在空闲块上维护着账本(块大小、前后指针、状态位)。写坏一个已分配对象的数据,谁都不管;但写坏账本,要等到下一次 malloc/free 走到那一页账时才被发现——崩溃点于是落在 libc 或运行时库内部,栈上全是你看不懂的代码。glibc 用户对这一族报错不陌生:malloc(): corrupted top size、free(): invalid next size……这些报错的共同点是:报错位置与案发位置无关,只与账本被查到的时间有关。
三、尸体上的标记:填充值指纹学
2008 年前后,微软的 CRT 团队做了件特别贴心的事:debug 模式下,运行时库给每块堆内存的每种状态都填上固定的模式。他们大概没意识到,这个为”帮你发现未初始化”设计的机制,顺便给全世界的调试者留下了一整套尸检标记。
《海森堡篇》里我们把这些填充当成”掩盖 bug 的善意谎言”——debug 下读到 0xDDDDDDDD 恰好不崩,release 下读到随机垃圾当场爆炸。那是观察者效应的视角。现在换一个视角:同一个机制,也是证据。当你在一块内存里看到这些值,你不用猜,直接知道这块内存”生前”发生了什么。
实验:认尸三连
// fill.cpp - 认尸实验:填充值指纹(MSVC debug CRT)
#include <cstdio>
int main() {
// 1) 新分配未初始化的堆内存
unsigned int* p1 = new unsigned int[4];
printf("fresh alloc : %08X %08X %08X %08X\n", p1[0], p1[1], p1[2], p1[3]);
// 2) 释放后再看(读已释放内存,观察尸体标记)
delete[] p1;
printf("after delete: %08X %08X %08X %08X\n", p1[0], p1[1], p1[2], p1[3]);
// 3) 未初始化的栈内存
unsigned int s[4];
printf("stack uninit: %08X %08X %08X %08X\n", s[0], s[1], s[2], s[3]);
return 0;
}
编译运行(注意栈填充需要 /RTC1,即 IDE 默认的 Debug 配置;命令行要显式加):
fresh alloc : CDCDCDCD CDCDCDCD CDCDCDCD CDCDCDCD
after delete: DDDDDDDD DDDDDDDD DDDDDDDD DDDDDDDD
stack uninit: CCCCCCCC CCCCCCCC CCCCCCCC CCCCCCCC
三个值,三句话的事:
| 值 | 状态 | 助记 |
|---|---|---|
0xCD | 堆上新分配、还没写过的内存 | Clean:刚打扫干净的房间 |
0xDD | 堆上已释放的内存 | Dead:尸体 |
0xCC | 栈上未初始化的变量 | Clean stack;它同时还是 x86 的 int3 断点指令——栈被踩花后程序”随机崩进调试器”的经典悬案,凶手常常就是它 |
这套指纹的实战价值极难高估。在一万个 0xDDDDDDDD 里,你不用读一行代码就知道:这里有个 use-after-free。在崩溃点附近看到 0xCDCDCDCD,结论立等可取:有人在读没初始化的堆内存。你甚至能读出事件的顺序——这就是下一节的主角。
NT 堆(操作系统自己的堆,HeapAlloc 那一族)有另一套标记:0xABABABAB(分配未用)、0xBAADF00D(Bad Food,LocalAlloc 的未初始化)、0xFEEEFEEE(HeapFree 之后)。glibc 默认不填充,但开一个环境变量就有了:MALLOC_PERTURB_=165。实测(glibc 2.39)有个值得记住的分界:大块(超过 0x408 字节)释放时正文填 0xA5、再分配时填按位取反的 0x5A–尸体上盖着”释放”和”分配”两枚不同的邮戳;而小块走每线程缓存 tcache,进出都不填充,头 16 字节还被链表指针占用(悬垂读读到乱值而不是 A5,多半就是这个原因)。完整的跨平台指纹总表放在附录 A,建议存一份,排查时直接对号。
指纹的边界,和”主动纹身”
填充指纹有一个天生的软肋:它只在 debug 构建里存在。release 的 CRT 不填,NT 堆的绝大多数路径不填,glibc 默认不填。而《Debug 与 Release 区别》那篇讲过,堆 bug 恰恰最爱躲在 release 里。于是就有了本章标题的后半句——主动纹身:
- MSVC:保持 debug CRT 的填充红利,CI 里留一个 Debug 套件专门跑(详见《非侵入探针》第八节的构建矩阵策略)。
- glibc:
MALLOC_PERTURB_=165一行环境变量,release 也能有指纹。 - 终极方案:ASan——分配的内存默认填
0xBE,释放后进隔离区并毒化,悬垂访问必撞标记。它的机制在《非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形》里讲透了,这里只强调一点:ASan 本质上是”给每具尸体强制纹身 + 不许毁尸”的官方升级版。
四、伤口形态学:从腐坏 pattern 反推破坏者类型
验尸报告到手了,下一课是法医的核心技能:从伤口形态推断凶器。不同的破坏方式,在内存里留下的痕迹是有形状的。这一节是全文信息密度最高的一节,六种伤口形态,每一种都对应一类嫌疑人。
先补最小必要知识(只讲取证需要的部分,不展开堆实现):
- glibc:每块堆内存前面有 16 字节账本——前 8 字节
prev_size,后 8 字节size(低 3 位是标志位)。malloc返回的指针 = 账本 + 16。也就是说,p[-16..0)是本块账本,p[usable_size..)是下一块的账本。越界写第一个撞到的永远是账本,不是隔壁的数据。 - Windows NT 堆:同样是”块头在前、数据在后”,块头记着大小和状态,被写花后 allocator 在记账时才发现。
- tcache(glibc 2.26+):每线程的小块缓存,被释放的块以链表形式挂起来,链表指针就写在块的开头——也就是你的”第一个字段”的位置。这个细节马上会用到。
形态一:只有相邻一个字段或几个字节坏了——嫌疑人:off-by-one
典型伤口:for (i = 0; i <= n; ++i)、memcpy 长度差一、buf[n] 的边界笔误。特征是伤口极小、位置紧贴块尾,常常只打到下一块的 prev_size 或 size 的最低一个字节。glibc 上有个阴险的细节:如果那个字节恰好把 size 改小了,free 会”成功”,块被当成小块放回缓存——账本从此就错了,但不报错,堆的布局认知悄悄错乱,若干次分配之后演化成块重叠,最后以一个八竿子打不着的崩溃收场。安全社区管这叫 poison null byte,是堆利用的经典起点;对工程师来说,它的教训是:形态一的伤口可以潜伏到完全变形才暴露。
形态二:大片同值覆盖——嫌疑人:memset/strcpy 家族的大越界
伤口是清一色的重复字节,比如一片 0x41(字符 A 的十六进制)。这是”写穿”型破坏:源数据把后面所有东西全部盖掉,直到撞上不可写页才停。这种反而最好查——伤口巨大,!heap -srch(WinDbg)或 gdb 里 find 一下这个值就能圈定范围,而且写它的代码通常带着明显的 memcpy/memset 痕迹。破案口诀:伤口里的值,往往就是凶手的指纹样本(它从源数据里带过来的)。
形态三:账本字段(size/next 指针)被精确改写——嫌疑人:相邻块的越界写
伤口特征:数据区完好无损,但块的 size 字段变成一个荒谬的值,或者 free list / tcache 的指针指向了不存在的地址。这类伤口全部由邻居越界造成——注意,是邻居干的,不是这块内存的主人干的。报错全部来自 allocator 的记账检查,glibc 的报错指纹对照表见附录 B。给个 glibc 上可复现的例子(gcc -O0 topbreak.c,glibc 2.29+):
// topbreak.c - 案发在 memset,报案在 malloc
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char* a = malloc(0x18);
// a 的块大小 0x20;a+0x18 处是下一块(此刻是 top chunk)的 size 字段
memset(a + 0x18, 0x41, 8); // T0 案发:off-by-8 打坏 top 的 size
// 报平安走 stderr:printf 第一次输出要 malloc 一块 stdio 缓冲区,
// 而此刻账本已坏,信使会当场死在 malloc 里
fprintf(stderr, "crime committed, heap silent\n"); // 沉默期
char* d = malloc(0x100); // T3 报案:崩在 libc 的 malloc 里
printf("never reached\n");
return 0;
}
crime committed, heap silent
malloc(): corrupted top size
Aborted (core dumped)
案发在 memset,报案在两条语句之后的 malloc,崩溃栈顶全部落在 libc 里。放大到真实工程,”两条语句”会变成”两天”。
输出里还埋着一个实测踩出来的坑,值得单独一说:沉默期那行如果改用 printf 走 stdout,多半打不出来–printf 的第一次输出需要 malloc 一块 stdio 缓冲区,而此刻账本已经坏了,报平安的信使当场死在 malloc 里,你连”案发后系统还正常”这一句证据都拿不到。案发之后,连 stdout 都不再可信。
形态四:读到结构合法、值诡异的对象——嫌疑人:use-after-free 读
症状:某个对象的字段值”完全说不通”——版本号是天文数字、指针指向高地址、字符串变成乱码,但程序不崩,只是数据错。机制:你读的那块地已经被释放又分配给了别人,你读到的是新住户的数据,按旧住户的格式来解释。这是所有形态里最”温和”也最贵的一种:不崩溃、不留痕、数据悄悄错,往往到下游业务对账才发现。debug 构建下这个形态常被填充指纹出卖——读到 0xDDDDDDDD(读尸体)或 0xCDCDCDCD(读到新住户没写过的地)。
形态五:别人的新对象里出现你的旧字段——嫌疑人:use-after-free 写
这是六种形态里唯一一个”凶手主动留指纹”的,也是最值得记住的。看实测:
// reuse.cpp - 指纹转移:release 堆上,凶手写的值出现在无辜的新对象里
// 编译:cl /O2 /MD reuse.cpp
#include <cstdio>
#include <cstring>
#include <cstdlib>
struct Config {
long hits;
char name[24];
};
int main() {
Config* cfg = (Config*)malloc(sizeof(Config));
cfg->hits = 42;
strcpy(cfg->name, "prod-config");
Config* stale = cfg; // 某处缓存的旧指针
free(cfg); // NT heap 释放:内容原样保留,不清不填
stale->hits = 0x7AB7AB7A; // T0 案发:悬垂写留在已释放的内存里
// 同尺寸再分配:free list 上的那块大概率被拿回来
Config* victim = (Config*)malloc(sizeof(Config));
printf("victim@%p stale@%p\n", (void*)victim, (void*)stale);
printf("victim->hits = 0x%08llX", (unsigned long long)victim->hits);
return 0;
}
victim@000001AFEB6D4E60 stale@000001AFEB6D4E60
victim->hits = 0x7AB7AB7A <- killer's fingerprint
注意两个地址:完全相同。victim 是一个无辜的新对象,从没有人给它的 hits 赋过值,但它出生时这个字段就是 0x7AB7AB7A——那是凶手(悬垂的 stale->hits = 0x7AB7AB7A)在尸体上留下的笔迹。release 堆 free 不清内存、同尺寸复用优先,于是凶手的字迹原封不动地转移到了下一个住户身上。
这个形态的破案价值巨大:伤口里的值,直接指向凶手的代码。在生产环境里看到”某个字段出现了一个没人写过的值”,别怀疑玄学——去代码库里搜这个值(或它的来源表达式),十有八九能搜到那行悬垂写。debug 构建下这一幕同样可见,而且更漂亮,尸体上新旧两种笔迹并存:
corpse : 7AB7AB7A DDDDDDDD DDDDDDDD DDDDDDDD
凶手刚写的 0x7AB7AB7A 旁边就是死者的 0xDDDDDDDD 标记——案发时序一目了然:先死(DD),后写(7A)。
形态六:free list / tcache 指针破坏——嫌疑人:double free 或 free 野指针
伤口特征:报错文本里出现 tcache、fastbin、unsorted 这些分配器内部结构的名字,或者 malloc 返回了一个根本不该存在的地址。机制:double free 让同一块挂进空闲链表两次,或越界写打坏了链表的 next 指针——分配器接下来会顺着一条被污染的链表走,取回一个”账本说是空闲、实际另有其主”的块。glibc 2.26+ 对最简单的 double free 有当场检测:
char* p = malloc(0x20);
free(p);
free(p); // -> free(): double free detected in tcache 2
这是六种里唯一常能”当场报案”的形态——因为 tcache 的 key 检查发生在第二次 free 的瞬间,距离为零。但注意:它抓的是”紧接着的 double free”,中间隔了任意次其他分配的 double free 仍然走形态三的路子延迟爆。
破坏者画像速查表
| 伤口形态 | 嫌疑人 | 首选验证手段 |
|---|---|---|
| 相邻 1~n 字节坏,常在块尾 | off-by-one / 小越界 | 红区类工具(ASan / PageHeap) |
| 大片同值覆盖 | memset/strcpy 家族 | 值本身当指纹搜代码 |
| 数据完好、账本坏 | 邻居越界写 | 数据断点守 size 字段 |
| 值诡异但不崩 | UAF 读 | debug 填充 / MSan |
| 新对象里出现旧字段 | UAF 写 | 搜字段值定位凶手 |
| 报错带 tcache/fastbin 字样 | double free / 野 free | 审计 free 路径 |
五、主案例:一起崩溃在 malloc 里的悬案(端到端)
把前面所有零件装起来,还原一次完整办案。案情是第四节实验的生产化版本:配置热更新场景,旧配置释放后仍被写入。
案发背景(合并前面的实验代码,去掉验证用的 printf,只留主干):
struct Config {
long hits; // 统计计数:每处理一个请求加一
char name[24];
};
Config* g_cfg;
void worker_step() {
g_cfg->hits++; // 热点路径:每请求执行一次
}
void reload_config() {
Config* fresh = load_new(); // 读新配置文件
delete g_cfg; // 旧配置释放
g_cfg = fresh; // 切换
}
某天,线上监控开始报一种”不可能”的数据:某个全新分配对象的 hits 字段生来就是 0x7AB7AB7A(形态五的指纹)。偶发、不可复现、一个月三四次。这就是我们的悬案。
错误办案路径(百分之九十的人走过的弯路):在发现坏数据的读取处下断点,重跑,等复现。等不到——因为断点守的是尸体,不是凶手;而且每次运行堆布局都不同,伤口落在谁身上是掷骰子(这正是《”在我机器上是好的”:环境差异五层谱系》里”内存布局差异”那层的日常形态(暂未发布,上线后补链接))。
正确办案路径,四步:
第一步,验伤口(不追人)。坏值 0x7AB7AB7A 稳定出现,且总在对象头部——形态五,UAF 写,凶手是个还在写旧对象的悬垂引用。先查系统里有没有这个常量:搜代码库,0x7AB7AB7A 没有字面量,但 hits 的写入点全库只有一处:g_cfg->hits++。凶手画像出来了:一个没跟上 g_cfg 切换的旧引用。搜 g_cfg 的缓存点(局部拷贝、lambda 捕获、回调闭包、另一个线程读到一半的旧值),范围从全库缩到个位数。
第二步,布控(守伤口,不守尸体)。在实验环境里给 hits 的地址下数据断点——注意是写断点,守的是”谁写这块地”,不是”谁读”。VS 里”新建数据断点”填地址,WinDbg 里 ba w8 <addr>,gdb 里 watch -l *(long long*)addr。命中即抓现行,栈顶就是写它的那条指令。
第三步,如果布控等不到(低频、要压测才出),换页堆。对越界写类凶手,full PageHeap 是降维打击——见第七节的实测对比。对 UAF 写这类”合法地址上的非法写”,Windows 上 gflags /p /enable app.exe /full 同样有效:块独占整页,free 即回收页面,悬垂写当场撞不可访问页。距离归零。
第四步,结案复盘。真凶是 reload_config 期间某线程把 g_cfg 读进了寄存器、切换后仍递增旧对象——修法是 shared_ptr + 原子切换(引用计数让旧对象活到最后一个使用者放手)。复盘时间线:T0 在热更新那毫秒,T3 在几天后的数据对账,中间隔着百万次请求。没有任何常规手段能从 T3 的现场直接看到 T0——这就是为什么方法论必须换。
(Linux 侧的等价办案姿势:MALLOC_PERTURB_=165 起指纹、gdb 的 watch -l 布控(实录见第七节)、ASan 三栈直接给分配/释放/访问三点判决。工具差异,剧情相同。)
六、方法论:从”看崩溃点”到”回溯因果链”
四步侦查法,一张表说完,建议贴在工位上:
| 步骤 | 侦查学动作 | 调试动作 | 关键心法 |
|---|---|---|---|
| 1 | 保护现场 | 第一时间取 dump,别在活进程上乱试 | 时间在毁证:每次分配都在冲刷现场 |
| 2 | 验尸 | 崩溃点分类:良民还是嫌疑人? | 栈上有你的写代码才直接查,否则只当”案发通知” |
| 3 | 鉴定受害者身份 | 读填充指纹、伤口形态,锁定破坏类型 | 尸体上的值会说话:DD/CD/同值海/旧字段 |
| 4 | 布控抓现行 | 数据断点守伤口 / PageHeap / ASan | 把暴露点搬到破坏点,距离归零 |
这张表背后,是贯穿全文的统一框架。回头看,所有堆调试手段其实都在做同一件事——压缩”破坏发生”与”破坏暴露”之间的时间距离:
| 手段 | 做的事 | 破坏-暴露距离 |
|---|---|---|
| 裸堆(release,无工具) | 带伤跑,伤口被复用冲刷 | 天文距离:天级 |
| 填充值(debug CRT / MALLOC_PERTURB_) | 给尸体纹身,认得出 | 缩短:至少能验尸 |
| guard 区(0xFD / _CrtCheckMemory) | 伤口门口装铃铛 | 踩到护栏才响,free 时检查 |
| 数据断点(ba w / watch) | 在伤口上装摄像头 | 谁写谁被抓,等得起就抓现行 |
| full PageHeap | 块独占页 + 尾部保护 | 越界写当场崩,距离≈0 |
| ASan 红区+隔离区 | 破坏即报告 + 尸体不许消失 | 距离=0,且证据不灭失 |
| TTD / rr 录制 | 全程可倒放 | 从 T3 倒带回 T0,任意回看 |
三件武器在这个框架下各就各位:dump 是物证袋,sanitizer 是案发直播,反向调试是时间机器。而本文讲的案侦学,是决定”带哪个工具、看哪块证据、守哪个地址”的办案章程——它们仨回答的是”怎么拿到证据”,这篇回答的是”证据到手后往哪看”。
七、为什么”在崩溃处下断点”注定徒劳(新手陷阱专题)
值得单独开一节,因为这是堆腐坏排查里最普遍的动作,也是最注定徒劳的动作。三个理由,一个比一个根本:
理由一:崩的是受害者。第一节讲透了。你在案发现场蹲守凶手,但凶手案发后就走了,尸体和你大眼瞪小眼。
理由二:重跑会重新洗牌。每次运行,堆布局都不同——ASLR、分配顺序、环境变量大小,都会改变”伤口落在谁头上”。你在等一个不会第二次以相同形态出现的崩溃。
理由三:断点守的是空间点,凶手是时间点。普通断点的语义是”执行到这条指令就停”,它假设凶手会回到这个地址。但堆腐坏的凶手是”某次对某个地址的写入”,它不重演。你需要的语义是”这块内存被写就停”——这是数据断点(watchpoint),硬件层由调试寄存器实现,x86/x64 只有 4 个(DR0~DR3),所以”守哪个地址”是门学问:首选伤口本身(size 字段、坏字段的头 4/8 字节),其次才是整块。
布控也不是一击必中。看一段实录(Ubuntu 24.04 + glibc 2.39 + gdb 15,程序 free 之后把悬垂指针交给别的函数去写,watch 盯住那块内存):
// stakeout.c - 布控目标:free 之后被 worker 悬垂写的那 8 字节
#include <stdio.h>
#include <stdlib.h>
static void worker(long long* stale) {
*stale = 0x7AB7AB7A7AB7AB7A; // 真凶,藏在别的函数里
}
int main(void) {
long long* p = malloc(sizeof(long long));
*p = 42;
long long* stale = p;
free(p);
worker(stale); // 案发
return 0;
}
(gdb) break stakeout.c:14 # 停在案发前,先布控
(gdb) run
(gdb) watch -l *(long long*)stale
(gdb) continue
Hardware watchpoint 2: -location *(long long*)stale
Old value = 42
New value = 22906492249
#0 tcache_put (tc_idx=0, chunk=0x555555559290) at ./malloc/malloc.c:3166
(gdb) continue # 栈顶在 glibc 内部:收尸人在挂链表,放行
Hardware watchpoint 2: -location *(long long*)stale
Old value = 22906492249
New value = 8842724935898475386 # 0x7AB7AB7A7AB7AB7A
#0 worker (stale=0x5555555592a0) at stakeout.c:7
#1 0x00005555555551ce in main () at stakeout.c:14
两次命中,一次陷阱:第一次停下来的是 tcache_put–allocator 在给尸体挂链表指针,合法的写,看栈就知道(栈顶埋在 malloc.c 里)。放行,第二次命中才是真凶:新值正是悬垂写的那个常量,栈顶是 worker,正是写下它的人。布控日志要过滤的不是噪声,是收尸人–这是数据断点布控的标准功课,新手常见的翻车是把第一次命中当成工具误报,掉头就走。
最后是 PageHeap 的降维打击,实测对比(Windows 11 + MSVC 2026,程序只做一件事:malloc(8) 后 memset 写 9 个字节):
===== 普通堆 =====
p = 000001FF50C53BC0
still alive: the 1-byte overflow went unnoticed
exit=0
===== full PageHeap(gflags /p /enable heapguard.exe /full)=====
p = 0000026A9BC69000 # 注意:整页对齐,块尾紧贴页边界
VERIFIER STOP 000000000000000F: pid 0x5840: corrupted suffix pattern
进程当场终止 # 写第 9 个字节的那条指令,当场崩
同一行越界代码:普通堆上静默通过(那 1 个字节打在 padding 或邻居头上,等未来的某次 free 才可能爆);full PageHeap 下,块被放到页尾、紧贴不可访问页,第 9 个字节写下去的瞬间进程死亡——崩溃点从”任意远之后”搬到了”案发指令本身”。(较新的 Windows 上页堆由 Application Verifier 层实现,报 corrupted suffix pattern;经典实现是直接触发 guard page 访问违例。机制细节不同,结论一致:距离归零。)
代价也要讲清楚:full page heap 下每个分配至少独占一页(4KB),内存放大上百倍,只能按进程灰度开、只在排查时开,开完记得 gflags /p /disable。这也是为什么页堆在本文里是”重炮轰门”而不是”日常巡逻”。
八、团队落地:把案侦学变成 SOP
单人会办案不够,要让团队拿到任何堆崩溃都能走对路。四件事:
1. 崩溃自动分流的第一道过滤。崩溃上报后先做一次栈分类:栈顶在 libc/字符串函数/allocator 内部的(良民名单命中),直接走本文的取证路线,禁止在崩溃点上浪费重跑;栈顶在自己代码的写操作上的,才走常规”看栈查代码”。这一步自动化的成本极低(正则匹配栈顶模块),收益是砍掉最大的一块无效排查时间。
2. 排查 SOP,五分钟首轮(认尸三命令)。拿到 dump 后,三分钟内做完:
– WinDbg:!analyze -v 看异常类型和栈 → .exr -1 看异常上下文 → 对可疑指针 db <addr> L20 看原始字节,对着附录 A 认指纹(DD/CD/FEEE 一眼定生死)。
– gdb:bt 看栈 → x/16gx <ptr> 看内存 → info proc mappings 看这个地址落在哪个区(堆?栈?还是根本没映射)。
– 认出指纹,直接跳到第四节的画像表选验证手段;认不出,才考虑布控。
3. 工具箱分层放置。填充指纹(debug CRT / MALLOC_PERTURB_)成本为零,CI 常备;ASan 构建进 CI 每日跑(详情见《非侵入探针》第八节);PageHeap 只在作战时开,开必有 disable 收尾,写进值班手册。三层的破坏-暴露距离分别是”能验尸 / 案发直播 / 当场击毙”,按需取用。
4. 预防端收口。案侦学的最高境界是少出案:智能指针和容器替代裸 new/delete(直接消灭形态四、五、六的嫌疑人池);缓冲区操作统一走带边界的接口;CI 的 ASan + 模糊测试让大部分越界在合并前就暴露(见《非侵入探针》的 fuzz 构建配置)。每灭掉一类嫌疑人,未来的值班同学就少熬一次夜。
九、结语:调试的时间维度
回头看这次 series 走过的路:《AI 写的 bug 像正确的代码》推翻了”作者知道意图”,海森堡篇推翻了”观察不改变系统”,这篇推翻的是最日常的一条:”崩溃告诉我们 bug 在哪里。”
三者拼起来是一个完整的认识论图景:你看到的崩溃,从来不是 bug 本身,而是 bug 与你的观测方式、你的构建配置、你的内存布局相撞之后留下的投影。堆腐坏把这件事推到极致——它把调试从空间问题(bug 在哪一行)变成了时间问题(那一笔写在哪个时刻、谁写的、为什么现在才爆)。崩溃栈是”现在”,病因在”过去”,而堆这个证人还在不停地销毁证据。
所以拿到任何崩溃,先默念一遍本文的全部内容:这是发现现场,还是案发现场?然后取物证、验指纹、看伤口、布控。工具会换,平台会换,这套章程不换。
附录 A:填充值指纹总表(跨分配器对照)
| 值 | 分配器/场景 | 含义 | 看到它说明 |
|---|---|---|---|
0xCDCDCDCD | MSVC debug CRT 堆 | Clean:新分配未写 | 有代码在读未初始化堆内存 |
0xDDDDDDDD | MSVC debug CRT 堆 | Dead:已释放 | use-after-free(读或写) |
0xFDFDFDFD | MSVC debug CRT 堆 | Fence:块前后护栏 | 越界读写打进了 no-man’s land |
0xCCCCCCCC | MSVC debug 栈(/RTC1) | 未初始化局部变量 | 读未初始化栈变量;栈被踩花的嫌疑值 |
0xABABABAB | Windows NT 堆 | 分配未初始化 | 读了 HeapAlloc 后未写的内存 |
0xBAADF00D | Windows NT 堆 | LocalAlloc(LMEM_FIXED) 未初始化 | 同上(Bad Food) |
0xFEEEFEEE | Windows NT 堆 | HeapFree 之后 | use-after-free(OS 堆路径) |
MALLOC_PERTURB_ 的值 | glibc(设置该环境变量后) | 大块释放填 n、分配填 ~n;小块走 tcache 不填充(头 16 字节被链表指针占用) | 带”邮戳”的尸体(仅大块可靠):能区分死后读还是生后读 |
0xBEBEBEBE | ASan | malloc 默认填充(malloc_fill_byte) | ASan 构建里读到未初始化 |
| 影子值 0xfd / 0xfa 等 | ASan | 影子内存毒化标记(非数据本身的值) | 报告文本直接给出,无需人眼认 |
用法提醒:debug 有 release 无(CRT/RTC 一族);NT 堆标记只在特定路径填充;glibc 必须显式开 MALLOC_PERTURB_。release 环境里”没有指纹”本身也是信息——说明你需要主动纹身(PERTURB / PageHeap / ASan)。
附录 B:glibc malloc 报错指纹速查
| 报错文本(malloc_printerr) | 直接含义 | 常见病因 |
|---|---|---|
free(): invalid pointer | 指针非堆地址或严重未对齐 | free 了栈/全局指针、野指针 |
free(): invalid size | 本块 size 非法(过小/未对齐) | 邻居越界写打坏本块账本 |
free(): invalid next size (normal) | 下一块 size 越界 | 本块 size 被改大、相邻块头被打坏 |
free(): invalid next size (fast) | 同上(fastbin 路径) | 同上 |
double free or corruption (top) | 试图 free 的块是 top chunk | double free 且块已并入 top |
double free or corruption (out) | 下一块地址越出 arena | size 被改大、账本严重损坏 |
double free or corruption (!prev) | 下一块显示本块已 free | double free(非 tcache 路径) |
free(): double free detected in tcache 2 | tcache key 命中 | 同一块紧挨着 free 两次(glibc 2.26+) |
malloc(): corrupted top size | top 的 size 大于系统内存 | 越界写打坏 top(glibc 2.29+) |
malloc(): invalid next size (unsorted) | unsorted 取块时 next 异常 | 相邻空闲块头被打坏 |
malloc(): unaligned tcache chunk detected | tcache next 解混淆后未对齐 | 悬垂写打坏 tcache 链表(glibc 2.32+) |
malloc(): unsorted double linked list corrupted | unsorted 链表断裂 | 越界打坏 fd/bk 指针 |
读法:这些报错全部是 T3,不是 T0。它们告诉你账本哪里错了(伤口形态),配合第四节的画像表反推破坏者,再上数据断点或 PageHeap 抓现行。报错文本随 glibc 版本略有增减,标注的版本号是引入时间。
附录 C:堆取证命令速查
WinDbg:
!heap -s # 堆摘要:各 heap 的大小与增长趋势
!heap -p -a <addr> # (页堆开启时)查地址归属 + 分配点栈,抓真凶神器
!heap -h 0 # 详列主堆所有块
!heap -srch 41414141 # 全堆搜值:圈定同值覆盖伤口的范围
ba w4 <addr> # 数据断点:该地址被写即断(守伤口不守尸体)
dt _HEAP_ENTRY <addr> # 看块头账本
gflags /p /enable app.exe /full # 开 full PageHeap(战时才开)
gflags /p /disable app.exe # 用完必关
gdb:
x/16gx <addr> # 按机器字看内存(认指纹的主力)
x/32bx <addr> # 按字节看(认单字节伤口)
watch -l *(long long*)<addr> # 硬件数据断点,谁写谁停
bt / frame N # 栈 / 切栈帧
info proc mappings # 地址落在哪个区(堆/栈/未映射)
set env MALLOC_PERTURB_ 165 # glibc 主动纹身
run # 跑起来等命中
VS:Debug 窗口 → 断点 → 新建数据断点(地址表达式),等效 ba w;注意全进程只有 4 个硬件槽位。
相关文章
- 你越想看清它,它就越不存在:海森堡 bug 与调试的观察者效应 – 同一堆 bug 的另一半:观察如何改变系统
- AI 写的 bug 像正确的代码 – 系列认识论的起点
- Visual Studio 的 Debug 与 Release 到底差在哪 – debug 填充为什么只在 debug 里有的完整答案
- WinDbg 应用层实战 – 附录 C 命令的系统教程
- 非侵入探针:ASan/TSan/UBSan 如何让 bug 在发作之前显形 – 距离归零的日常方案

发表回复