线程从未卸载的第三方 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 账号上,讲述一些毫无实用信息的故事。
需要完整排版与评论请前往来源站点阅读。