在以前的一篇文章中,作者设计了一种简单的方法来检测图形设备接口 (GDI) 对象,这些对象并未由 Windows 9x 平台上基于 Win32 的应用程序正确地进行发布。因为有些更新版本的 Windows 需要一种不太相同的 GDI 泄漏方法,作者已经更新了针对那些操作系统的方法。他构建并说明了两种工具,这两种工具旨在检测并消除在 Windows XP、Windows 2000 和 Windows NT 上运行的应用程序中的 GDI 泄漏。
在 Windows 95、Windows 98 和 Windows Me 中,图形设备接口 (GDI) 句柄是一个 16 位的值,任何应用程序都可以使用它来调用 GDI API 的函数。在 2001 年 3 月一期的 MSDN Magazine 中,我讲述了如何利用这些平台的 16 位特性来构建 GDIUsage,这是一种所有应用程序都可使用的列出、比较并显示 GDI 对象的工具(参见“Resource Leaks:Detecting, Locating, and Repairing Your Leaky GDI Code”)。本文将说明如何编写用于 Windows XP 的同种类型的工具。我这里将要使用的方法同样很好地适用于 Windows 2000 和 Windows NT 4.0,但出于本文的目的,我将使用 Windows XP 来表示所有这三种平台。
图 1 Windows 2000 中 GDI 的使用
本文说明了 Windows 9x 和 Windows XP 平台的不同,提出了在工具的实现过程中产生问题的解决方案,您可以在图 1 中看到该工具。我将解释如何利用代码插入机制来确定某个进程的 GDI 资源消耗情况,以及如何修补进程或 DLL,使其在创建 GDI 对象时得到通知。接下来,我将说明如何编写 Win32 调试器来驱动某一进程,如何让该进程和调试器彼此之间进行通信,以及如何实现调用堆栈管理器来提供有关 GDI 对象资源分配的额外信息。
Windows 9x 与 Windows XP
对于 Windows XP,一系列 GDI 对象均与各个进程相关,大部分由 win32k.sys 按内核模式进行托管,设备驱动程序负责 USER 和 GDI 实现。Win32 应用程序通过由 user32.dll 和 gdi32.dll 提供的 API 调用这些系统服务。因为 Windows 基于每个进程保留了 GDI 对象的记录,所以只有创建了 GDI 对象的应用程序能够使用该对象对应的 GDI 函数。
Windows 9x 版本的 GDIUsage 使用 GetObjectType API 函数,该函数提供给定句柄值的 GDI 对象的类型,以检查某个随机值是否是个有效的 GDI 句柄。然而,与 Windows 9x 不同,Windows XP GDI 对象句柄完全是个 32 位的值。其可能的范围是从 0 到 0xFFFFFFFF,为了列出所有真实的 GDI 对象,各个可能的句柄值都需提供给 GetObjectType。这就造成了实际的性能问题。下列代码需要几分钟来执行,遗憾的是,很多通过运行测试应用程序检测到的 GDI 对象都不是真的(其中有 500 多个!):
DWORD dwObjectType; DWORD hGdi; for (hGdi = 0; hGdi < 0xFFFFFFFF; hGdi++) { dwObjectType = ::GetObjectType((HANDLE)hGdi); if (dwObjectType != 0) { TRACE("0x%08x -> %u\n", hGdi, dwObjectType); } }
这意味着还需要有使用 GetObject 的额外测试代码(可能会很长)来获得一个可靠的列表。循环周期使得该方法不可用。本文提供了两种其他的解决方案。第一种方案使用 GDI 管理的句柄表,而第二种方案通过挂钩来自 GDI API 的函数进行工作。值得一提的是,第一种解决方案在未来的 OS 版本中既得不到支持也不能保证具有相同的行为。找到另一种可获得真正 GDI 对象列表的方法是要解决的首要问题。
对于 Windows 9x 的 GDIUsage,有可能利用对应于每个 GDI 对象类型(如位图的 BitBlt 或画笔的 FillRect)的函数来显示 GDI 对象。但是由于当利用由另一个进程创建的 GDI 对象来调用这些 API 函数时会失败,我所开发的工具的主要功能(即能够“看到”正在泄漏的资源)消失了。另一方面,显示的代码在创建 GDI 对象的应用程序中运行正常。解决方案是显而易见的 — 显示引擎必须运行于其他进程的上下文中。本文的稍后部分将讲述一种基于将 Windows 挂钩作为进程间通信机制的实现。
最后,用 Win32 调试 API 将这些方法结合起来,因此,就获得了可以运行于 Windows XP 和其他 32 位 Windows 平台上的 GDIUsage 版本的实现。
GDI 如何管理句柄
在我 2002 年 8 月 的文章中,WinDBG 用来说明进程环境块 (PEB) 结构。在那篇文章中,GdiSharedHandleTable 字段应引起您的注意,该字段如图 2 转载所示。事实上,这是一个指向表的指针,其中 GDI 存储了它的句柄,甚至那些由其他进程创建的句柄。在他撰写的 Windows Graphics Programming:Win32 GDI and DirectDraw (Prentice Hall,2002 年)一书中,Feng Huan 提供了另一种访问该表的方法,但他也描述了该表中每个 0x4000 项的结构,如下所示:
typedef struct { DWORD pKernelInfo; // 2000/XP layout but these fields are inverted in Windows NT WORD ProcessID; WORD _nCount; WORD nUpper; WORD nType; DWORD pUserInfo; } GDITableEntry;
每一项都存储了 GDI 句柄的详细信息,句柄的值很容易计算。它的低 16 位是在表中的索引,其高 16 位保存在 nUpper 字段中。顾名思义,ProcessID 字段包含创建对象的进程 ID。有了这些信息,简单的循环就可以允许您列出某个特定进程正在使用的对象,而这也正是 GDIndicator 所做的事情,如图 3 所示。
如果您有兴趣了解获得运行进程列表的不同方法,您可以阅读我在 2002 年 6 月一期上发表的文章。每个进程都有一个 ID,用来从共享的表中收集该进程使用的 GDI 对象,并利用 ProcessID 字段进行比较。得到的计数值显示在每个对象类型列下的 GDIndicator 中。
与其他列不同,第三列显示两个值。第一个值是调用 GetGuiResources 的结果(这应该返回该进程使用的 GUI 对象句柄的计数值),第二个加括号的值是在解析 GDI 句柄共享表的过程中得到的和。您可以在图 3 中看到,这两个值通常是不同的,而 GetGuiResources 总是返回较大的计数值。没有文献说明这种不同的原因,与常用对象或未发布对象也没有什么明显的关系。有可能是在您背后分配给 GDI 没有存储在共享表中的对象,因此是您没有涉及的对象。
这种隐藏分配的一个例子发生在图标操作的过程中。当您创建或加载某个图标时,Windows 需要多个位图来实现透明效果。通常一个用于掩码,一个用于可视图形。与位图不同,图标由 USER 系统组件来处理,而不是由 GDI 来处理。这可能就是当调用 GetGuiResources 来了解 GDI 的使用情况时 GetGuiResources 背后的代码好像没有跟踪这些分配的原因。
通过 API 挂钩来跟踪对象分配
您已经看到,要了解特定的过程使用哪些 GDI 对象并不容易。怎样才能知道对象是否已由应用程序代码或背后的 GDI 自身加以分配呢?如果创建 GDI 对象时 Windows 能够通知您,那么就很容易存储它的句柄值并构建由应用程序分配的对象列表。遗憾的是,Win32 API 并没有为开发人员提供这种通知机制。
如果您想知道何时创建了新对象,必须了解图 4 中列出的函数调用。
幸运的是,在作者的文章“Learn System-Level Win32 Coding Techniques by Writing an API Spy Program”中(发表于 1994 年 12 月一期的 MSJ),Matt Pietrek 说明了如何编写 Win32 领域的 API 侦探引擎。给定一个特定模块(进程或 DLL),该引擎可以用您自己的函数地址替换被调用函数(由 DLL 导出)的地址。一旦执行了这种替换,每次被侦探的模块调用一个挂钩函数时,将在其所在位置执行您自己的句柄。
该 API 挂钩原则已经过多年的改进(参见 1998 年 2 月和 1999 年 6 月期 MSJ 的 John Robbins Bugslayer 专栏。)如果您需要了解不同 Windows 平台的可能实现,应该阅读 Jeffrey Richter 的 Programming Applications For Microsoft Windows Fourth Edition“(Microsoft 出版社,1999 年)一书的第 22 章,以及 John Robbins 的 Debugging Applications(Microsoft 出版社,2000 年)一书。这里我使用了 John Robbins 的方法。
图 5 调用 GetDC 的内存布局
John 的 HookImportedFunctionsByName helper 函数接受修补函数列表、导出它们的系统 DLL 的加载地址、调用被修补函数的模块,以及要重新定向到的存根列表。关于退出,它填充了包含所有被修补函数地址的列表。例如,如果 App.exe 正从 USSER32.DLL 调用 GetDC,则您将得到如图 5 所示的内存布局。如果我用下面的输入参数调用 HookImportedFunctionsByName,它将产生如图 6 所示的不同布局。
• |
系统修补函数列表 (GetDC) |
• |
导出函数的 DLL 的地址 (USER32.dll) |
• |
模块调用(App.exe 直接调用 GetDC) |
• |
修补函数的列表(来自于 Hook.dll 的 GetDC) |
在该特例中,包含所有已修补函数地址的列表应是 initial@。
图 6 对 GetDC 修补调用的内存布局
除了图 4 中列出的每个函数调用外,用同一机制来挂钩自由的 GDI 对象的函数(如图 7 所示)。 有了这两种类型的通知,您就有可能跟踪运行的活动 GDI 对象。
CGDIReflect 类负责提供静态的存根方法,这些方法将替代系统函数被调用。该类派生于 CAPIReflect,其主要目标是利用宏将给定模块的函数调用重新定向到静态类成员中。这种替换是通过对 DoReflect 的调用来完成的,DoReflect 接受调用方模块句柄作为参数。派生类的作用就是将您有兴趣接收有关信息的每个系统函数映射到适当的存根函数,本文后面的部分将对此进行讨论。
遵循消息映射机制,定义了一组宏来帮助您自动定义并声明存根函数。在 /P 编译器选项的帮助下,可获得每个包含所有扩展宏代码的源文件的 .i file。您需要观察结果文件的大小,该结果文件可能很大,但这种方法准确地显示了哪些代码被执行,您将在本文后面的图和源代码中看到。
从 USER 和 GDI 重定向函数需要三步,稍后我将对这三步进行概述。使用同一示例,我将讲述如何修补 user.dll 中的 GetDC。
第一步是利用 DECLARE_REFLECT_APIxxx 宏声明 CGDIReflect 中的静态变量,其中 xxx 表示该函数的参数数量(GetDC 有 1个,它接受 HWND 作为参数)。该声明用 BEGIN_REFLECT_LIST 和 END_REFLECT_LIST 框起来,前者定义了一个隐藏的、提供跟踪服务的 TraceReflectCall helper 方法,后者没起什么作用:
BEGIN_REFLECT_LIST() DECLARE_REFLECT_API1(GetDC, HDC, HWND, hWnd) END_REFLECT_LIST()
但是,DECLARE_REFLECT_API 宏需要提供系统函数名、其返回类型及其参数列表(类型和名称)。提供这些信息允许将宏扩展到 CGDIReflect(实际上是个存根)的静态方法中,该方法共享同一原型并执行下列步骤。首先,通过别名调用初始的系统函数(后来由DEFINE_API_REFLECT 实例化)。之后,由前一调用分配的句柄及其类型被存储到一个 CHandleInfo 结构中(参见图 8),这是供将来使用的一些额外数据(参见下一部分有关 DoStackAddressDump 的讨论)。最后,CGDIReflect 的静态映射成员被更新,从而用前面讲到的结构来与新分配的句柄以及想俘获的创建对象相关联。
第二步通过下列宏实现每个静态成员,作为挂钩系统函数地址的别名:
DEFINE_API_REFLECT(CGDIReflect, GetDC);
在我的示例中,宏可扩展到:
CGDIReflect::GetDCProc CGDIReflect::__GetDC = 0;
在第三步和最后一步,需要实例化所有这些成员,然后在执行时使用。在这两种情况下都要调用 FillStubs 方法。首先,将 APIR STATE INIT 作为参数,由 Init调用 FillStubs,并且没有模块句柄要修补。这在利用 GetProcAddress 计算系统函数的地址时发生,然后地址存储在别名成员(对于 __GetDC 为 GetDC)中。
接着,在新的 DLL 需要修补时调用 FillStubs。用 APIR STATE ENABLE 作为参数,DoReflect调用 FillStubs。它将由模块进行的每个系统调用 (GetDC) 重定向到相应的静态存根方法(本示例中为 _GetDC),而模块的加载地址作为参数被传递。该方法遵循与 MFC 消息映射相同的模式:
BEGIN_IMPLEMENT_API_REFLECT() ••• IMPLEMENT_API_REFLECT(hModule, "USER32.DLL", GetDC); ••• END_IMPLEMENT_API_REFLECT()
为了在执行过程中帮助调试宏,一些 AfxTrace 调用分散在扩展代码中。根据图 9 中所示的值,SetTraceLevel 允许您选择跟踪哪个操作。
现在有了 CGDIReflect 类,它允许您将由特定模块发出的任一调用重定向到存根方法,该方法的唯一作用是将新建的 GDI 对象存储到映射中。但是该实现有一个缺陷 — 它不是线程安全的。如果您需要检查的应用程序是多线程的,几个线程都在调用 GDI 函数,产生的行为可能不确定,因为跨线程访问句柄映射是不同步的。
利用堆栈跟踪监视分配
每次对重要函数的调用 都终止于一个存根,该存根完成两项操作。第一项是将分配的句柄及其类型包装到 GDIReflect.h 中声明的一个 CHandleInfo 对象中。第二项操作更有趣。在 CHandleInfo 对象中,当前调用堆栈的每个函数地址都存储在 m_pStack 中 — 分配的 DWORDs 数组 — 而 m_pStack 中保存的地址数保存在 m Depth 中。因此,除了以图形方式表示 GDI 对象外,还可能显示导致分配特定 GDI 对象的函数调用堆栈,如图 10 所示。
图 10 导致分配对象的单元
当需要浏览堆栈时,imagehlp.dll 和 dbghelp.dll 是您最好的朋友。为了便于您的使用,John Robbins 已经将该引擎包装到了 CSymbolEngine 类中,该类的发展历程在 1998 年 4 月和 1999 年 2 月的 MSJBugslayer 专栏中有所介绍。在 John Robbins 的 Debugging Applications 一书中详细地介绍了使用了 DBGHELP 的最后一个版本,该书我在前面提到过。
CSymbolEngine 类是一个在由 DBGHELP 导出的许多函数顶部的低级层。StackManager.cpp(位于本文顶部链接处代码下载中的 /Common 目录内)中实现的 CstackManager 提供了只有三个有趣方法的更高级功能。第一个是 DoStackAddressDump,它利用 CSymbolEngine 分配并填充一组当前的调用堆栈。该方法由每个存根调用,并存储导致对象分配的每个函数地址。
十六进制地址对计算机有好处,但对人没什么好处。为了将由 DoStackAddressDump 返回的地址数组转换为可读的格式,如图 10 所示,必需调用 DumpStackAllocation。该方法接受堆栈转储及其深度,然后在一个 CString 中返回转换后的堆栈。该方法的调用方能够选择他希望在每个地址间使用的行分隔符,要么是选择 \r\n 在编辑框中显示 CString,要么是选择 \n,利用 Trace 或 OutputDebugString简单地将其进行记录。该方法背后并没有什么魔法,对于给定数组中的每个地址,它都调用 ConvertStackAddressIntoFunctionName。
魔法在其他地方存在。当堆栈由 DoStackAddressDump 转储,而地址存储于返回的数组中时,该方法还可以利用 CSymbolEngine 中定义的 SymGetModuleInfo、SymGetSymFromAddr 和 SymGetLineFromAddr(有关实现细节,请参见代码下载中 StackManager.cpp 中的 ConvertAddress)找到对应于地址的符号。为什么现在进行转换?答案很简单:在这个特定的时刻,您确信相应的 DLL 被加载,但稍后调用 DumpStackAllocation 时情况可能会不一样。
如果频繁创建 GDI 对象,就会产生许多堆栈转储,并保存在 m_HandleMap 中。但保存在该映射中的 CHandleInfo 对象保留的是地址数组,而不是转换后的字符串数组。技巧是利用一个映射成员(如 m_AddressToName)来跟踪转换。这就避免了在堆栈转储中存储长字符串来代替每个地址的 DWORD 类型,因此减少了对内存的消耗。另一个好处是,堆栈转储运行速度会更快,因为利用 m_AddressToName 来作为缓冲区,从而避免了对符号引擎进行查询。
即使您知道如何挂钩一系列 GDI 函数,您仍需要了解在哪些模块中调用这些 GDI。我们说,在共享 DLL 中使用 MFC 的应用程序正在创建一个 Cpen 对象,从而操作与 Windows 画笔相关的 API。真正调用 CreatePen(它返回画笔的句柄)是在 MFC DLL 中(而不是在调用应用程序代码中)完成的。如果只挂钩由可执行文件调用的 API 函数,就会丢失来自由应用程序使用的所有 DLL 的调用。
通过调试来确定需要修补的 DLL
在 Windows XP 中,获得在给定时间由某个进程加载的所有 DLL 的列表非常简单,这要感谢 PSAPI 函数 EnumProcessModules,正如 Matt Pietrek 在“Under the Hood”一文中所述,该文发表在 1996 年 8 月发行的 MSJ 中。但是,对于动态加载的库,这个问题需要一点窍门。除了挂钩系统函数,还必须挂钩主程序发出(ANSI 与 UNICODE 版本)的 LoadLibrary 调用,从而检测何时加载了一个新 DLL,并递归地对其进行相同的挂钩处理。
需要回答最后的两个问题。第一,如何知道在侦探的进程中哪些 DLL 需要修补?第二,如何确保这些代码在另一个进程中执行?如果有这样的解决方案,那么还有可能用它来显示任何 GDI 对象句柄的图形化表示。在2002 年 8 月发表的有关调试的文章中,一个调试过的进程通过使用 Win32 调试 API 来动态或静态地检测加载的 DLL。这种方法的主要缺点是需要启动和调试应用程序。与 Windows 9x 版本的 GDIUsage 不同,被检测的 GDI 对象必须是由调试过的应用程序分配的对象,这可以在图 1 中看到。
Win32 调试 API 允许您编写代码来启动某个应用程序(调试对象),并且当发生事件时(如加载一个新的 DLL)获得通知。这正是您所需要的。要轻松地编写调试器,您只需重载在调试事件发生时要调用的虚方法。CGDIDebugger 类派生于 CapplicationDebugger,在我的 2002 年 8 月的文章中介绍过 CapplicationDebugger。图 11 显示了 CGDIDebugger 所重载方法的名称,并解释了每个方法的作用。稍后我将讨论调试器和调试对象之间的通信机制。
图 12 搜索字符串
除了这些方法,已经重载了 OnOutputDebugStringDebugEvent,从而将调试对象(前缀为 >)留下的踪迹重定向到专用的列表框中。还有可能将一个选择复制到剪贴板,或者搜索一个字符串,如图 12 所示。当利用 TRACE 或 OutputDebugString 添加跟踪时,它就会出现在该列表框中。这是一种调试代码的有效机制,并可以标出调试对象(前缀为 >)和调试器的输出结果之间的差异。
在另一个进程中插入运行代码
现在还有一个遗留问题需要解决:一定有一种方法可以使一些代码在另一个应用程序的上下文中运行。幸运的是,Jeffrey Richter 很早以前就在他的文章“Load Your 32-bit DLL into Another Process's Address Space Using INJLIB”(MSJ,1994 年 5 月)中解决了这个问题。
由于通常我们只对使用 GDI API 的应用程序感兴趣,因此我们可以假定这样的应用程序至少使用一个窗口来显示它的图形。(否则,它为何需要 GDI?)因此,在不同的解决方案中,基于 Windows 挂钩的方案好像是最佳的选择。当调用下面的挂钩时,任何进程中的任何线程执行 GetMessage 时 Windows 将会调用 GetMessageHookProc 回调函数:
SetWindowsHookEx(WH_GETMESSAGE, GetMessageHookProc, hInstance, 0)
由于这是一个系统范围的挂钩(最后的参数为 0),回调函数的代码必须位于某个 DLL 中,该 DLL 被映射到其线程调用 GetMessage 的各个进程的地址空间。
如果挂钩进程和驱动应用程序适合,用预先定义的消息在它们之间建立通信信道就非常简单。这是一种允许调试器为一些插入代码(运行在被侦探应用程序上下文中)发送请求的不错方法,确切地说,这就是 GDI 对象显示对话框所需的!当挂钩进程拦截了第一条消息时,它首先重定向已经加载的 DLL 调用。然后,它启动一条新的线程,专门处理来自调试器的请求。这就在调试器和调试对象之间创建一条通信信道(有关详细信息,请参见 GDITrace.cpp 中的 StartInfiltratedThread)。
由调试器和调试对象从挂钩进程和渗透线程函数中调用的函数都已经收集到了 GDITrace.dll 中,GDITrace.dll 的行为由 CGDITraceApp 类来实现。该 DLL 与调试器应用程序 GDIUsage 静态链接,但是它动态加载到触发 Windows 挂钩的进程中。由调试器调用的函数在 _GdiTrace.h 中声明,并集合在 GdiTrace.cpp 中,从而帮助您理解调试器使用的是哪一部分,调试对象使用的是哪一部分。但为什么要在同一个 DLL 中混合不同的代码?需要在这两种代码之间共享一些变量,在同一个 DLL 的实例之间共享变量的值很简单,如图 13 所示。
这些代码用读/写/共享属性 (rws) 定义了名称为 .shared 的 PE 区域,这些属性包含 5 个前缀为 s_ 的需要共享的变量。根据这些声明,Windows 将这些变量保存到一个加载 DLL 的进程共享的内存块中。因此,这些变量在各个进程中的值都相同,特别是调试器和调试对象。我们看一下当调试器启动一个调试对象时会发生什么情况,以及如何使用这些变量。
当启动调试对象时,调试器线程接收到一个 CREATE_PROCESS_DEBUG_EVENT,它由 OnCreateProcessDebugEvent 处理,OnCreateProcessDebugEvent 反过来又调用 StartTraceGDI。该函数执行 SetSharedVariables,用调试器线程的 ID 设置 s_dwCallingThreadID 的值。如果当前进程的 ID 与保存在 s_dwProcessID 中的 ID 相同,挂钩进程就会知道它是在调试对象的上下文中运行,并根据已经加载的 DLL 开始修补 GDI 调用。接着,由挂钩进程启动的专用线程在 dwInfiltratedThreadID 中保存了它的 ID。最后,当该挂钩进程成功运行时,s_bDebuggeeIsStarted 被设置为 TRUE,然后由调试器用它来决定渗透线程是否已经准备好响应请求。
如果需要在调试器和调试对象之间传递或检索 GDI 对象句柄列表,就需要一个正好比一个 DWORD 或一个 BOOL 大的共享缓冲区。除了这 5 个变量,还要使用一个名为 GDITrace SharedBuffer 的内存映射文件,对应的内存由 CGDITraceApp 的成员 m_lpvMem 指定。它在 DLL 启动期间被初始化(有关详细的实现,请参见 GDITrace.cpp 中的 CGDITraceApp::InitInstance)。只有当这两个进程加载 DLL 时该缓冲区才需要创建和初始化:作为调试器的 GDIUsage 及其当前的调试对象。
s_dwProcessID 共享的变量用来识别两个进程间的区别。如果没有启动的调试对象,它的值总是 0;否则,它就包含调试对象进程的 ID。当 DLL 加载到进程中时,它的 InitInstance 检查 s_dwProcessID 是否等于 0(应当是 GDIUsage)或者等于 GetCurrentProcessId(应当是调试对象),从而创建内存映射文件。
从调试器到调试对象的通信
调试器使用 s_dwInfiltratedThreadID 共享变量来发送一个请求(利用 PostThreadMessage 通过一个简单的 Windows 消息),该请求将由调试对象中的渗透线程来处理。当调试对象通知调试器这样的一个请求已经完成时,需要另一个 s_dwCallingThreadID 共享变量。例如,当用户单击“Take Snapshot!”按钮时,GDIUsage 需要从调试对象收集已分配的 GDI 对象。
GDIUsage 发送一条 TM_GET_LIST 消息给调试对象中的渗透线程,调试对象的值保存在 s_dwInfiltratedThreadID 中。它将执行连同参数 UM_SNAPSHOT_READY 一起发送给 OnGetList 函数,该参数将被用作回调消息被 GDIUsage 主对话框接收。为什么不简单地使用同样的 TM_GET_LIST?答案与共享代码有关。“Take Snapshot!”与“Compare”按钮都需要获得相同的已分配 GDI 对象列表(虽然使用该列表的方法不同),以更新 GDIUsage 用户界面的相应部分。
为了总结一下在前面段落中我已经论述的内容,TM_GET_LIST 线程消息触发对调试对象端 GDI 对象的处理。另外,根据用户定义的两条消息,有两种方法可以更新 GDIUsage 主对话框:UM_SNAPSHOT_READY 与 UM_COMPARE_READY。
渗透线程唤醒并要求 OnGetList 来处理请求。该 CGDITraceApp 方法通过修补的存根枚举出调试对象中检测到的 GDI 分配,并将每个对象的说明(句柄值和类型)按照下面的 GDI_LIST 格式复制到由 m_lpvMem z指向的内存映射文件共享的缓冲区中:
typedef struct { DWORD dwType; HGDIOBJ hObject; } GDI_ITEM; typedef struct { DWORD dwCount; // count of meaningful GDI_ITEM slots in Items GDI_ITEM Items[]; } GDI_LIST;
为了通知调试器列表已经准备好,一条相同的 TM_GET_LIST 消息被发送回由 s_dwCallingThreadID 识别的线程。该消息由负责调试事件的线程在 GDIUsage 上下文中接收并发送给 CGDIDebugger::OnThreadMessage。该方法通过用一个指向共享内存的指针向主对话框发送正确的用户消息(本示例为 UM_SNAPSHOT_READY)来通知 UI 线程,共享内存由内存映射文件定义,而调试对象将 GDI 对象保存在该文件中。通过使用 CGdiResources 的 CreateFromList 方法,这个自动封送处理的序列化列表用来实例化 CGdiResources。该类用来包装一个 GDI 对象列表,同时还提供诸如枚举与图形显示等服务。
死锁与计时问题
在深入了解远程 GDI 对象的图形显示之前,您应当了解一下可能发生的死锁问题。以前探讨的收集 GDI 对象的技术都是异步的,因为它要依赖调试器和调试对象之间交换的 Windows 消息。如果需要进行强同步通信,可以使用 Win32 事件。例如,渗透线程等待一个有特定名称的事件,当一条消息发送到它的队列中或者事件获得信号通知时,它就调用 MsgWaitForMultipleObjectsEx 来唤醒(有关详细的源代码信息,请参见 GDITrace.cpp 中的 CGDITraceApp::InfiltratedThreadProc)。在该实现中,用事件来要求线程结束它的生存期,因此不是真正的同步。
另一种需要真正同步行为的情况是,当调试对象加载一个 DLL 时修补 GDI 调用。为了截获 GDI 调用,调试器必须尽快通知渗透线程。否则,调试对象可能在安装截获存根之前就开始分配 GDI 对象。这里是一个不错的实现方案:
1. |
调试器线程获得通知,通过 WaitForDebugEvent 返回的LOAD_DLL_DEBUG_EVENT 已在调试对象中加载了一个 DLL。 |
2. |
OnLoadDLLDebugEvent 重写方法接收 DLL 对应的 hModule,将它保存在一个新的共享变量中,并通过为事件发送信号来请求调试对象为该特定的 DLL 修补 GDI 调用。 |
3. |
如果调试对象加载另一个 DLL,为了避免重新进入,OnLoadDLLDebugEvent 等待另一个在调试对象完成其修补工作后的信号通知事件。 |
4. |
MsgWaitForMultipleObjectsEx 唤醒调试对象-渗透线程,因为它等待的一个事件已经由信号通知。 |
5. |
CGDITraceApp::OnNewDLL 方法为在由共享变量定义的地址处加载的 DLL 重定向 GDI 调用,该共享变量由调试器用 DLL hModule 填充。 |
6. |
调试器等待的事件由渗透线程发信号通知。 |
7. |
渗透线程调用 MsgWaitForMultipleObjectsEx 等待完成另一个请求。 |
8. |
调试器线程继续进行,因为它等待的事件已经由信号通知。 |
注意,最后两步的顺序号相同,因为它们的代码在由 Windows 调度的两个不同线程中运行。不要指望一个会在另一个之前执行。
虽然这种方案看起来很完美,但它会在调试器线程和插入调试对象的线程之间引起死锁。第 3 步和第 4 步之间有一个隐患。Win32 调试 API 假定调试器正在利用 WaitForDebugEventsumes 等待一个调试对象事件。当该函数返回时,一直到 ContinueDebugEvent 被调用,调试对象中的所有线程(甚至那些没有生成调试事件的线程)都被挂起。因此,停留在第 3 步和第 4 步的调试器线程将永远不会由调试对象执行,因为在同步通信结束后 ContinueDebugEvent 才应执行。要切记,从由调试器接收的调试事件来同步调试对象中的行为是不可能的。
在 GDIUsage 的情况下,基于消息的机制提供的通知速度好像已足够迅速。真正的问题出在其他地方。由于调试对象检索到第一条消息后就启动渗透线程,所以所有静态链接的 DLL 都已经初始化。如果其中的一些已经分配了 GDI 对象,这样的消耗将永远不会被 GDIUsage 检测到。在这种情况下,需要另一种方法来执行修补代码。GDIUsage 帮助您检测和找到的 GDI 对象创建不是在应用程序的生存期内发布的,而是在其启动时发布的。
显示 GDI 对象
图 1 中所示的 GDIUsage 用户界面允许用户获得在任何给定时间点某进程使用的 GDI 对象的快照,并且稍后可以与同一进程的当前状态进行对比。每组对象都保存在 CGdiResources 对象中。可以在 TM_GET_LIST 注释中看到,对象列表一旦通过内存映射文件在调试对象和调试器之间被序列化和封送处理,利用 CreateFromList 就可以初始化一个 CGdiResources 对象。
在 Windows 9x 版本的 GDIUsage 中,负责显示 GDI 对象的 CgdiResourcesDlg 类接受指向 CGdiResources 对象的指针作为参数。遗憾的是,在 Windows XP 版本的 GDIUsage 中,由于用来以图形方式显示 GDI 对象(在调试对象内部创建)的 GDI 函数总是出故障,因此在调试器的上下文中,CGdiResourcesDlg 就变得毫无价值。
解决方案是移去 GDITrace.dll 内部的 CGdiResourceDlg 和 CgdiResources,同先前讨论的一样,GDITrace.dll 通过 Windows 挂钩被加载到调试对象中。在检索完已分配对象的列表后,就该显示这个列表了。使用的通信机制和先前讨论的一样,但在显示列表并为渗透的线程发送消息之前,该列表必须在调试器的上下文中保存到内存映射文件。调试器将 CGdiResources 列表(或者当前的快照,或者对比的结果,取决于用户单击的按钮)序列化到内存映射文件,并将一条 TM_SHOW_LIST 消息发送给调试对象中渗透的线程,其中利用 GDIUsage 主对话框窗口的句柄作为参数。在这样的处理中,由 CGDIDebugger::ShowList 完成序列化,而由 ShowRemoteList 完成消息发送。
与 TM_GET_LIST 一样,由渗透的线程处理 TM_SHOW_LIST 消息,接着发送到 CGDITraceApp::OnShowList。这种方法根据内存映射文件中保存的序列化与封送数据初始化 CgdiResources。现在,有可能让 CGdiResourcesDlg 来显示对应的 GDI 对象了。
与 TM_GET_LIST 消息不同,为了将该显示命令发送给调试对象,调试器不需要任何返回代码或信息。在任何情况下,用户都希望 GDIUsage 保持禁用,直到他关闭了该 CGdiResourcesDlg 对话框。这就是将 GDIUsage 主对话框的句柄作为父窗口传递给 CGdiResourcesDlg 对象的原因。
除了有两点改进之外,该类的实现从 Windows 9x 版本以后就没有更改过。第一点改进是利用构建 GDI 句柄的方法来检测它是否引用一个常用对象以及是否在列表框的对应行中添加一个 *。这种功能在 GDIUsage 中是不可见的,因为创建常用对象的 API 没有被修补过,但您可能在编写自己的代码时想实现它。
第二点改进是,为了呈现对应的堆栈跟踪,用户单击或者双击 GDI 对象列表时需要进行检测,如图 10 所示。由于不应当将 CGdiResourcesDlg 代码链接到堆栈跟踪代码,因此定义了一个通用的回调接口 INotificationCallBack。CGDITraceApp 类派生于该界面,它实现了 OnDoubleClick 并使用 CGdiResourcesDlg::SetNotificationCallBack 对其自身进行注册。
当用户双击某个 GDI 对象时,CGdiResourcesDlg 要检查是否已经注册了一个回调。如果是,它就调用该回调的 OnDoubleClick 方法,并以被双击的 GDI 对象对应的 CGdiObj 说明作为参数。然后,CGDITraceApp 从传递的 CGdiObj 提取调用堆栈,并用它实例化 CcallStackDlg,从而为用户显示堆栈跟踪(有关实现的详细信息,请参见 GDITrace.cpp 中的 CGDITraceApp::OnDoubleClick)。
这种功能也添加到了图 3 所示的 GDIndicator 工具中。与 GDIUsage 不同,插入机制是基于 CreateRemoteThread 的,并且在我 8 月份的文章中已经进行过论述。一个要注意的有趣事实是,代码的序列化和显示与 GDIUsage 完全相同;只是更改了远程处理机制。由于没有被记录的堆栈跟踪,因此没有注册双击的处理程序。
还有最后两个缺陷必须进行处理。由于显示 GDI 对象的对话框陷入了另一个进程上下文,因此会发生一些怪异的事情。首先,在 Windows 2000 和 Windows XP 中,从另一个进程中设置前台的窗口绝非易事。您必须在拥有前台窗口的进程中调用 AllowSetForegroundWindow,从而让另一个进程设置它的一个窗口,并作为一个新的前台窗口。在用户取消之前,对话框代码会在另一个进程当前线程的上下文中无限循环地运行。因此,在关闭该对话框之前,GDIndicator 一直挂起。为了避免这种讨厌的行为,在对话框的生存期间,要隐藏它的主窗口。
第二个负面影响更难解决。对话框控制的线程中创建的窗口将不再接收它们的所有消息,因为该对话框进程对它们进行了筛选。例如,如果您使用这里概述的工具来找出由 Notepad 分配的 GDI 对象,它的重画功能将是不错的选择,而且可以轻松地导航到它的菜单中,但在关闭对话框之前,选择的命令不会触发事件。这是一种复杂的使用功能,但在搜索难以琢磨的 GDI 漏洞时确实颇有价值。
小结
Windows 9x 和 Windows XP 之间,GDI 已有所不同。虽然程序中的许多代码都重新使用了其 Windows 9x 实现中的代码,例如对 GDI 对象组的管理以及它们的图形显示,但获得由某个进程分配的 GDI 对象列表的内在机制却是全新的。Windows XP 版本是基于 Win32 调试 API、DLL 插入、Windows 挂钩和 API 补丁的,并且与对应的 Windows 9x 相比,提供的功能更多。
除了导致每个 GDI 分配的调用列表外,还增加了最后的处理。当远程进程终止时,插入的 DLL 的 ExitInstance 方法被调用。CGDITraceApp 最后利用该通知枚举 GDI 对象并转储仍然有效的对象。这与在调试版本中使用 DEBUG_NEW 作为内存分配器来检测内存泄漏时从 MFC 应用程序中获得的最后转储是一样的。