做浏览器内核开发时间久了,看到一份陌生崩溃转储的第一反应,往往是先深吸一口气。因为崩溃点就像事故现场,能看到的只是某一个瞬间的定格,真正的原因常常藏在时间线的另一头。这次要说的这个案例,就是一份用户侧回传的 MiniDump,堆栈起初看还算干净,追到最后居然是一个 BindOnce 回调把裸 this 绑进了延迟任务,对象销毁之后任务才执行,典型的 use-after-free。
这份分析过程比较有代表性,我打算完整记录下来:从拿到 dmp 文件开始,先看异常记录,再还原调用栈,顺着崩溃指令摸到堆里的一块释放内存,最后在源码里定位到 BindOnce 的生命周期缺陷。整个过程不涉及某个具体浏览器的黑魔法,就是普通 C++ 崩溃调试的通用思路,但里面有不少实际操作细节,是日常文档里不容易看到的。
文章适合这几类人看:正在做浏览器内核、客户端框架或者任何重度异步 C++ 项目开发的工程师;被用户反馈的 dmp 文件反复折磨的稳定性负责人;以及想深入理解 UAF 机制和回调生命周期管理的同学。阅读这篇文章你不需要会所有 windbg 命令,但如果你能跟着走一遍,下次遇到类似的异步回调崩溃,大概率能少熬两个通宵。
1. 拿到 MiniDump 的第一步:先把事故现场拼出来
1.1 崩溃记录里到底藏着哪些“物证”
MiniDump 不是日志,它是一份进程崩溃瞬间的“冷冻切片”。所谓 Frozen Snapshot,指的是把失败那一刻的线程列表、寄存器上下文、调用栈、模块加载列表,以及若干内存区域全部打包进一个 .dmp 文件里。和我们平时看事故录像不一样,录像能回放全过程,dmp 只是一张静态的照片,所有动态行为都只能靠代码推理补全。
我常用的类比是:MiniDump 相当于事故车辆的行车记录仪截图加仪表盘数据,而引擎盖下每个零件的状态要靠你自己去拆。文件里有几个关键部分你要心里有数:
- ExceptionRecord,也就是异常记录,包含异常类型、异常地址、异常发生的指令指针。
- ThreadList,所有线程的上下文快照,每个线程的寄存器值、栈信息都在里面。
- ModuleList,崩溃时进程加载了哪些模块,模块基址、大小、版本。
- MemoryList,转储时选定的内存范围,可能是部分堆内存、栈内存和代码段。
大部分用户侧回传的 dmp 都是 mini 级别,内存并不完整,但这不代表没法分析。真正决定分析上限的,往往不是 dump 尺寸,而是你手里的符号文件。
1.2 符号就是“地图”,没有符号寸步难行
用 windbg 打开 dump 后第一件事,永远是配好符号路径。没有符号的堆栈长这样:
0:000> k Child-SP RetAddr Call Site 000000ba`4a9fea58 00007ff7`1a3a1234 mycore.dll+0x2a33f4 000000ba`4a9fea60 00007ff7`1a3b5678 mycore.dll+0x1b3ab2这种十六进制偏移看着就像一堆乱码,你根本不知道它停在了哪个函数,更别提定位源码行。
我的做法是先设置环境变量_NT_SYMBOL_PATH,指向本地符号缓存和公共符号服务器,再对内核自己的模块也强制重新加载:
0:000> .symfix D:\symbols 0:000> .reload /f 0:000> !sym noisy符号加载成功后,k输出会变成可读的函数名、文件路径、源码行号。调内核崩溃时,这一条做好了,后面就顺了一半。
另一点容易被忽略:如果分析的目标进程是浏览器内核,它的渲染进程、网络进程、GPU 进程可能是不同模块,你需要确认崩溃发生在哪个进程上下文里。如果只有渲染进程的 dump,所有模块基址都和你本地构建不一致,记得对好版本号,否则符号错位会让堆栈完全走样。
1.3 先看异常点,还是先看线程?
打开 dump 后不要急着双击“异常点”。两个固定动作必须先做:
第一步,用!analyze -v让调试器自动扫一遍异常记录。它会给出异常类型、出错模块、可能出错的函数,还会尝试做一些默认分析。
第二步,用.ecxr把当前线程上下文切换到异常发生时的上下文。这个很关键,否则你看到的寄存器可能不是崩溃那一刻的现场。
在浏览器内核里,崩溃不一定发生在主线程。渲染进程里的 JS 线程、IO 线程、合成器线程都可能挂。!analyze -v往往会直接指出一个 FAULTING_IP 和一个 FOLLOWUP_IP,这两个位置通常就是第一现场。
但我要多说一句:现场线程的调用栈,只是“压死骆驼的最后一根稻草”。真正的问题可能早在几分钟前就在另一个线程埋下了。所以看完异常线程后,要把输出里的 STACK_TEXT 完整保存下来,再到主线程和后台线程里翻一翻,尤其注意所有线程的栈上是不是有对象析构、任务清除、容器清空之类的痕迹。
1.4 分析前的“三不要”
这三条是我在实战里踩过坑之后总结的,先说结论:
- 不要一上来就改代码。没有完整证据链之前,任何修改都是猜,猜错了还会把现场覆盖掉。
- 不要忽略异常偏移。崩溃指令的偏移往往能精确指向某个成员变量的访问,这在后面判断对象类型时非常有用。
- 不要被模块名误导。比如崩溃栈飘到了系统模块,只能说明你访问的内存地址非法,不代表根因在系统代码里。
分析 dmp 的核心目标只有一个:把“某一个时刻的静态状态”还原成“一条有时间顺序的动态链路”。做到了这一步,才算真看懂了这个崩溃。
2. 从堆栈到“物证”:把悬空对象揪出来
2.1 崩溃指令到底访问了什么
拿到异常点后,我习惯先用反汇编看一眼崩溃指令附近在做什么。比如某个崩溃的 FAULTING_IP 显示MediaDeviceCompletionHandler::OnResult+0x12,反汇编出来可能长这样:
0:000> u rip L 10 mycore!MediaDeviceCompletionHandler::OnResult+0x12: 00007ff7`1a4a07e2 mov rax, qword ptr [rcx+0x28] 00007ff7`1a4a07e6 mov rdx, qword ptr [rax+0x10] 00007ff7`1a4a07ea call qword ptr [rdx+0x20]这里崩溃在mov rdx, qword ptr [rax+0x10],说明 CPU 尝试从rax指向的地址偏移 0x10 处读数据。这个rax很可能是某个对象的成员变量,而成员本身已经被释放了。
这时候看寄存器:
0:000> r rax, rcx rax=000001d5f0c2e568 rcx=000001d5f0c0a210接下来就是要确认这两个地址指向哪儿。用!address看虚拟内存页状态:
0:000> !address rax如果输出显示MEM_FREE或者Page present: 0,那这个地址已经被操作系统回收,访问它当然崩。
这里有个关键技巧:如果目标地址还在堆里,但堆块已经被标记为 free,那就不只是普通的野指针问题,而是典型的 UAF——对象销毁了,指针却还被某个执行流握着。
2.2 在堆里认“尸体”:常见填充值识别
Windows 上 MSVC 的 Debug CRT 在分配和释放内存时会填不同的字节模式,这些“尸体标记”是定位 UAF 的好帮手:
0xCDCDCDCD:new 出来的内存尚未初始化。0xDDDDDDDD:堆内存已经释放。0xFDFDFDFD:堆块边界之后的保护字节。0xCCCCCCCC:栈上的变量,通常是未初始化返回地址。
在崩溃对象地址上直接 dump 内存,如果看到一片0xDDDDDDDD,基本可以断定对象已经析构,而且析构发生在崩溃之前。
举个例子,我用dp查看疑似对象地址:
0:000> dp 000001d5f0c0a210 L 8 000001d5`f0c0a210 dddddddd`dddddddd dddddddd`dddddddd 000001d5`f0c0a220 dddddddd`dddddddd dddddddd`dddddddd对象头原本应该放 vtable 指针,现在却是清一色的 0xDD,说明释放后内存还没被重新分配,或者重新分配的初始化没有覆盖这一块。
Release 模式下虽然不会填 0xDD,但你可以用!heap -p -a <addr>让调试器去查这个地址属于哪个堆块,并显示它是 busy 还是 free。如果是 free,再加上崩溃栈里访问的原指针,UAF 的“物证”就齐了一半。
2.3 vtable:判断对象类型的钥匙
如果对象内存没被完全破坏,或者崩溃栈上有明确的函数符号,我们可以反推对象类型。但如果对象头部已经被 0xDD 填满,事情会难一些。
反过来,如果对象内存还算完整,第一件事是用dps看头部指针。一般 C++ 对象的第一个 8 字节是 vtable 指针:
0:000> dps 000001d5f0c0a210 L 4 000001d5`f0c0a210 00007ff7`1a4a5c30 mycore!MediaDeviceCompletionHandler::`vftable' 000001d5`f0c0a218 00000000`00000000 000001d5`f0c0a220 000001d5`f0c2e568 <some pointer>看到vftable符号,就能知道这是哪个类。再用dt按类型解释整段内存,就能把对象里的成员变量一个个拆出来看。
dt的典型用法是:
0:000> dt MediaDeviceCompletionHandler 000001d5f0c0a210输出会把每个成员变量的偏移和当前值列出来。这时候,你要找的是那些指向堆内存、却已经失效的成员。比如某个成员是std::unique_ptr<ReplyQueue>,但地址对应的堆块已经 free,那这条线索就非常接近根因了。
2.4 检查分配/释放状态:!heap 的实战用法
在 windbg 里查堆块,最常用的是:
0:000> !heap -p -a 000001d5f0c2e568这个命令会告诉你在哪一个 Heap 里、属于哪个 segment、是 busy 还是 free。如果显示是 free,你还可以继续看这个地址附近有没有同类型的对象,看看它是不是某次批量析构时一起被释放的。
还要注意 x64 和 x86 的差异,heap 数据结构在不同架构下表现不太一样,!heap的输出格式也会有区别。但思路一样:确认指针在崩溃时是否还活着。
到了这一步,我们手里通常已经有三个事实:
- 崩溃指令在访问某个成员对象。
- 该成员对象所在的内存块已经被释放。
- 调用栈上有明确的任务执行帧。
接下来,就得去代码里找答案:这个指针是怎么绑定到回调里的,对象又是何时被销毁的。
3. BindOnce 与 UAF:一次回调的“延迟爆雷”
3.1 BindOnce 是什么,为什么所有人都爱用它
在浏览器内核这类大型 C++ 项目里,跨线程传任务太常见了。你写完一个回调,可能想把它丢到 IO 线程或者 UI 线程去执行。如果直接传裸函数指针,类型信息和参数捕获都很难管理,所以内核的 base 库里一般会提供 BindOnce 和 BindRepeating 这一套回调封装工具。
BindOnce 的核心语义是“一次性”。你把它绑定到一个成员函数和一组参数上,它会产生一个 move-only 的可调用对象,这个对象只能被调用一次。调用完之后,内部状态会被清空,不允许再Run()。
典型写法是这样的:
base::OnceClosure task = base::BindOnce( &MediaDeviceSession::OnDeviceReply, weak_factory_.GetWeakPtr(), std::move(reply)); task_runner_->PostTask(FROM_HERE, std::move(task));BindOnce 让“跨线程投递某个一次性操作”这件事变得类型安全,参数通过 move 语义传进去,不用手动 new 一堆 closure。这是它好用、也容易被大量使用的原因。
3.2 UAF 是怎么在“延迟任务”里生根的
问题在于:任务队列是异步执行的。你 PostTask 之后,这个任务不一定马上执行,它可能要排队等几百毫秒甚至更久。在这段时间里,如果任务里引用的对象先被销毁了,那任务执行时就会访问到悬空内存。
回到我们的案例。一开始代码可能是这样写的:
void MediaDeviceSession::StartFetch() { task_runner_->PostTask( FROM_HERE, base::BindOnce(&MediaDeviceSession::OnResult, this, request_id_)); }看起来没问题:this是当前对象指针,OnResult是成员函数。但如果MediaDeviceSession在任务执行前析构了,这个this就成了悬空指针。
时序可以还原成三步:
- 某线程 PostTask 提交回调,任务进入队列。
- 用户关闭页面/会话,
MediaDeviceSession析构,对象内存被释放。 - 任务队列执行回调,
OnResult开始访问this->xxx,刚好踩到已释放堆块。
这就是我们这份 dump 里看到的完整链条。堆栈显示执行帧是TaskRunnerImpl::RunTask,再往下走两层就是MediaDeviceCompletionHandler::OnResult,而崩溃地址所在堆块早已被标记为 free。
3.3 BindOnce 为什么没能救你
看到这,你可能会问:BindOnce 这么讲究安全性,它不是应该绕开 UAF 吗?答案是否定的。
BindOnce 只约束“调用次数”,不管理“参数里指针指向的对象的生命周期”。它保证的是:同一个 OnceClosure 不会被执行两次,而不保证闭包内部捕获的裸指针在延迟执行时仍然有效。
BindOnce 里有两类典型参数:
- 值类型参数,比如 int、std::string,任务无论何时执行,这些值都还在。
- 指针/引用类型参数,比如
this、裸指针、引用,这类参数只负责“指向”,不负责“持有”。
一旦参数是裸指针,释放和延迟执行之间的时间差就是你埋下的炸弹。内核里经常能看到base::Unretained(this),它其实是把“我不保证对象存活”的责任显式摆在台面上,配合一些同步场景使用。但只要是异步投递,Unretained 的裸 this 就是 UAF 的高发区。
简单总结:BindOnce 管的是“你用了一次就不能再用”,UAF 管的是“对象已经没了你还去摸它”。两者是两个维度的问题,不能混为一谈。
3.4 真正的解法是让生命周期“对齐”
既然问题出在生命周期不匹配,那修复的方向只有两个:要么让对象活到任务执行完,要么让任务在对象销毁后不再执行。
首选方案是 WeakPtr。WeakPtr 的特性和 BindOnce 搭配非常默契:对象析构时,WeakPtrFactory 会让所有关联的 WeakPtr 自动失效;任务执行时,底层绑定代码会自动检测 WeakPtr 是否失效,如果失效就直接短路,不再调用成员函数。
代码改成这样:
void MediaDeviceSession::StartFetch() { task_runner_->PostTask( FROM_HERE, base::BindOnce(&MediaDeviceSession::OnResult, weak_factory_.GetWeakPtr(), request_id_)); }任务队列里即使OnResult被调度到,也会因为 WeakPtr 失效安全退出。代价很小,收益是崩溃点直接消失。
另一种思路是把对象生命周期托管给 shared_ptr,让异步任务持有强引用,保证对象活到任务执行完。这个方案适合一些把会话生命周期和任务绑定在一起的场景,但要注意循环引用问题,不能闭着眼套。
我个人的代码规范建议是:回调里一律禁止裸this,统一走 WeakPtr,或者显式走值捕获。这规则写进 review checklist,能挡掉一大批上线的定时炸弹。
4. 完整复盘:一次渲染进程崩溃从 dmp 到修复
4.1 拿到 dump 后的标准动作清单
这一节我把整个排查流程按顺序列出来,你可以直接照着走一遍。
第一步,配置符号路径并重新加载模块。这一步决定了后续堆栈可读性。
0:000> .symfix D:\symbols 0:000> .reload /f第二步,跑自动分析:
0:000> !analyze -v重点关注 FAULTING_IP、EXCEPTION_RECORD 和 STACK_TEXT。把异常类型记下来。这次是EXCEPTION_ACCESS_VIOLATION,也就是 0xC0000005,典型的内存访问非法。
第三步,切换异常上下文:
0:000> .ecxr第四步,看调用栈:
0:000> k L 30注意所有栈帧,尤其是有符号的帧。看到TaskRunnerImpl、MessagePump、RunTask这类帧时,要下意识警觉,这很可能是异步任务执行。
第五步,查崩溃指令访问的地址范围:
0:000> !address <crash_address> 0:000> !heap -p -a <crash_address>第六步,用dt按类型检查对象内容,用dps找 vtable,确认对象类型。
第七步,把所有线索串联起来,再到源码里找对象析构和任务提交的顺序。
这套动作从开始到拿到核心结论,熟练之后大约二十分钟。真正的瓶颈在第七步,因为它要你对业务代码够熟。
4.2 本次案例里的关键证据链条
这次崩溃的具体路径是这样的:用户开启了一个远程设备会话,浏览器内核发起请求后,IO 线程上排队等待返回结果。请求还没回来,用户直接关闭了会话窗口,MediaDeviceSession被析构。随后网络事件返回,任务队列执行之前提交的 BindOnce 回调,回调访问this->session_state_,而该对象已经被释放。
调出的堆栈关键帧如下:
00 mycore!MediaDeviceCompletionHandler::OnResult+0x12 01 mycore!base::internal::Invoker<...>::RunOnce+0x45 02 mycore!TaskRunnerImpl::RunTask+0x1e 03 mycore!MessagePump::DoRunLoop+0x3a!heap -p -a输出显示session_state_指向的堆块已经 free。用dp查看该地址时,内存里全是0xDDDDDDDD,说明对象在 Debug 构建下已经走完了析构函数。
到这里,证据已经很硬,剩下的只是去源码里确认谁在后面拿着这个指针。
4.3 修复方案:用 WeakPtr 斩断悬空路径
修复过程比定位简单得多。给MediaDeviceSession加一个WeakPtrFactory<MediaDeviceSession>成员,然后把所有 PostTask 里的裸this全部换成weak_factory_.GetWeakPtr()。
改完之后的回调入口长这样:
void MediaDeviceSession::OnResult(base::WeakPtr<MediaDeviceSession> session, int request_id) { if (!session) { return; } session->HandleResult(request_id); }这样即使任务被调度到,发现 WeakPtr 已经失效,什么都不做直接返回。对象已经析构,成员访问根本不会发生。
验证时,我建议做两步:
- 写一个单元测试:先 PostTask,再立刻析构对象,然后手动 RunLoop 驱动任务队列执行,断言没有崩溃、没有访问悬空成员。
- 在 PageHeap 环境下跑同一个复现用例,确认修复前崩在访问点,修复后正常退出。
4.4 沉淀下来的团队守则
这次问题修复后,我顺手把下面这几条守则发给了团队,很有用:
- 异步回调参数中,禁止裸指针捕获
this。一律使用 WeakPtr 或在回调内显式处理生命周期。 - PostTask 前先确认对象的生命周期。对象有明确析构时机的,优先用 WeakPtr。
base::Unretained(this)只能用于同步调用栈,异步场景一律拒绝。- 凡是任务队列回调,review 时必须检查 this 的捕获方式。
这些规则虽然简单,但真正堵住的是 UAF 里最危险的一类“延迟爆雷”。崩溃调试很多时候是这样:定位到根因之后,修复不只是改一行代码,而是把一个常见模式纳入规范,避免同样的问题换一层皮再出来。
5. 常用工具与排查避坑记录
5.1 让 UAF 更容易现形的三把手术刀
线上 dmp 固然重要,但真正想快速定位 UAF,我更推荐先在开发环境复现。三把工具按适用场景分工:
第一把,PageHeap。通过 gflags 开启 full page heap,对象释放后所在页会被标记为不可访问,任何访问都会立即触发崩溃,崩溃点就是第一次使用悬空内存的地方。命令参考:
gflags /p /enable mycore.exe /full开启后,如果在开发机上复现了这个用户操作流程,UAF 会非常显眼。缺点是内存占用大,只适合线下调试。
第二把,AddressSanitizer,也就是 ASan。它能在运行时精确报告 heap-use-after-free,并且同时打印两段关键栈:释放发生时的栈,和访问发生时的栈。这两个栈放一起,根因几乎呼之欲出。ASan 适合 CI 跑回归用例,但不适合线上环境。
第三把,自定义 UnhandledExceptionFilter,配合MiniDumpWriteDump抓取完整的崩溃现场。产品环境下很多用户不会主动反馈,但如果有崩溃上报机制,就能源源不断把 dmp 收集回来,保证线上问题可以回溯。
三把工具的使用顺序我一般是:先 ASan 复现,复现不出再用 PageHeap,线上就看 dmp。这套组合基本覆盖了日常 90% 的 UAF 排查需求。
5.2 读堆栈时容易踩的坑
第一个坑是混淆崩溃线程和根因线程。崩溃线程可能只是受害现场,真正的凶手线程可能早就跑完了。分析时要把所有线程的栈都粗略过一遍,特别是那几个看起来“什么都没干”的线程。
第二个坑是内联优化导致栈帧缺失。Release 构建下,内联函数和尾调用优化会让栈上少掉好几层。这时候不能因为栈不完整就放弃,可以结合反汇编和源码逐行对照,把缺失的帧补丁一样补出来。
第三个坑是 dump 太小,缺少关键内存区域。有些 mini dump 只包含崩溃线程栈和少量堆数据,!heap查不了几个地址。这种情况下,我一般退一步,优先看栈上的上下文对象,以及寄存器里残存的指针,能拼多少算多少。最理想的情况是开启完整内存转储,但线上要考虑体积,所以常常是在“够用”和“小”之间取平衡。
第四个坑是符号不匹配。客户环境的内核版本号和本地不对,加载了错误符号会让每一帧都错位。所以分析之前,先确认.ecxr里显示的模块版本和本地构建产物是否一致。
5.3 windbg 常用命令速查表
这里整理一份我平时最常用、也是这次分析里用到的命令表,方便查阅。
| 命令 | 作用 | 典型用法 |
|---|---|---|
!analyze -v | 自动分析异常现场 | 打开 dump 后第一件事 |
.ecxr | 切换到异常发生时的上下文 | 紧跟 analyze 之后执行 |
k | 显示当前调用栈 | k L 30 |
!address <addr> | 查看虚拟内存页状态 | 判断地址是否 free |
!heap -p -a <addr> | 查看堆块归属和状态 | 判断地址是否被释放 |
dt <Type> <addr> | 按类型解释内存布局 | 查看对象的成员值 |
dps <addr> L n | 按指针宽度 dump 内存 | 找 vtable 或指针数组 |
ln <addr> | 查找地址对应的最近符号 | 识别未知地址来自哪个函数 |
u <addr> L n | 反汇编浏览 | 看崩溃指令附近的汇编 |
r | 查看/修改寄存器 | r rax, r rcx |
不要让这张表变成只会背命令的字典。关键在于理解每个命令在回答什么问题:异常上下文回答“发生了什么”,堆检查回答“内存是不是死了”,类型解释回答“死的是谁”,符号对应回答“它是代码里的哪一行”。
最后再说点自己的体会
浏览器内核的崩溃调试,尤其是这种从 dmp 堆栈一路摸到 BindOnce UAF 的场景,真正难的不是某个命令行技巧,而是把“静态状态”还原成“动态时序”的思路。MiniDump 给的是结果,代码才是过程。你只要把两者一对应,锁定“对象先析构、回调后执行”这条因果链,问题就解决了一大半。
我最初调这一类问题的时候,也走了一些弯路,比如花很长时间在崩溃线程的汇编里扣细节,却忽略了旁边的后台线程已经执行完析构。后来养成习惯,每次都先理清任务提交点和对象析构点的先后关系,效率明显提升。
这次复盘就先写到这里。其实还有一个更硬核的取证方向没有展开:如何在内存不完整的 mini dump 里,通过对象引用的残留数据重建整个对象引用图。这一块比单纯看栈更接近“现场考古”,我计划在下一篇里继续聊。如果你们在异步回调 UAF 里也遇到过类似的困扰,这篇整理出来的工具链和排查思路,应该能帮你省下几个通宵。