返回 2026-06-26
⚙️ 工程

一个未被正式卸载却不在内存中的 DLL 悬案(第一部分)The case of the DLL that was not present in memory despite not being formally unloaded, part 1

深入排查了一个 DLL 文件虽然从未被系统正式卸载,却意外从内存中消失的底层技术问题。文章作为系列的第一部分,重点剖析了 Windows 操作系统内部加载器的工作机制以及追踪内存状态变化的方法。通过真实的调试案例,揭示了复杂应用环境下 DLL 生命周期管理的隐蔽陷阱。此类内存幽灵问题往往需要结合调用栈和底层系统钩子才能准确定位。

Raymond Chen

负责 shell32.dll 的团队收到了一个 bug 报告,称他们导致了一个特定第三方程序的大量崩溃。打开崩溃转储文件,显示了明显的堆栈溢出现象:

 # Child-SP          RetAddr           Call Site
00 000000ba`92851098 00007ff9`fed521c1 ntdll!_chkstk+0x37
01 000000ba`928510b0 00007ff9`feea5ace ntdll!RtlDispatchException+0x2d1
02 000000ba`92851300 00007ff9`fed4e02d ntdll!KiUserExceptionDispatch+0x2e
03 000000ba`92852060 00007ff9`fed5222f ntdll!RtlLookupFunctionEntry+0x8d
04 000000ba`928520b0 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f
05 000000ba`92852800 00007ff9`fed4e02d ntdll!KiUserExceptionDispatch+0x2e
06 000000ba`92853560 00007ff9`fed5222f ntdll!RtlLookupFunctionEntry+0x8d
07 000000ba`928535b0 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f
08 000000ba`92853d00 00007ff9`fed4e02d ntdll!KiUserExceptionDispatch+0x2e
09 000000ba`92854a60 00007ff9`fed5222f ntdll!RtlLookupFunctionEntry+0x8d 
0a 000000ba`92854ab0 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f  
0b 000000ba`92855200 00007ff9`fed51f29 ntdll!KiUserExceptionDispatch+0x2e
0c 000000ba`92855f70 00007ff9`feea5ace ntdll!RtlLookupFunctionEntry+0x8d
0d 000000ba`928561c0 00007ff9`fed4e02d ntdll!RtlDispatchException+0x33f
...

高亮的堆栈帧块(从 Rtl­Lookup­Function­Entry 到 Ki­User­Exception­Dispatch)重复了很长一段。

我们显然陷入了某种递归异常处理的死亡螺旋中。发生了一个异常,内核判定这不是内核模式能够处理的,¹ 于是它将异常反射回用户模式以进行进一步处理(Ki­User­Exception­Dispatch)。在试图找出要调用哪个异常处理程序(Rtl­Lookup­Function­Entry)时,我们又遇到了一个异常,从而重新开始了异常循环。

最终,所有这些递归异常耗尽了堆栈,我们遇到了导致进程终止的堆栈溢出异常。

这个 bug 被分配给了 shell32,因为看起来 shell32 是最初异常的源头。如果你一路回溯到堆栈的底部,你会看到类似这样的内容:

23f 000000ba`9294c620 00007ff9`fed5222f ntdll!RtlLookupFunctionEntry+0x8d
240 000000ba`9294c670 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f
241 000000ba`9294cdc0 00007ff9`fed4e02d ntdll!KiUserExceptionDispatch+0x2e
242 000000ba`9294db20 00007ff9`fed5222f ntdll!RtlLookupFunctionEntry+0x8d
243 000000ba`9294db70 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f
244 000000ba`9294e2c0 00007ff9`fcba0af0 ntdll!KiUserExceptionDispatch+0x2e
245 000000ba`9294f018 00007ff9`fde2ad13 combase!CoTaskMemFree
246 000000ba`9294f020 00007ff9`fc7abc75 shell32!wil::details::string_maker::~string_maker+0x13
247 000000ba`9294f050 00007ff9`fc7ab897 ucrtbase!<lambda_f03950bc5685219e0bcd2087efbe011e>::operator()+0xa5
248 000000ba`9294f0a0 00007ff9`fc7ab84d ucrtbase!__crt_seh_guarded_call<int>::operator()+0x3b
249 000000ba`9294f0d0 00007ff9`fc7d2f0c ucrtbase!execute_onexit_table+0x3d
24a 000000ba`9294f110 00007ff9`fdff4645 ucrtbase!__crt_state_management::wrapped_invoke+0x2c
24b 000000ba`9294f140 00007ff9`fdff476e shell32!dllmain_crt_process_detach+0x45
24c 000000ba`9294f180 00007ff9`fee9f6fe shell32!dllmain_dispatch+0xe6
24d 000000ba`9294f1e0 00007ff9`fed4bcae ntdll!LdrpCallInitRoutineInternal+0x22
24e 000000ba`9294f210 00007ff9`fedcd37f ntdll!LdrpCallInitRoutine+0x10e
24f 000000ba`9294f280 00007ff9`fedcc54e ntdll!LdrShutdownProcess+0x17f
250 000000ba`9294f390 00007ff9`fdcb18ab ntdll!RtlExitUserProcess+0x9e
251 000000ba`9294f3c0 00007ff9`e754882e kernel32!ExitProcessImplementation+0xb
252 000000ba`9294f3f0 00007ff9`e754f344 mscoreei!RuntimeDesc::ShutdownAllActiveRuntimes+0x2fa
253 000000ba`9294f6d0 00007ff9`e66f464b mscoreei!CLRRuntimeHostInternalImpl::ShutdownAllRuntimesThenExit+0x14
254 000000ba`9294f700 00007ff9`e66f44c9 clr!EEPolicy::ExitProcessViaShim+0x8b
255 000000ba`9294f760 00007ff9`e66f441e clr!SafeExitProcess+0x9d
256 000000ba`9294f9e0 00007ff9`e66f3f44 clr!HandleExitProcessHelper+0x3e
257 000000ba`9294fa10 00007ff9`e66f3e24 clr!_CorExeMainInternal+0xf8
258 000000ba`9294faa0 00007ff9`e753d6da clr!CorExeMain+0x14
259 000000ba`9294fae0 00007ff9`e75d785b mscoreei!CorExeMain+0xfa
25a 000000ba`9294fb40 00007ff9`fdc9e8d7 mscoree!CorExeMain_Exported+0xb
25b 000000ba`9294fb70 00007ff9`fedcc40c kernel32!BaseThreadInitThunk+0x17
25c 000000ba`9294fba0 00000000`00000000 ntdll!RtlUserThreadStart+0x2c

重复的块停在第一个异常的源头:combase!Co­Task­Mem­Free。

我们可以查找异常记录,看看最初的问题是什么。

异常记录和上下文记录可能被传递给了 Rtl­Dispatch­Exception,所以我们可以看看 Ki­User­Exception­Dispatch 传递了什么。

  # Child-SP          RetAddr           Call Site
243 000000ba`9294db70 00007ff9`feea5ace ntdll!RtlDispatchException+0x33f
244 000000ba`9294e2c0 00007ff9`fcba0af0 ntdll!KiUserExceptionDispatch+0x2e

0:000> u ntdll!KiUserExceptionDispatch 00007ff9`feea5ace 
ntdll!KiUserExceptionDispatch:
00007ff9`feea5aa0 cld
00007ff9`feea5aa1 mov     rax,qword ptr [ntdll!Wow64PrepareForException (00007ff9`fef272f0)]
00007ff9`feea5aa8 test    rax,rax
00007ff9`feea5aab je      ntdll!KiUserExceptionDispatch+0x1c (00007ff9`feea5abc)
00007ff9`feea5aad mov     rcx,rsp
00007ff9`feea5ab0 add     rcx,4F0h
00007ff9`feea5ab7 mov     rdx,rsp
00007ff9`feea5aba call    rax
00007ff9`feea5abc mov     rcx,rsp 
00007ff9`feea5abf add     rcx,4F0h
00007ff9`feea5ac6 mov     rdx,rsp 
00007ff9`feea5ac9 call    ntdll!RtlDispatchException (00007ff9`fed51ef0)
00007ff9`feea5ace test    al,al

我们看到传递给 Rtl­Dispatch­Exception 的两个参数分别位于 rsp+4f0h 和 rsp。我猜测异常记录在前面,然后是上下文记录,因为这些指针在 EXCEPTION_POINTERS 中出现的顺序就是这样的。

  # Child-SP          RetAddr           Call Site
244 000000ba`9294e2c0 00007ff9`fcba0af0 ntdll!KiUserExceptionDispatch+0x2e

00007ff9`feea5ace test    al,al
0:000> dps 000000ba`9294e2c0+4f0
000000ba`9294e7b0  00000000`c0000005 ← STATUS_ACCESS_VIOLATION
000000ba`9294e7b8  00000000`00000000
000000ba`9294e7c0  00007ff9`fcba0af0 combase!CoTaskMemFree
000000ba`9294e7c8  00000000`00000002
000000ba`9294e7d0  00000000`00000008

是的,这看起来像是一个异常记录。它以异常代码开头,紧接着就是发生异常的代码地址。

0:000> .exr 000000ba`9294e2c0+4f0
ExceptionAddress: 00007ff9fcba0af0 (combase!CoTaskMemFree)
   ExceptionCode: c0000005 (Access violation)
  ExceptionFlags: 00000000
NumberParameters: 2
   Parameter[0]: 0000000000000008
   Parameter[1]: 00007ff9fcba0af0
Attempt to execute non-executable address 00007ff9fcba0af0

好的,所以我们试图执行一个不可执行的地址,而这个地址是 combase!Co­Task­Mem­Free。

为了好玩,让我们确认一下第二个参数确实是一个上下文记录:

0:000> .cxr 000000ba`9294e2c0
rax=00007ff9fe3a9850 rbx=000001bbebd12388 rcx=000001bbebd63140
rdx=00007ff9fe4e99e0 rsi=000001bbebd12828 rdi=000001bbebd12310
rip=00007ff9fcba0af0 rsp=000000ba9294f018 rbp=0000df1c60b20569
 r8=000001bbebd12310  r9=0000df1c60b20569 r10=d94b3944a87271f0
r11=000000000000000b r12=0000000000000001 r13=00007ff9fdff47c0
r14=000000ba9294f128 r15=000001bbebd12310
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010202
combase!CoTaskMemFree:
00007ff9`fcba0af0 sub     rsp,28h

是的,看起来像是一个上下文记录。

但是等等,异常声称 combase!Co­Task­Mem­Free 是不可执行的。首先,让我们看看调试器是否同意这个评估。

0:000> !address  00007ff9`fcba0af0

Usage:                  Image
Base Address:           00007ff9`fcb20000
End Address:            00007ff9`fcea6000
Region Size:            00000000`00386000 (   3.523 MB)
State:                  00010000          MEM_FREE
Protect:                00000001          PAGE_NOACCESS
Type:                   <info not present at the target>
Image Path:             C:\Windows\System32\combase.dll
Module Name:            combase
Loaded Image Name:      combase.dll
Mapped Image Name:      C:\symbols\combase.dll
More info:              lmv m combase
More info:              !lmi combase
More info:              ln 0x7ff9fcba0af0
More info:              !dh 0x7ff9fcb20000

Content source: 2 (mapped), length: 1eb510

包含 Co­Task­Mem­Free 函数的内存已经被释放了!

事实上,如果你查看基地址和区域大小,你会发现整个 combase.dll 都已经从内存中卸载了。

另一方面,如果你询问加载器(loader)对该地址的看法,它会说:“哦,那是 combase.dll 内部的代码。”

0:000> !dlls -c 00007ff9`fcba0af0

0x1bbeb111020: C:\WINDOWS\System32\combase.dll
      Base   0x7ff9fcb20000  EntryPoint  0x7ff9fcc9a9d0  Size        0x00386000    DdagNode     0x1bbeb114380
      Flags  0x0028a2cc  TlsIndex    0x00000000  LoadCount   0xffffffff    NodeRefCount 0x00000000
             <unknown>
             LDRP_LOAD_NOTIFICATIONS_SENT
             LDRP_IMAGE_DLL
             LDRP_PROCESS_ATTACH_CALLED

好了,既然我们已经收集了证据,让我们看看能得出什么理论。

combase.dll 仍然在加载器的记录中,并且我们看到它的加载计数为 0xFFFFFFFF,这意味着该 DLL 已被“锁定(pinned)”,也就是说加载器永远不会卸载它。这两条信息表明,该 DLL 并不是通过 Free­Library 从内存中移除的,而是被某人显式释放了,比如对该内存执行了 Virtual­Free。

我的猜测是,某处的内存损坏 bug 导致某些代码清理了错误的内存块,从而在不知情的情况下释放了 combase.dll 占用的内存,比如可能有人用 combase.dll 的地址覆盖了它的“别忘了释放它”变量,或者是因为存在未初始化变量的 bug,而该未初始化的值恰好是 combase.dll 基地址的残留副本。

但无论如何,问题都不在 shell32。shell32 只是又一个受害者,在某个未知组件将 combase 强制从内存中移除后,它成了第一个调用 combase 的 DLL。

如果这个理论成立,那么我应该能找到类似类型的崩溃,即某个其他 DLL 成为某个 DLL 被强制从内存中移除的受害者。

我索要了该第三方程序最近发生的 100 次崩溃记录,并将它们放入数据透视表中,以便查看其分布情况。

shell32 的 bug 排在倒数第二,占崩溃总数的 11%。但还有另外 13 个栈溢出 bug。此外,还有一堆归为“unknown”的访问冲突。

我抽查了那些栈溢出和“unknown 访问冲突”的崩溃,发现它们的形式与 shell32 bug 完全相同,只是涉及的 DLL 不同:在发送 DLL_PROCESS_DETACH 通知时,发现某个 DLL 已被强制从内存中移除,而接下来调用这个被强制卸载的 DLL 的那个 DLL 就会背锅,尽管它其实是受害者。(其中有一大堆表现为“unknown 访问冲突”,是因为系统发现崩溃发生在异常分发代码内部,并且由于某种原因无法一路回溯堆栈直到递归崩溃循环的起点。)

因此,总共有 46% 的崩溃是由这种流氓式的强制卸载 DLL 引起的。这是一个典型的“桶喷射”(bucket spray)案例,即单一的根本原因导致了大量不同类型的崩溃。

对 shell32 团队来说,好消息是他们摆脱了干系;他们只是受害者。坏消息是我们不知道罪魁祸首是谁。

下次,我们将进一步了解这些崩溃,这将有助于确认关于这个特定崩溃的一些理论,甚至可能会推翻其他理论。

¹ 内核模式可以处理的情况包括保护页异常(通过扩展栈)或换出内存中的页错误(通过将其换回)。

作者

Raymond 参与了 Windows 的演进超过 30 年。2003 年,他创办了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他最疯狂的想象,这一发展至今仍让他感到心有余悸。这个网站衍生出了一本书,巧合的是书名也叫 The Old New Thing(Addison Wesley 2007 年出版)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上讲讲故事,不过这些故事并没有传达什么有用的信息。

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