返回 2026-07-03
⚙️ 工程

线程从未卸载的第三方 DLL 中执行的案例The case of the thread executing from an unloaded third-party DLL

Windows 系统底层存在一个经典且危险的异常场景:线程试图从未卸载的第三方 DLL 中执行代码。Raymond Chen 通过具体案例剖析了该问题的触发机制,通常是由于 DLL 卸载过程与线程执行存在竞态条件。文章详细提供了排查此类底层内存访问冲突问题的思路。掌握这些调试技巧对于构建高稳定性的 Windows 应用程序至关重要。

Raymond Chen

Explorer 团队当时正在调查一个发生频率相对较高的崩溃问题,并发现该崩溃表现为某个线程正在从一个已卸载的第三方 DLL 中执行代码。

0:173> k
RetAddr               Call Site
00000000`557c5820     <Unloaded_LibUtils_CloudNs_3.dll>+0x265fe
00000000`00000008     <Unloaded_LibUtils_CloudNs_3.dll>+0x2b5820
00000000`0000000e     0x8
00000000`00000008     0xe
00000000`557c8c18     0x8
ffffffff`fffffffe     <Unloaded_LibUtils_CloudNs_3.dll>+0x2b8c18
00000000`00000000     0xffffffff`fffffffe

堆栈上几乎没有任何信息。

0:173> dps @rsp
00000000`1248f920  00000000`557c5820 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b5820
00000000`1248f928  00000000`00000008
00000000`1248f930  00000000`0000000e
00000000`1248f938  00000000`00000008
00000000`1248f940  00000000`557c8c18 <Unloaded_LibUtils_CloudNs_3.dll>+0x2b8c18
00000000`1248f948  ffffffff`fffffffe
00000000`1248f950  00000000`00000000
00000000`1248f958  00000000`00000000
00000000`1248f960  00000000`00000000
00000000`1248f968  00000000`00000000
00000000`1248f970  00000000`00000000
00000000`1248f978  00000000`00000000
00000000`1248f980  00000000`00000000
00000000`1248f988  00007ff9`a2117344 kernel32!BaseThreadInitThunk+0x14
00000000`1248f990  00000000`00000000
00000000`1248f998  00000000`00000000

这只是一个完全在 LibDB.CloudNs.3.dll 内部运行的工作线程。它的堆栈并不深,所以我怀疑它正处于空闲状态,正在等待执行任务。

对于这类调查,通常在崩溃线程本身中看不出太多端倪。该线程只是受害者。你必须进行进一步的调查,找出是谁过早地卸载了该 DLL。

经过一番排查,我们发现了另一个涉及这个已卸载 DLL 的堆栈:

0:159> k
RetAddr               Call Site
00007ff9`9fdbbea0     ntdll!ZwWaitForMultipleObjects+0x14
00007ff9`9fdbbd9e     KERNELBASE!WaitForMultipleObjectsEx+0xf0
00000000`554d65fe     KERNELBASE!WaitForMultipleObjects+0xe
00000000`55765820     <Unloaded_LibDB_CloudNs_3.dll>+0x965fe
00000000`00000003     <Unloaded_LibUtils_JsonNs_3.dll>+0x255820
00000000`00000004     0x3
00000000`00000008     0x4
00000000`55768c18     0x8
ffffffff`fffffffe     <Unloaded_LibUtils_CloudNs_3.dll>+0x258c18
00000000`00000000     0xffffffff`fffffffe

最近卸载的 DLL 包括:

00007ff9`6d7c0000 00007ff9`6d80a000   FabrikamContextMenu.dll
00007ff9`115e0000 00007ff9`1172f000   LitWareSync.dll
00007ff9`643d0000 00007ff9`64681000   CcNamespace.dll
00000000`55440000 00000000`5550b000   LibDB_CloudNs_3.dll
00000000`55860000 00000000`55998000   LibNet_CloudNs_3.dll
00000000`557f0000 00000000`5585b000   LibJson_CloudNs_3.dll
00000000`55510000 00000000`557e7000   LibUtils_CloudNs_3.dll
00000000`561a0000 00000000`56238000   MSVCP100.dll
00000000`56240000 00000000`56312000   MSVCR100.dll
00007ff9`85130000 00007ff9`85167000   EhStorShell.dll
00007ff9`3cac0000 00007ff9`3cb61000   wpdshext.dll
00007ff9`78a00000 00007ff9`78a26000   EhStorAPI.dll
00007ff9`686f0000 00007ff9`68754000   PlayToDevice.dll
00007ff9`67110000 00007ff9`6718d000   provsvc.dll

因此,被卸载的 LibDB.CloudNs.3.dll 只是一个由 Lib*.CloudNs.3.dll 动态库组成的完整生态系统的一部分,它们是同时被卸载的。

这一操作的“幕后黑手”似乎是 CcNamespace.dll,它看起来像是 Contoso 命名空间扩展。该扩展会在“我的电脑/此电脑”下添加一个“Contoso”节点,让你能够查看存储在 Contoso 云服务中的所有 Contoso 内容。所有其他的 DLL 都是主 DLL(即 CcNamespace.dll)用来完成其任务的辅助模块。

Explorer 将主 DLL(即 CcNamespace.dll)作为外壳扩展加载,当 CcNamespace.dll 中的对象没有活动引用时,它的 DllCanUnloadNow 函数会返回 S_OK。不幸的是,当它表示“当然,卸载我是安全的”时,这个关键 DLL 卸载了它所有的附属模块,却没有意识到其中一个附属模块(实用工具库)已经启动了一些工作线程。

你可能会认为,解决方法是在后台线程仍然繁忙时,更新实用工具库的 DllCanUnloadNow 使其返回 S_FALSE。¹ 但这行不通,因为实用工具库本身可能就不是一个 COM DLL。它只是 CcNamespace.dll 使用的一个传统 DLL,而 CcNamespace.dll 才是那个 COM DLL。

CcNamespace.dll 中的 DllCanUnloadNow 可以警告 LibUtils.CloudNs.3.dll 让它开始收尾工作,但你基本上陷入了一个棘手的境地,因为 DLL_PROCESS_ATTACH 无法等待工作线程退出。

我认为可行的方案是,工作线程在启动其工作线程时递增 DLL 的引用计数,并使用 FreeLibraryAndExitThread 来退出工作线程。或者,它可以将工作线程设为线程池线程,并使用 FreeLibraryWhenCallbackReturns 请求系统在线程完成工作时递减 DLL 的引用计数。

这可能本来就是实用工具库应该做的事情。我怀疑实用工具库的客户端甚至根本不知道工作线程的存在。它只是实用工具库的一个实现细节,是在主 DLL 不知情的情况下创建的。

幸运的是,应用程序兼容性团队的库中有一份 Contoso Cloud 的副本,因此即使我们无法重现该崩溃,我们仍然能够确认 CcNamespace.dll 确实是那个外壳扩展 DLL,它的卸载触发了所有依赖 DLL 的卸载。

我们正准备联系 Contoso,告知我们的结论和改进建议,却发现这毫无意义,因为 Contoso 早在几年前就已弃用了那个命名空间扩展。他们换用了另一种方式将其云内容集成到 Windows 中;目前仍在使用该命名空间扩展的,只有那些还在使用旧版本的用户,这要么是因为他们不想付费升级,要么是因为他们喜欢旧版本的操作方式而刻意逃避升级。

这些客户使用的都是已经停止支持的产品。Contoso 不再关心这些老客户了。Windows 只能在没有 Contoso 帮助的情况下自行修复这个问题。

Explorer 团队为 Contoso Cloud 命名空间扩展添加了一个应用程序兼容性标志,规定“加载此 shell 扩展时,请带上 GET_MODULE_HANDLE_EX_FLAG_PIN 标志执行 GetModuleHandleEx,使该 DLL 永远不会被卸载”。这样一来,即使 DLL 说“好的,尽管卸载我吧,绝对安全,相信我”,并且 COM 执行了 FreeLibrary,该 DLL 实际上也不会被卸载。

¹ 即使你设法让 DllCanUnloadNow 返回 S_FALSE,在 COM 正在被取消初始化时也无济于事。在这种情况下,CoUninitialize 会询问 DLL 现在是否可以卸载,但结局早已注定:如果 COM 正在关闭,它就会卸载其加载的所有 DLL。它询问你是否同意,并不是因为它在乎你的回答,而是为了给你一个在 DllMain 之外执行清理操作的机会。

作者

Raymond 参与 Windows 的演进已超过 30 年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他的想象,这种发展态势至今仍让他感到心有余悸。这个网站衍生出了一本书,巧的是书名也叫《The Old New Thing》(Addison Wesley 2007 年出版)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲述一些毫无实用信息的故事。

需要完整排版与评论请前往来源站点阅读。