Raymond Chen 2007年04月25日
识别底层 DLL 已卸载的对象
简要
本文通过实例教学如何诊断程序崩溃问题,特别是当涉及到动态链接库(DLL)被卸载时。作者利用调试器识别出虚方法调用、vtable位置、以及DLL卸载的迹象。通过将模块作为转储文件加载,计算vtable地址的偏移,并最终确认了崩溃的原因是由于DLL被错误地卸载,而程序仍尝试访问其资源。
正文
好吧,我在标题里已经透露了答案,但请继续阅读。
你的程序运行着,然后突然像这样崩溃了:
eax=06bad8e8 ebx=00000000 ecx=1e1cfdf0 edx=00000000 esi=06b9a680 edi=01812950
eip=1180ab57 esp=001178b4 ebp=001178c0 iopl=0 nv up ei pl nz na pe nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010206
ABC!FunctionX+0x1f:
1180ab57 ff5108 call dword ptr [ecx+8] ds:0023:1e1cfdf8=????????
0:000>>
你可能会迅速察觉到几个关键点::
- 这是一个通过寄存器间接调用的虚方法调用。 (我们对此有极高的把握。)
- 虚函数表(vtable)存储在
ecx
寄存器中。 (这是间接调用的基础寄存器,我们对此也非常有信心。) - 这个对象的底层DLL已经被卸载了。 (vtable所在的内存区域不再有效,而且地址与曾经是有效代码的区域相符,我们对此有较高的把握。)
- 这很可能是一个
IUnknown::Release
的调用。 (因为在x86架构中,Release
是IUnknown
接口的第三个函数,位于vtable的第8个字节位置,我们对此有很高的信心。)
当然,所有的这些“即时判断”都只是基于经验的猜测,但生活中充满了这样的猜测。(比如每天早晨,我都会猜测我的盘子还在橱柜里。)
接下来,我们假设对象位于一个已经被卸载的DLL中,并寻找证据来支持这一点。
0:000> lm
start end module name
...
Unloaded modules:
10340000 10348000 DEF.DLL
1e1c0000 1e781000 GHI.DLL
25a90000 25a96000 JKL.DLL
0:000>
我们注意到,假设的vtable地址正好处于GHI.DLL
之前的加载地址空间内。
为了查看那个地址之前加载了什么,我们采用了Doron的一个技巧:将模块作为一个转储文件加载。这样,我们可以在其中进行探索,就像它被加载了一样。
C:\Program Files\ABC> ntsd -z GHI.DLL
Microsoft (R) Windows Debugger
Copyright (c) Microsoft Corporation. All rights reserved.
Loading Dump File [C:\Program Files\ABC\GHI.DLL]
...
ModLoad: 15800000 15dc1000 C:\Program Files\ABC\GHI.DLL
eax=00000000 ebx=00000000 ecx=00000000 edx=00000000 esi=00000000 edi=00000000
eip=15807366 esp=00000000 ebp=00000000 iopl=0 nv up di pl nz na pe nc
cs=0000 ss=0000 ds=0000 es=0000 fs=0000 gs=0000 efl=00000000
GHI!_DllMainCRTStartup:
15807366 8bff mov edi,edi
0:000>
加载模块的通知告诉我们DLL被加载到了哪个地址;在我们的例子中,它被加载到了0x15800000。
这个地址和程序崩溃时的地址不同,因此我们需要做一些计算来调整这个差异。
回顾原始的寄存器转储,我们假设的vtable在ecx=1e1cfdf0
,相对于加载地址1e1c0000
。由于我们作为转储文件加载的DLL被加载在0x1580000
,我们需要将地址调整为相对于新位置。
// working with the second copy of ntsd
0:000> ln 0x1580fdf0
(1580fdf0) GHI!CAlphaStream::`vftable'
通过一些简单的计算,我们得到了0x1580fdf0
这个地址。首先,我们从原始的vtable地址中减去DLL的加载地址:
0x1e1cfdf0
-0x1e1c0000
0x0000fdf0
这是崩溃时vtable相对于DLL加载地址的偏移量。然后,我们将这个偏移量加到作为转储文件加载的DLL的加载地址上:
0x15800000
+0x0000fdf0
0x1580fdf0
这是 DLL-loaded-as-a-dump-file(DLL 加载即转储文件)中 vtable 的地址,相对于 DLL-loaded-as-a-dump-file(DLL 加载即转储文件)中 DLL 的加载地址。正如你所看到的,计算其实并不难,因为很多东西都可以抵消。这种情况经常发生。
当我们要求调试器告诉我们哪个符号离该地址最近时,我们中了大奖:它正是 CAlphaStream 对象的 vtable。这证实了我们最初的推测。我们甚至可以通过查看vtable的内容来验证IUnknown::Release
的调用:
0:000> dds 1580fdf0
1580fdf0 159234b3 GHI!CAlphaStream::QueryInterface
1580fdf4 15810539 GHI!CBetaState::AddRef
1580fdf8 15923cfc GHI!CAlphaStream::Release
1580fdfc 15923d30 GHI!CAlphaStream::Read
...
没错,这就是一个 CAlphaStream vtable。
由于我对 GHI.DLL 文件不太熟悉,所以让我们问问调试器源代码在哪里,以便仔细查看:
0:000> .lines
Line number information will be loaded
0:000> dds 1580fdf0
1580fdf0 159234b3 GHI!CAlphaStream::QueryInterface
[c:\dev\fabricam\synergy\proactive\winwin.cpp @ 2624]
1580fdf4 15810539 GHI!CBetaState::AddRef
[c:\dev\fabricam\leverage\paradigm\initiative.cpp @ 427]
1580fdf8 15923cfc GHI!CAlphaStream::Release
[c:\dev\fabricam\synergy\proactive\winwin.cpp @ 2638]
1580fdfc 15923d30 GHI!CAlphaStream::Read
[c:\dev\fabricam\synergy\proactive\winwin.cpp @ 2649]
既然我们知道了 CAlphaStream 的源代码在哪里,我们就可以跳过去快速浏览一下,确认一下,哦,看,这个对象在构造时并没有增加 DLL 对象计数(或在销毁时减少 DLL 对象计数)。因此,当 COM 调用 DllCanUnloadNow 时,GHI.DLL 会说:"当然,请便!"即使 ABC 仍有对该 DLL 的引用,该 DLL 还是被卸载了,然后当 ABC 去释放该引用时,我们就会崩溃,因为 GHI 已经消失了。
写完这篇文章后,发现Tony Schreiner也经历了类似的挑战,他处理的是一个第三方的Internet Explorer工具栏,而且他没有插件的源代码,这给他带来了额外的挑战!