微软 Raymond Chen 在 6 月 25 日的 The Old New Thing 里,复盘了一个很容易派错单的 Windows 崩溃。
某个特定第三方程序出现大量栈溢出。崩溃桶看起来指向 shell32.dll,像是 shell32 自己把栈打爆了。但转储往下追,第一次异常发生在 combase!CoTaskMemFree:要执行的地址已经不可执行。
反常点在这里:combase.dll 没有从加载器记录里消失,实际内存却已经空了。
我更在意的不是“又一个稀有 Windows 崩溃”,而是这个案例把崩溃归因里最常见的坑摊开了:崩溃桶里的模块,常常只是第一个倒下的人,不一定是开枪的人。
栈溢出是表象,第一次异常才是入口
这次崩溃栈里反复出现三个函数:RtlLookupFunctionEntry、RtlDispatchException、KiUserExceptionDispatch。
这组名字放在一起,基本说明程序陷进了递归异常处理。一次异常被分发回用户态;系统查找异常处理器时又触发异常;异常再进异常。栈就这样被一层层吃完。
真正要看的不是最后的栈溢出,而是递归开始前发生了什么。
Chen 往栈底追,递归块停在 combase!CoTaskMemFree。异常码是 c0000005,访问冲突。参数含义是:尝试执行一个不可执行地址。
这个地址,正对应 CoTaskMemFree。
| 线索 | 表面看到什么 | 更合理的判断 |
|---|---|---|
| 崩溃桶 | shell32.dll 栈溢出 | shell32 是第一个调用失效 combase 的模块 |
| 重复栈帧 | 异常分发函数反复出现 | 异常处理过程再次异常,形成递归 |
| 首次异常 | combase!CoTaskMemFree 执行失败 | 代码所在内存已经不可执行 |
| 加载器记录 | combase 仍显示已加载 | 加载器账本和真实内存状态不一致 |
对负责 dump 分析的人,这里有一个很实际的动作:不要停在 !analyze -v 给出的 bucket,也不要只看顶部调用栈。要顺着异常链往前找第一次异常,尤其是递归异常处理前的那一帧。
否则,shell32 团队会收到一个看似合理、实际偏题的 bug。
combase 还在账本里,但内存已经被抽走
Chen 用 !address 查那个地址,结果显示 combase.dll 对应区域是 MEM_FREE,保护属性是 PAGE_NOACCESS。
这句话翻成白话就是:调试器还能根据符号把地址叫作 combase!CoTaskMemFree,但那片内存已经不属于可执行代码了。
更反常的是 !dlls。
加载器仍显示 C:\Windows\System32\combase.dll 已加载,LoadCount 是 0xFFFFFFFF。在 Windows 加载器语义里,这通常表示 DLL 被 pinned。正常情况下,它不该被 FreeLibrary 卸载。
所以这里不能写成“combase 被正常卸载”。证据不支持。
更谨慎的判断是:某个未知组件可能用 VirtualFree 一类方式释放了不该释放的地址,也可能发生了内存破坏。比如未初始化变量、错误指针、越界写,把 combase 的基址或相关地址当成了可回收内存。
目前看不清元凶是谁。不能直接指向某个插件、钩子、安全模块或注入组件。
但可以确定一件事:加载器还以为 combase 在,真实内存已经不在。shell32 后面调用它时,只是踩空。
这对 Windows 原生开发者和崩溃分析工程师的影响很具体:
- 如果你维护的是原生程序或插件,不要把系统 DLL 出现在崩溃栈顶部当成免责证据。先排查进程内有没有错误释放、越界写、注入模块、异常的内存管理封装。
- 如果你负责崩溃转储分析,看到 DLL 仍在
!dlls、但!address显示MEM_FREE/PAGE_NOACCESS,优先查谁改动了这片地址,而不是把单子派给栈顶模块。
能做的验证也不玄:围绕可疑地址查 !address、!dlls,再结合全页堆、Application Verifier、ETW 或内存写监控,缩小谁在释放或破坏那段内存。
限制也要说清。这个案例限定在某个特定第三方程序的崩溃样本里。它不能推出“Windows 普遍有这个问题”,也不能推出“所有第三方程序都有类似风险”。
46% 同类样本说明,shell32 只是被喷到的桶
Chen 抽查了这个第三方程序最近 100 个崩溃。
其中,shell32 相关栈溢出只有 11 个样本。还有其他栈溢出、unknown access violation 等不同表象。
继续点查后,约 46% 都属于同一类问题:发送 DLL_PROCESS_DETACH 通知时,某个 DLL 已经被强行从内存移除。随后调用它的模块被错误归因。
这就是 bucket spray。一个底层原因,喷出多个崩溃桶。
工程上,这个判断很值钱。因为排查策略会完全不同。
| 排查路线 | 看起来在做什么 | 实际成本 |
|---|---|---|
| 按崩溃桶逐个派单 | shell32、unknown AV、不同栈溢出分开查 | 人力被切碎,容易追着受害者跑 |
| 按首次异常和内存状态归并 | 找 DLL 被强行移除的共同来源 | 更可能收敛到同一个破坏点 |
我不太买账的是那种“系统 DLL 在栈上,所以先查系统 DLL”的直觉。对这类 native crash,它太省事,也太容易错。
下一步最该看的变量,不是 shell32 哪个调用路径有问题,而是谁让已加载、且被 pinned 的 DLL 内存变成了 MEM_FREE/PAGE_NOACCESS。
如果能在该第三方程序里抓到这一步,46% 这类样本才可能一起收敛。抓不到,就会继续在不同 bucket 里兜圈子。
账本还在,库已成空。这个案子最提醒人的地方正在这里:别见到倒下的人,就急着给他定罪。
