摘要
在一次使用 SpaceSniffer 分析磁盘占用时,我遇到了一个非常容易复现的界面卡死问题。为查明原因,我首先通过 procdump 周期性保存完整转储文件,并使用 WinDbg 对调用栈进行对比分析,确认程序确实陷入阻塞而非假死。逐层追踪后,我最终将问题定位到第三方 Shell 扩展在 COM 激活过程中的异常阻塞,并由此引发死锁。
保存现场
遇到卡死问题,可以先使用 process explorer 观察一下线程运行情况,看看是假死,还是真的卡死了。还可以隔一段时间抓取一个转储文件,如果几次转储文件的调用栈都一样,大概率是真的卡死了。
procdump 有一个非常方便的功能,可以每隔一段时间自动保存转储文件,语法如下:
1 | procdump.exe -ma -n 3 -s 5 SpaceSniffer.exe |
以上命令可以每隔 5 秒(-s 5)为 SpaceSniffer.exe 保存一份完整(-ma)转储文件,一共保存 3 次(-n 3)。效果如下:

简单查看保存的三份转储文件,发现调用栈是一样的,大概率是真卡死了。用 windbg 打开其中一份转储文件,进行分析。
初步分析
执行 ~* kn 命令查看所有线程的调用栈。
1 | 0:000> ~* kvn |
可以看到底部的几个线程都在等待同一个句柄 0x7c,而且都是由 LdrpDrainWorkQueue() 发起的。由上篇文章——《调试实战 | DllMain 的陷阱:当 Windows 10 并行加载遇上线程等待引发的死锁》可知,这个句柄大概率是 ntdll!LdrpLoadCompleteEvent。在 windbg 中输入 dd ntdll!LdrpLoadCompleteEvent L1 可以查看其值,果然是 0x7c。
1 | 0:000> dd ntdll!LdrpLoadCompleteEvent L1 |
大概率又是一个加载器相关的死锁!这些线程中,1 号线程和 7 号线程的调用栈比较值得怀疑,尤其是 1 号线程。先来看看 1 号线程在等待什么?由以上输出可知,1 号线程调用 ntdll!NtWaitForSingleObject() 时的参数是 0x730。执行 !handle 00000730 f 查看句柄信息,如下:
1 | 0:000> !handle 00000730 f |
看来 1 号线程在等待一个 Event,接着看一下 7 号线程在等待什么?7 号线程调用 win32u!NtUserMsgWaitForMultipleObjectsEx() 进入的等待.
1 | 7 Id: 2620.584 Suspend: 0 Teb: 003b3000 Unfrozen |
第一个参数是句柄数组数量,第二个参数是句柄数组。执行 dd 0f49efe8 L1 查看句柄值,然后再使用 !handle 命令查看句柄信息。
1 | 0:000> dd 0f49efe8 L1 |
也在等待一个 Event,但是由于 7 号线程调用的是 NtUserMsgWaitForMultipleObjectsEx(),所以有消息的时候也会被唤醒,重点关注 1 号线程。
细看调用栈
如果仔细查看 1 号线程的调用栈,可以发现 1 号线程的等待是在 ntdll!LdrpCallInitRoutine() 内部,我们知道 LdrpCallInitRoutine() 内部会调用每个模块的 DllMain(),在执行到 YunShellExtV1.dll 对应的 DllMain()(由于没有对应的调试符号,所以不能直接从调用栈看出来在调用 DllMain() 函数)时,其内部调用了 user32!CreateWindowExW() 创建窗口,又间接调用到了 SHCore!_CreateThreadWorker(),而 SHCore!_CreateThreadWorker() 内部又会调用 KernalBase!WaitForSingleObject() 来永久等待一个事件。相当于间接的在 DllMain() 中执行了等待操作!这是一个非常危险的信号。关键调用栈帧可以参考下图红色高亮部分。

那 SHCore!_CreateThreadWorker() 内部在等待什么事件呢?只能从反汇编代码中找答案了。
查看反汇编
打开 32 位的 IDA,加载 32 位的 SHCore.dll,找到 CreateThreadWorker(),使用 F5 查看伪代码,如下(有删减):
1 | BOOL __fastcall CreateThreadWorker(LPCWSTR lpModuleName, int a2, int a3, int a4, _DWORD *a5) |
可以很明显的看到关键代码(带有 //<---- 注释的行)。先创建一个 Event,然后创建线程,把带 Event 的参数传递给新线程,最后再无限等待 Event。猜测新线程执行完必要的操作后,会触发事件。
看到这段逻辑,我立刻就知道了原因:当前可是在 DllMain() 里啊,如果执行不完,新线程没有机会得到执行,也就没办法触发事件,DllMain() 也就会永远的等下去。
没想到这么容易就破案了!捡了个大漏!
总结
- 在
DllMain()中不要做危险操作!尤其不要做等待操作!