微软 Raymond Chen 在 6 月 25 日的 The Old New Thing 里,复盘了一个很容易派错单的 Windows 崩溃。

某个特定第三方程序出现大量栈溢出。崩溃桶看起来指向 shell32.dll,像是 shell32 自己把栈打爆了。但转储往下追,第一次异常发生在 combase!CoTaskMemFree:要执行的地址已经不可执行。

反常点在这里:combase.dll 没有从加载器记录里消失,实际内存却已经空了。

我更在意的不是“又一个稀有 Windows 崩溃”,而是这个案例把崩溃归因里最常见的坑摊开了:崩溃桶里的模块,常常只是第一个倒下的人,不一定是开枪的人。

栈溢出是表象,第一次异常才是入口

这次崩溃栈里反复出现三个函数:RtlLookupFunctionEntryRtlDispatchExceptionKiUserExceptionDispatch

这组名字放在一起,基本说明程序陷进了递归异常处理。一次异常被分发回用户态;系统查找异常处理器时又触发异常;异常再进异常。栈就这样被一层层吃完。

真正要看的不是最后的栈溢出,而是递归开始前发生了什么。

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 已加载,LoadCount0xFFFFFFFF。在 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 里兜圈子。

账本还在,库已成空。这个案子最提醒人的地方正在这里:别见到倒下的人,就急着给他定罪。