定制调试诊断工具和实用程序——摆脱DLL“地狱”(DLL Hell)的困扰(六)

2016-06-08

本文假设你熟悉 Win32,DLL

定制调试诊断工具和实用程序――摆脱DLL"地狱"(DLL Hell)的困扰(一)

定制调试诊断工具和实用程序――摆脱DLL"地狱"(DLL Hell)的困扰(二)

定制调试诊断工具和实用程序――摆脱DLL"地狱"(DLL Hell)的困扰(三)

定制调试诊断工具和实用程序――摆脱DLL"地狱"(DLL Hell)的困扰(四)

定制调试诊断工具和实用程序――摆脱DLL"地狱"(DLL Hell)的困扰(五)

摘要

本文前面讨论了用几种不同的方法来获取进程及其相关 DLLs 的信息,例如通过 PSAPI、NTDLL 以及TOOLHELP32 库提供的 APIs,在这一部分,作者给出了几种获得系统级信息的非常规方法,你可以轻松将它们集成到自己的工具包中。本文范例包含三个实用工具:

LoadLibrarySpy,监视并扫描应用程序加载了哪些 DLLs;

WindowDump,获取任何窗口的的内容以及窗口的详细描述信息;

FileUsage,重定向控制台程序,揭示哪个进程正在使用打开的文件;

本文前面的部分讨论了如何用有着良好文档描述的 API 函数来获取运行进程列表以及它们加载的 DLLs 信息。接下来我将用不同的方法,或者说是非正式的方法来获取系统级信息,首先,我将深入分析 Win32 调试 API 以及 Windows 加载器(Windows Loader)提供的痕迹来揭示给定进程是如何加载 DLL 的。我将借助我的 CApplicationDebugger 可重用类,用几种不同的方法来分析 DLL 重定位的原因。

接着,我将生成两个工具。LoadLibrarySpy 扫描 DLL 重定位。WindowDump 窃取任何窗口的内容和详细描述信息。最后,在讨论进程环境块(PEB)内部结构之前,我会向你展示如何操纵控制台程序产生的输出以便摸索寻找一些未公开的信息。

回到 DLL Hell

前面我们已经看到获取所有静态或动态加载的 DLLs 列表是很容易的事情。但是对动态加载的DLL而言,情况比想象的稍微复杂一些。例如,DllSpy 和 ProcessSpy 两个工具依据某个时间点获得的快照。因此,有可能出现来不及扫描某个被快速加载和卸载的DLL。Win32 调试 API 提供了对这个问题的解决办法:在调试程序时, 这些 API 可以对被调试程序加载和卸载的任何DLL了如指掌。

要实现我的意图,并不需要一个功能完整,名副其实的调试器,但我必须侦测到新 DLL 何时被加载到进程地址空间。因此,我将讨论 Win32 调试 API 的基本知识以及它们在 Windows NT、Windows 2000 和 Windows XP 操作系统中有用的扩展。

为了调试一个程序,你首先必须使用用下面这些特殊的标志之一调用 CreateProcess 来启动拟调试的程序。DEBUG_PROCESS 表示请求来自被调试程序以及被调试程序启动的每一个进程的事件。DEBUG_ONLY_THIS_PROCESS 表示只请求来自被调试程序的事件(而不是来自其子进程的事件)。

使用 DEBUG_ONLY_THIS_PROCESS 标志时,调试器将接收不到来自被调试程序启动的进程事件。性能监视器(perfmon.exe)就是一个很好的例子,此标志对这个程序没有作用。性能监视器是一个简单的打包程序,其作用 只不过是启动另外一个程序――微软管理控制台(MMC),并传递任何所需的参数使它显示性能计数器。

在被调试程序的生命期内,Windows 通知调试器 Figure 1 所列出的事件。这些事件由 DEBUG_EVENT 结构描述,如 Figure 2 所示。

Figure 1 Events Received by the Debugger

Event Value
Description
CREATE_PROCESS_DEBUG_EVENT
This is the first event received by the debugger, even before LOAD_DLL_ DEBUG_EVENT for statically linked DLLs.
EXIT_PROCESS_DEBUG_EVENT
This is the last event received by the debugger. It means the debuggee has reached the end of its life.
EXCEPTION_DEBUG_EVENT
An exception occurs. Its description is in u.Exception. It is received before any catch when the dw- FirstChance flag is set. If there is no catch, a second event is received before the debuggee is terminated.
CREATE_THREAD_DEBUG_EVENT
A new thread is created. Its description is in u.CreateThread.
EXIT_THREAD_DEBUG_EVENT
The description of an exiting thread is set in the u.ExitThread member.
LOAD_DLL_DEBUG_EVENT
When a DLL is mapped in the debuggee address space, either statically linked or dynamically loaded, this event is received by the debugger.
UNLOAD_DLL_DEBUG_EVENT
Unlike the previous event, this occurs only when a DLL is dynamically unloaded. This means it cannot be used to detect when each statically loaded DLL is unloaded at the end of the process life.
OUTPUT_DEBUG_STRING_EVENT
Each time the debuggee calls OutputDebugString, the debugger receives this event with the string in u.DebugString.lpDebug.StringData, but in the debuggee address space.
RIP_EVENT
According to the documentation, this event is received when a RIP-de-bugging event (system debugging error) occurs, but I have never seen this in practice.
				Figure 2 DEBUG_EVENT 

typedef struct _DEBUG_EVENT { 
   DWORD dwDebugEventCode; 
   DWORD dwProcessId; 
   DWORD dwThreadId; 
   union 
   { 
      EXCEPTION_DEBUG_INFO      Exception; 
      CREATE_THREAD_DEBUG_INFO  CreateThread; 
      CREATE_PROCESS_DEBUG_INFO CreateProcessInfo; 
      EXIT_THREAD_DEBUG_INFO    ExitThread; 
      EXIT_PROCESS_DEBUG_INFO   ExitProcess; 
      LOAD_DLL_DEBUG_INFO       LoadDll; 
      UNLOAD_DLL_DEBUG_INFO     UnloadDll; 
      OUTPUT_DEBUG_STRING_INFO  DebugString; 
      RIP_INFO                  RipInfo; 
   } u; 
} DEBUG_EVENT, *LPDEBUG_EVENT;

为了接收这些事件,调试器必须调用 WaitForDebugEvent。该函数阻塞调试器的运行,直到被调试程序发生 Figure 1 所列的事件之一,或者超时参数中给定的秒数为止。当调试器处理某个事件时,它调用 ContinueDebugEvent 让被调试程序继续其生命之旅。注意:在调试器中,当 WaitForDebugEvent 解除阻塞时,所有被调试者线程被冻结,在调用 ContinueDebugEvent 期间被解冻。参见 Figure 3:

Figure 3 调试事件流

CApplicationDebugger

调用 CreateProcess 的线程必须是进入调试循环的线程。既然调试器阻塞于 WaitForDebugEvent,因此最好让这部分代码运行在一个与主UI线程不同的专门线程中。本文将其行为包装在 CApplicationDebugger 类中,其声明参见本文附带源代码中的 ApplicationDebugger.h 文件,这个类的一部分灵感还来自 Matt Pietrek 的 LoadProf32(参见 MSJJul95.exe)。

CApplicationDebugger 是一个虚拟类,因为你得从它派生并实现自己的重写版本,以便特定的调试事件发生时进行相应的调用。这个类被用于生成 LoadLibrarySpy(参见 Figure 4),这是一个调试程序和监控 DLL 加载和卸载的工具,不论是静态加载还是动态加载,也不论是不是有加载地址冲突,它都能监控。

Figure 4 LoadLibrarySpy

调用 CreateProcess 是在 CApplicationDebugger::LoadTheProcess 中进行的,为简单起见,参数使用 DEBUG_ONLY_THIS_PROCESS。如果需要,你可以将 CApplicationDebugger 扩展成能处理来自多个被调试进程的事件,对于 MMC 管理单元(snap-ins)很有用。

CLoadLibrarySpyDlg 类负责对话框自身的处理,同时也是暗中监视 CApplicationDebugger 派生类的线程宿主。CModuleListCtrl 类负责显示附属到每个DLL的详细信息 CModuleInfo*;针对每个 DLL,这个类存储的详细信息见 Figure 5。

Figure 5 DLL Details

Type
Member
Description
CString
m_szName
Module name
DWORD
m_LoadAddress
hModule
DWORD
m_PreferedLoadAddress
Supposed loading address (at link time)
CString
m_szReason
Gets real info
BOOL
m_bDynamic
TRUE if loaded through LoadLibrary
BOOL
m_bAfterStartup
TRUE if loaded after the process starts
DWORD
m_nLoaded
Number of times it has been loaded
DWORD
m_nRemoved
Number of times it has been unloaded
CString
m_szFullPath
Full path name of the DLL
DWORD
m_Position
Loading position, starting from 1

当某个 DLL 被加载,对话框便调用 AddModule 方法;反之卸载DLL时,则执行 RemoveModule 方法。这两个方法都以 UpdateModule 方法告终,从而更新与该 DLL 对应的 CModuleObject 对象的 m_nLoaded 或 m_nRemoved。如果不存在这样的对象,则会创建一个新的对象,并将它添加到列表框中。

不要为 m_nLoaded 或 m_nRemoved 而困惑。如果你针对某一行的相同 DLL 多次调用 LoadLibrary,调试器只会收到 LOAD_DLL_DEBUG_EVENT 一次,并且 m_nLoaded 被赋值为 1。如果调试器接收到某个 DLL 的 UNLOAD_DLL_DEBUG_EVENT,你便可以确定该 DLL 不再被该进程使用。因此,对于静态 DLLs 而言,你决不会收到此事件,即使可能在进程被启动后,它们被动态加载并用 LoadLibrary/FreeLibrary 卸载。

处理被调试程序的事件

一旦被调试程序的进程启动后,调试器便等待某些事件的发生。这就是为什么它应该在一个与主 UI 线程不同的单独线程中的原因,当主窗口是一个模式对话框时尤其如此!

为了在 CLoadLibrarySpyDlg 中有效地使用 CApplicationDebugger,GoThreadProc 线程过程首先声明一个 CApplicationDebugger 对象,指定要执行的命令行并说明是否截获来自被调试程序的 OutputDebugString 或 TRACE 输出。接着,DebugProcess 阻塞,直到被调试程序终止(接收 EXIT_PROCESS_DEBUG_EVENT 或第二次的未处理异常),或者重写的方法之一未返回 DBG_CONTINUE。

线程与对话框之间的沟通机制很简单:当某个被调试事件发生时,调试器线程将 Figure 6 中所列的消息发送到对话框。其中第一个消息是在加载了所有静态链接的 DLLs 时发送;也就是说,当 Windows 触发第一个(伪)断点时,便发信号给调试器,然后调试器调用可重写的 OnProcessRunning 将消息发送给对话框。第二个消息是当被调试程序卸载某个 DLL 时,由可重写的 OnUnloadDLLDebugEvent 调试事件处理例程发送

Figure 6 Debugger Thread Messages

ID
wParam
lParam
Description
UM_INITPROCESS
0
0
The statically linked DLLs have all been loaded. The icon changes from static to dynamic after this event.
UM_FREELIBRARY
0
CModuleInfo*
The DLL has been unloaded. The corresponding line is updated in CModuleListCtrl.
UM_LOADLIBRARY
0
CModuleInfo*
The DLL has been loaded. The corresponding line is added or updated in CModuleListCtrl.

第三个消息需要所解释几句,为了创建 CModuleInfo,需要 DLL 的全路径名。而在本文第一部分中,我们没有提供任何方法直接从其 hModule 或加载地址获取 DLL 文件名。即便是当调试器接收到此事件时(因为它可能浏览到了它的 PE 头),DLL已经被映射到被调试程序的地址空间,这时,Windows 还没有初始化 PSAPI 所需的数据结构。

事实上,LoadDll.lpImageName 域是一个 LOAD_DLL_DEBUG_INFO 结构成员,LOAD_DLL_DEBUG_INFO 来自 DEBUG_EVENT 结构中的联合 u(参见 Figure 2),LoadDll.lpImageName 总是指向被调试程序地址空间中一块具备读/写/执行权限的奇怪的内存区域,LOAD_DLL_DEBUG_INFO 结构定义如下:

typedef struct _LOAD_DLL_DEBUG_INFO { 
  HANDLE hFile; 
  LPVOID lpBaseOfDll; 
  DWORD  dwDebugInfoFileOffset; 
  DWORD  nDebugInfoSize; 
  LPVOID lpImageName; 
  WORD fUnicode; 
} LOAD_DLL_DEBUG_INFO, *LPLOAD_DLL_DEBUG_INFO; 		

被加载的DLL的路径名就包含在此内存块中。MSDN 在线帮助文档是这样描述 IpImageName 的:

“...与 hFile 关联的文件名指针。该成员可能为 NULL,也可能包含被调试进程地址空间中的串指针地址。这个地址可能为 NULL 或者指向实际的文件名。

如果 fUnicode 是一个非零值,则名字串是 Unicode,否则是 ANSI 串。该成员是可选项。调试器必须考虑处理 lpImageName 为 NULL 或 *lpImageName(在被调试进程的地址空间中)为 NULL 的情况。很显然,系统决不会为某个创建进程事件提供映像名,同时它也不可能为第一个 DLL 事件传递映像名。系统也决不会在源于 DebugActiveProcess 函数调用的调试事件中提供这个信息。”

OnLoadDLLDebugEvent 可重写方法将上述解释翻译为在 99% 的情况下可工作的纯 C++ 代码。其余 1% 不工作的情况是指加载 ntdll.dll:这种情况既是文档中所说的第一个 DLL 事件。即使延迟到下一个被调试程序事件发生时(参见 CLoadLibraryDebugger 的 OnDebugEvent)才获取路径名。在文档的描述中,可以调用 SearchPath 从模块名获得全路径名,“system32”对于 ntdll.dll 并不感到惊讶。这个 API 函数使用与 LoadLibrary 同样的算法在文件系统中查找某个 DLL。从理论上讲,因为它是由调试器调用的,有可能返回的文件并不是被调试程序加载的那个文件――例如,在调试器文件夹中存在另外一个版本的 ntdll.dll。在实际应用中,ntdll.dll 得不到打补丁的机会,并且被拷贝到了某个与 system32 不同的目录。

防止泄漏

文档中关于 Win32 调试 API 的另一方面的描述是必须释放不同的 XXX_DEBUG_EVENT 结构返回的句柄。Matt Pietrek 在其 November 1995 MSJ“Under the Hood”专栏文章中指出:在 XXX_DEBUG_EVENT 结构中返回到调试器的句柄应该被关闭。事实上,几乎每个句柄都必须用 CloseHandle 关闭。只有一个例外,就是存储在 CREATE_THREAD_DEBUG_EVENT 中的线程句柄,它应该在进程终止时由系统来关闭。其它的句柄如果不关闭,便会造成增长速度非常快的系统资源泄漏,有关的句柄如 Figure 7 所示。这类垃圾的收集由 CApplicationDebugger::HandleDebugEvent 自动处理。

Figure 7 XXX_DEBUG_EVENT Handles

Event
Handles to Close
LOAD_DLL_DEBUG_EVENT
u.LoadDll.hFile
CREATE_PROCESS_DEBUG_EVENT
u.CreateProcessInfo.hFile
u.CreateProcessInfo.hProcess
u.CreateProcessInfo.hThread

不论你使用哪种清除方法,每次你调试某个进程时,系统不可避免地要泄漏两个句柄:信号机(semaphore )和端口(port),两者都没有命名。为了让你确信 CApplicationDebugger 不负责处理这种泄漏,请允许我指出:用 sysinternals 的 ProcessExplorer 或 Windows Resource Kit 中的 DH.EXE 可以观察到 Visual Studio 6.0 和 Visual Studio .NET 中同样的泄漏行为。

现在你已经看到了如何用 Win32 调试 API 来获取某个进程执行期间在其地址空间中加载和卸载的 DLLs 确切列表。Windows 本身提供了另外一个途径来获取有关 DLLs 的其它详细信息。

参考资料

The Win32 Debugging Application Programming Interface;

Bugslayer: Windows 2000 and LDR Messages, A COM Symbol Engine, Finding Bloated Functions, and More DEB Sample: Debug Event Browser;

Spawn Console Processes with Redirected Standard Handles;

GetWindowModuleFileName & GetModuleFileName Work Only with the Calling Process

在后续文章中,我将介绍 Windows Loader,它知道一切。

(待续) 

作者简介

Christophe Nasarre 是法国 Business Objects 公司的技术经理(technical manager)。他在 Windows 平台上(3.0 以后的版本)编写了若干个低级工具。他的联系方式:cnasarre@montataire.net.

本文出自 MSDN Magazine 的 August 2002 期刊,可通过当地报摊获得,或者最好是 订阅