定制调试诊断工具和实用程序 — 摆脱 DLL“地狱”
2008-07-21 17:47
274 查看
本文假设你熟悉 Win32,DLL
摘要
在 Windows 系统中,动态库版本冲突实在是一个老大难的问题了,为了解决这个问题,除了使用大量现有的工具外,你还可以利用丰富的 Windows APIs 函数构造自己的调试工具和实用程序。作为例子,本文将提供三个这种类型的工具,讨论如何利用它们来解决动态库的冲突问题。这三个工具分别是:
DllSpy——列出加载到系统中的所有 Dlls 以及所有使用这些 Dlls 的进程;
ProcessSpy——列出系统中所有正在运行的进程以及它们正在使用的 Dlls;
ProcessXP——显示 Windows XP 所有并发运行的会话(Sessions)清单;
从所周知,动态库“地狱”(DLL Hell)已经不是什么新鲜玩意儿了,如果你使用第三方的 Dlls,肯定会碰到不少与它有关的问题,如找不到入口点,或者库版本不兼容等。.NET 中允许组件的并行执行,减少了产生这种问题的几率,但是如果你还没有升级到 .NET 环境,那怎么办?针对这种情况,可用的方法是用不同的工具跟踪 DLL 的依赖性。但是用标准工具跟踪时,你可能最后得不到所要的信息。许多工具都没有你需要的功能,比如自动写日志文件,跟踪分析,仅在控制台操作脚本控制等。
本文我们先用一些现有的工具来考察系统中的运行进程,然后系统地研究本文提供的三个工具:DllSpy, ProcessSpy 和 ProcessXP,以便在今后的开发或调试中使用这些工具和技术。
现有的工具
Depends.exe 是 Visual C++ 自带的一个工具。它可能是我们经常使用的工具中最简单的一个工具了,其功能是列出某个应用程序或 DLL 需要的 DLLs。这个程序在本站可以下载(更新版本请到下面这个地址下载:http://www.dependencywalker.com)。如果你需要看某个 DLL 或可执行文件的全路经,可以用它的上下文菜单进行设置:如图一:
图一 察看全路经
对于静态加载的情况(即应用程序在链接过程中将 dlls 对应的 lib 文件链接到程序中),这个工具非常好用,但对于版本较新的系统,大多使用 COM 编程接口,或者说是用 COM 对象编程模型,而 COM 对象的实例化都是运行时加载或者说动态加载某个 DLL 文件,然后通过 LoadLibrary 和 GetProcAddress 调用其中某个特殊的函数来实现的。你不知道这个 DLL 是何时、从哪里被加载的。
一种确定 DLLs 被动态加载的方法是找出需要被每一个进程加载的 DLL。Sysinternal 公司(http://www.sysinternals.com)提供了一个工具软件 ListDlls.exe。它是一个控制台程序,其图形用户界面(GUI)版本为 Process Explorer。如图二:
图二 Process Explorer 运行画面
除了列出被某个进程使用的 DLLs 之外,还可以用这个工具了解某个程序用到了哪个 kernel 对象,从版本3.11之后,Process Explorer 还可以让你在两个快照之间轻松扫描到新的或未使用的对象。
有时候在你用 Process Explorer 扫描到某个进程之前,它可能已经被加载然后又在很短的时间内被卸载了。碰到这种情况时,你需要另外一种类型的工具,我们将在后文中讨论。
为了操纵进程和 DLLs,首先你必须知道每一个被加载的 DLL 被哪些进程使用。本文的例子程序 DllSpy 实现目的即在于此。如图三所示:
图三 DllSpy 运行画面
DllSpy程序上面的窗格列出的是所有已经加载的 DLL,每选中一个DLL,在下面的窗格中就会列出使用该 DLL 的所有进程。
而 ProcessSpy 例子程序的功能正好与 DllSpy 相反,它在上面窗格列出系统中所有的运行进程,每选中一个进程,在下面窗格便显示出此进程使用的所有 DLLs,如图四所示:
图四 ProcessSpy 运行画面
下面窗格还反映了 DLL 加载的地址是实际地址还是首选地址,以及它们的从属性是静态的还是动态的。这些工具的源代码和可执行程序都可以从本文的下载链接中下载,它们也许不完全满足你的需要,但可以作为技术参考,对编程工作肯定是有所裨益的。
如何获取运行进程列表
有三种方法来获取Win32运行进程的信息,参见表一:
(表一)
本文不打算讨论 TOOLHELP32,因为 MSDN 中提供了很多使用 TOOLHELP32 函数的例子代码。性能计数器提供的信息更多,不仅仅是进程清单。如果你想获取远程机器的信息,那么性能计数器是再好不过的工具了。如果你总是想得到另外一台机器的进程列表信息,那么就用性能计数器吧!
进程状态 API(PSAPI 全称是 Process Status API)是微软 SDK 中一个很有用的工具,在例子程序中有一个类 CProcessList,其实现文件 Process.cpp 对 PSAPI 进行了打包,用这个类可以获取进程清单。只要 Refresh 一被调用,通过某个进程的ID便可获得此进程的描述信息,并很容易用 GetFirst 和 GetNext 列举出其它进程:
Refresh 的实现用到了 EnumProcesses 函数(在PSAPI中):
如果你至今还在支持 Windows 的 16 位代码,比如在任务管理器的 ntvdm.exe 项下列出的应用程序就属于此列,那么枚举进程更棘手。有关细节参见本文参考资料中的知识库文章,以及 Matt Pietrek 的专栏文章:August 1998 和 September 1998。
如何获取进程信息
有了运行进程的列表,接下来就是根据 EnumProcesses 返回的进程IDs尽可能多的获取每一个进程的详细信息,然后根据这些信息建立有用的工具。用PROCESS_QUERY_INFORMATION | PROCESS_VM_READ作为参数,调用OpenProcess获取进程句柄,然后用AttachProcess(参见Process.cpp文件中的CProcess类实现)方法创建进程描述。表二中列出的是CProcess用于获取进程细节信息的函数:
(表二)
这里有几个关于AttachProcess的细节问题需要解释一下。首先,为了避免与PSAPI或 NTDLL这样的操作系统特有的DLLs进行静态链接,编写一个类对我们需要的从这些DLLs中输出的函数进行打包是值的得--有关细节可以参考例子代码中的Wrappers.h和Wrappers.cpp文件。这样的话,你只需要定义一个CPSAPIWrapper对象并调用它的GetModuleFileNameEx方法即可,不用链接到PSAPI库。另外,你应该调用IsValid方法来检查这些DLLs在你运行系统中是可用的。如此一来你的代码便可以运行在任何Windows平台而不会产生诸如某某函数未定义之类的链接错误。注意在使用某个专门的特性之前,你应该检查一下Windows的版本或IsValid的返回结果(参见DllSpy例子代码中的DllSpyApp::InitInstance部分)。
注意PSAPI中的GetModuleFileNameEx函数返回的文件名很奇怪:如:"/SystemRoot/ System32/ smss.exe"或者"/??/C:/WINNT/system32/winlogon.exe"等等。谁知道这是什么意思?在Helper.cpp中有一个函数TranslateFilename专门对此进行转换,将这些文件名转换成更容易理解的名字。稍候会我们还会谈到这个函数。
接下来我们讨论如何寻找某个进程的主窗口,EnumWindows有一个参数是回调函数,此回调函数的作用是接收顶层窗口句柄,在这个问调函数中,我们要调用GetWindowThreadProcessId来获取创建相应窗口的进程ID,如果找到这个窗口(可见的)便停止枚举(详情请参见GetMainWindow实现)。函数GetWindowText可以被用来获取某个不同进程的窗口标题。
在Windows NT 和Windows 2000里,为了获取与创建某个特定窗口的进程对应的文件名字,不能像以前那样用GetWindowModuleFileName函数,你会毫无所获,这个函数总是返回当前运行进程的路径名。
获取某个进程主窗口的详细过程描述可以参考Jeff Prosise在MSJ Aug99上的Wicked Code专栏文章。现在你已经知道了如何通过某个已知的进程ID,调用PSAPI函数来获取全路径名。然后利用这个路径名并调用GetWindowThreadProcessID函数获取创建某个特定窗口的进程文件名。
在AttachProcess中必须调用OpenProcess来获取大多数的进程信息,但是有可能出现拒绝访问的错误。如果出现这种情况,我用了一个Keith Brown给出的方法,参见他在MSJ Aug99中的"Security Briefs"专栏文章,其中详细讨论了如何用高级别权限获取进程句柄。细节请参见例子代码的Helpers.cpp文件,其中有一个函数名叫GetProcessHandleWithEnoughRights,就是出自Keith Brown之手。
当某个进程是作为另外一个用户账号计划任务而运行的时候,就会出现上述提到的拒绝访问问题。即便是Windows任务管理器都无法终止这样的进程,它只显示一个象下面这样的对话框。如图九:
图九 无法终止的进程
如果你在ProcessSpy例子程序里的某个进程上双击鼠标按键,它将终止这个进程。你看一下在例子代码中的SlayProcess函数就会知道。此辅助函数调用了GetProcessHandleWithEnoughRights来获取进程句柄,但访问权限参数是PROCESS_TERMINATE,而不是在AttachProcess里所用的PROCESS_QUERY_INFORMATION | PROCESS_VM_READ。
最后是用GetProcessOwner获取进程运行的用户账号(格式为//Domain/User),通过用TokenUser作为参数调用GetTokenInformation,然后用LookupAccountSid将返回的用户SID转换为人可读的域名和用户名。有时OpenProcessToken会因为遇到象System这样的进程而调用失败,甚至是Windows 2000 资源开发包中的PULIST.EXE遇到这种情况都无法显示出拥有进程的用户。只有ProcessExplorer(Sysinterals公司开发的一个工具软件)能成功找到此"安全的"应用的所有者。本文稍后会讨论Windows XP中如何用WTS APIs(也就是Windows Terminal Services API--Windows终端服务API)来获取进程的宿主。
列举加载的模块
任何时候通过 PSAPI 或 TOOLHELP32 都可以列出某个进程加载的 DLLs 列表。在写此文前的调研过程中,我研究了 Matt Pietrek 以前在 MSJ Under The Hood 专栏中的一篇文章,其内容是讨论如何使用 TOOLHELP32 来实现前述的功能,我发现在 Windows 2000 和 Windows XP 环境中是有问题的,代码不能正常工作,现将其代码摘录如下:
用TOOLHELP32遍历模块
那个无效的 if 语句是这样的:由于 CreateToolhelp32Snapshot 调用失败时不会返回 NULL,所以下面的错误处理代码是无效的:
为了搞清楚代码运行中发生的事情,用本文实例代码包含的Helpers.cpp 文件中提供的GetLastErrorMessage辅助函数可以有助于你看得更清楚。他调用GetLastError 和 FormatMessage以纯文本形式获取相应的失败原因。失败原因都一样:Access Denied,也就是拒绝存取。但是使用PSAPI函数时,当获得相同进程的模块列表时不存在存取问题。
之所以发生存取问题,是由于缺乏优先级。使用TOOLHELP32 的代码要正常工作必须得有 SE_DEBUG_NAME 优先级。有关这个问题的详细信息,请参考 1998.3 MSJ 的 Q&A Win32 专栏以及 1999.8 的 Security Briefs 专栏
关于 DLL 的方方面面
用 PSAPI 和 TOOLHELP32 两种途径获得的某个进程所加载的模块列表只反映地址,在这个地址处,DLL被映射到地址空间。下一步便是尽可能完整地获取关于DLL的描述。我的实现并不象在CProcess中所做的那样提供单独的 AttachModule 方法。因为要获取某些细节信息代价实在太高,因此我选择将它们分割成不同的函数。最不值钱的信息从 CModule 的构造函数获得,其它信息的获取要到相应的存取器方法被调用(通过 Refresh 函数)。实现细节请参考 Module.cpp 文件。其 Refresh 方法模仿了 Matt Pietrek 的 CModuleList 中的Refresh/RefreshTOOLHELP32 方法。表三列出了 CModule 的存取器方法:
我前面提到过,想要获取模块的全路径名需要一点诀窍。由于一些原因,GetModuleFilenameEx 或 TOOLHELP32 模块函数返回的模块名很奇怪,它们不遵循 Win32 的命名标准。例如以smss为例,返回的名字是"/SystemRoot/System32/smss.exe",这里"/SystemRoot"必须用Windows文件夹的实际名字来替换。又如 wonlogon.程序,返回的名字是"/??/C:/WINNT/system32/winlogon.exe",应该转换成"C:/WINNT/system32/winlogon.exe"。"/??/"前缀是 Windows NT 名字空间的残留物,是 kernel 模式中的东西,即便是Win32编程也很少用到它。我写了一个辅助函数 TranslateFilename 用于将这些文件名转换成更标准的形式。此函数的细节请参考下载源代码中的Helpers.cpp 文件。
我用 Refresh 方法采集其余的模块描述,具体实现请参考 Module.cpp 文件,下面是对它的一个概述,详细的存取函数见表四:
大部分的描述信息都是从文件本身吸取出来的,同时借助了 Matt Pietrek 所写的几篇文章中有关PE格式的知识。
如果你想了解更多有关 PE 文件的细节,请阅读 Matt Pietrek 的这些文章,其中重点是 PE_EXE 类和 PEDUMP 实现。其代码对于诸位具有很高的参考价值。
GetBaseAddress 一个有趣的使用方法是将它的返回值与 GetModuleHandle 的返回值进行比较。后者是实际的地址,正是在这个地址,模块被加载到进程地址空间里,而前者的地址是模块希望被加载的地址。这正好用来发现加载是否冲突。
当一个进程启动时,Windows 加载程序自动加载静态DLLs。这些静态链接的东西很容易用PE格式和 MODULE_DEPENDENCY_LIST 类通过编程获得。没有哪个API能扫描到这些模块与那些用 LoadLibrary 或 CoCreateInstance 动态加载的模块之间的差别。如果一个DLL被某个进程使用,但它又不在静态链接之列,那么它就应该是动态加载的。
在 ProcessSpy 的输出画面中,如图四,底下的窗格中每一个模块都有一个前缀图符,圆形的D表示动态加载的,方形的S表示静态加载的。它们的颜色也有不同的意思,红色表示这个模块的基地址与其加载地址是不同的,反之则为浅蓝色。
除了从文件本身吸取描述信息外,还可以从它的资源版本中获取其它描述信息。Paul DiLascia 在他的 C++Q&A 专栏(MSJ 1998.4)文章中为我们提供了一个很帅的打包类 CModuleVersion,用这个类可以方便地获得资源版本中对模块的描述信息。对于每一项VS_VERSION_INFO 细节都有存取函数,这些函数返回 CString 引用,都是由 CModuleVersion::GetFileVersion 用相应的串填写。GetCompanyName 就是一个很好的例子。
为了满足我的需要,我对 Paul DiLascia 的代码进行了修改。GetFileVersionInfo 方法应该得到模块的名字,而不是真正的文件名。为了获取相应的文件名,调用 GetModuleHandle。如果在当前的进程空间中查找模块失败(这种情况罕见)。为了解决这个问题,当给定的模块名就是实际的执行文件名时(用 GetFileAttributes 可以判断出来),则直接使用它即可。
Windows 提供的资源信息不仅仅限于公司名这么简单,通常还有更多的东西,例如,从中可以很容易知道应用程序是否为Debug版本,是否是私隐或特别版。你必须看一下 VS_FIXEDFILEINFO 结构中的 dwFileFlags 标志。MSDN文档对它的描述是包含一个位码(bitmask)值,这些位码值的含义请参考表五:按照版本信息对文件进行分类:
(表五)
在相同版本的结构中,dwFileType域定义了文件类型,参见表六:dwFileType域中的标志
(表六)
ProcessSpy 使用这些标志来表示版本栏(Version),用D表示 Debug,用P表示补丁,参见图四。
下一回内容预告
本文以后的内容将讨论几种用非常规方式来获取一些附加的信息源。也就是说如果在没有可借助的 API 的情况下,你就可以用这几种非常规方式。其中包括我至今未曾提到的一个主要信息源,那就是 Windows 的外壳(Shell)。在模块文件中隐藏一个文件的时候, 关于某个文件的信息,没有人比 Windows 资源管理器知道的更多。如图十八所示:
图十八 用资源管理器查看文件信息
那么如何从自己的程序中打开或者调用 Windows 资源管理器文件属性对话框呢?关于这个请参考精华区的一小段代码。其关键是先填写 SHELLEXECUTEINFO 结构,注意结构中的 fMask 成员一定要用 SEE_MASK_INVOKEIDLIST 赋值,然后调用 ShellExecuteEx API 函数,如:
在 ProcessSpy 程序界面底部窗格中任何一个模块记录上双击鼠标便可以调出文件的属性对话框,相应模块文件的描述信息一目了然。注意 Windows XP 中不支持多个 ShellExecuteEx 调用,当你调用第二次时,线程冻结,也不会有任何提示。
正如你所看到的,有许多方法都可以获得加载 DLLs 以及活动进程的信息。我在本文中提供的几个工具可以作为一个很好的学习开端,你完全可以借鉴文本描述的方法以及所提供的 C++ 类来定制满足自己需要的调试工具。
本文前面的部分讨论了如何用有着良好文档描述的 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 所示。
为了接收这些事件,调试器必须调用 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。
当某个 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 调试事件处理例程发送
第三个消息需要所解释几句,为了创建 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 结构定义如下:
被加载的DLL的路径名就包含在此内存块中。MSDN 在线帮助文档是这样描述 IpImageName 的:
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 自动处理。
不论你使用哪种清除方法,每次你调试某个进程时,系统不可避免地要泄漏两个句柄:信号机(semaphore )和端口(port),两者都没有命名。为了让你确信 CApplicationDebugger 不负责处理这种泄漏,请允许我指出:用 sysinternals 的 ProcessExplorer 或 Windows Resource Kit 中的 DH.EXE 可以观察到 Visual Studio 6.0 和 Visual Studio .NET 中同样的泄漏行为。
现在你已经看到了如何用 Win32 调试 API 来获取某个进程执行期间在其地址空间中加载和卸载的 DLLs 确切列表。Windows 本身提供了另外一个途径来获取有关 DLLs 的其它详细信息。
Windows Loader 知道一切
除了 Win32 调试 API 之外,Windows 还提供另外一种很好的关于 DLL 加载地址冲突的信息源。那就是在注册表中设置的一些全局标志(或 GFlags):
从而改变 Windows 处理应用程序的方式。GFlags.exe (see Figure 8) 是一个微软调试工具之一,用它可以轻松更改上述的注册表项值。
Figure 8 全局标志
在 1999 九月的 “Under the Hood”专栏中, Matt Pietrek 解释了如何将 FLG_SHOW_LDR_SNAPS 与上述 GFlags 结合使用让 Windows Loader 产生一些有用的跟踪信息。如果你想捕获这些跟踪信息,你有两个选择:第一个是调试应用程序,然后象所做的 CApplicationDebugger 那样解释 OUTPUT_DEBUG_STRING_EVENT。另外一个方法比较容易:使用全局捕获工具。如果你想要生成自己的跟踪信息,使用 Sysinternals 或《Inside Windows 2000, Third Edition》CD 中的 DbgView,这个工具还可以显示内核跟踪信息。
在 LoadLibrarySpy 工具中,启动被调试程序之前,与被调试程序对应的 GFlags 值会被 CApplicationDebugger 的 PreLoadingProcess 更新,其以前的值会被保存在 PostMortem 中,也就是说,因为使用“LDR”作为前缀,所以调试器从 Windows Loader 得到的专用输出信息很容易在 OnOutputDebugStringDebugEvent 中过滤。
这种 Loader 日志的一个主要优点是输出信息前都有一个 LDR:自动化 DLL 重定位信息。它解释哪个 DLL 与另外的 DLL 有地址冲突。这既是 CModuleListCtrl 获取 Reason 栏数据的方法。不幸的是,Windows 2000 Loader 好像抑制这种特定的输出信息。如果你过去习惯于通过加载某个进程来存取其资源,如 explorer.exe 的动画或图标,0x400000 加载地址通常已经被你的程序使用,Loader 会自动进行重定位。在这种特殊情况下,即便是在 Windows NT 4.0,它都不会发出 LDR:为动态加载进程自动化 DLL 重定向。
另外一个解决方案是枚举每个加载的 DLL 并与专用的地址空间区域(从 hModule 开始)进行比较,从而找到冲突者(实现细节参见 CLoadLibraryDebugger::OnLoadDLLDebugEvent)。加载器还提供另外一个带前缀“LDR:Loading (DYNAMIC)”的有趣的信息,同时其后跟随模块的全路径名。当某个 DLL 被显示通过 LoadLibrary 加载时,似乎就是这种情况。
使用这些来自 Windows Loader 的线索,LoadLibrarySpy 根据加载状态为每个 DLL 提供了一个专门的图标,详情参见 Figure 9。
带方形图标的 DLLs 是在进程初始化期间加载的,称为静态加载。带圆形图标的则是在进程初始化之后加载的,因此称为动态加载。图标的颜色预示着是否有加载地址冲突,红色表示有,蓝色表示没有。
那些带黑色背景与其它动态 DLLs 之间的区别很微妙:带黑色图标的 DLL 已被加载,要么是用 LoadLibrary 显式加载,要么是用其它类似 CoCreateInstance 的 API 函数加载。没有黑色背景图标的 DLL 已被加载,因为另外一个 DLL需要它。例如,在 Figure 4 中,BROWSEUI.dll 有一个黑色图标,因为它已被动态加载。而 SHDOCVW.dll 图标没有黑色背景,因为它已被 Windows 自动加载。理由很简单:BROWSEUI.dll 是静态链接到 SHDOCVW.dll 的,所以为了加载 BROWSEUI,Windows 也得加载 SHDOCVW。
另一种“盗取”信息的途径
在结束 Win32 调试 API 的讨论之前,我想用一点点时间讨论异常处理机制。当被调试程序中有异常发生时,调试器通过 EXCEPTION_DEBUG_EVENT 收到通知,并且 u.Exception.ExceptionRecord.ExceptionCode 域中会包含此异常编码。异常编码都分布在 WINNT.H 和 WINBASE.H 文件中,因此要获得一个全面而且清晰易读的异常编码清单并不是件容易的事。CApplicationDebugger 的 GetExceptionDescription 方法将这些异常编码转换成可读性更强的字符串。
另一个异常编码清单信息源是 Visual C++ 本身。在调试应用程序时,“Debug”菜单中有一个“Exception”菜单项,它允许你选择调试器处理异常的方式,如 Figure 10 所示:
Figure 10 Exceptions Dialog
你可能会感到惊讶,在这里能发现没有定义过的异常编码。不用动手拷贝,从这个列表框“盗取”信息不是很好吗。这便是 WindowDump 的目的。它允许你用鼠标拾取某个窗口(通过其句柄值)并将信息 dump 到一个编辑框中。此外,它还能收集类信息和式样描述信息,如 Figure 11 所示。
Figure 11 WindowDump 中的异常编码
WindowDump 的背后并没有什么玄机。唯一有趣的地方是 Windows 通常允许 GetWindowText 和 WM_GETTEXT 操作不同的进程。但对于列表视图和树型视图这样的公用控件除外。Jeffrey Richter 在他的 Q&A Win32(MSJ September 1997)专栏中解释了如何 dump 另外一个进程中列表视图的内容,附带一个范例程序 LV2Clip。下面是一些 WindowDump 能盗取其内容的窗口类:Edit、ScrollBar、ListBox、ComboBox、ListView 和 TreeView。根据这些类的窗口内容,你还能得到 Figure 12 所列出的信息。
有关 WindowsDump 实现的最后一个重点是进程 ID。从窗口句柄入手,使用 GetWindowThreadProcessId 不难确定线程以及负责创建该线程的进程。如果你还想知道模块名,用 GetWindowModuleFileName 可能会碰壁。与在文档中给出的信息相反,这个 API 函数在 Windows NT、Windows 2000 或 Windows XP 下调用失败。你得钻研知识库的文章 Q228469 查明原因。
在这样情况下,你应该用 PSAPI 及其 GetModuleFileNameEx 函数。它以进程和 hModule 模块句柄为参数,返回对应的路径名。为了查出某一个进程的可执行文件路径名,hModule 应该为 0。不要使用 0x400000:某些进程被加载到不同的地址,如 winlogon 和 Task Manager 在 0x1000000,ntvdm 在 f000000 以及Microsoft Word 2000 在 0x30000000。
摘要
在 Windows 系统中,动态库版本冲突实在是一个老大难的问题了,为了解决这个问题,除了使用大量现有的工具外,你还可以利用丰富的 Windows APIs 函数构造自己的调试工具和实用程序。作为例子,本文将提供三个这种类型的工具,讨论如何利用它们来解决动态库的冲突问题。这三个工具分别是:
DllSpy——列出加载到系统中的所有 Dlls 以及所有使用这些 Dlls 的进程;
ProcessSpy——列出系统中所有正在运行的进程以及它们正在使用的 Dlls;
ProcessXP——显示 Windows XP 所有并发运行的会话(Sessions)清单;
从所周知,动态库“地狱”(DLL Hell)已经不是什么新鲜玩意儿了,如果你使用第三方的 Dlls,肯定会碰到不少与它有关的问题,如找不到入口点,或者库版本不兼容等。.NET 中允许组件的并行执行,减少了产生这种问题的几率,但是如果你还没有升级到 .NET 环境,那怎么办?针对这种情况,可用的方法是用不同的工具跟踪 DLL 的依赖性。但是用标准工具跟踪时,你可能最后得不到所要的信息。许多工具都没有你需要的功能,比如自动写日志文件,跟踪分析,仅在控制台操作脚本控制等。
本文我们先用一些现有的工具来考察系统中的运行进程,然后系统地研究本文提供的三个工具:DllSpy, ProcessSpy 和 ProcessXP,以便在今后的开发或调试中使用这些工具和技术。
现有的工具
Depends.exe 是 Visual C++ 自带的一个工具。它可能是我们经常使用的工具中最简单的一个工具了,其功能是列出某个应用程序或 DLL 需要的 DLLs。这个程序在本站可以下载(更新版本请到下面这个地址下载:http://www.dependencywalker.com)。如果你需要看某个 DLL 或可执行文件的全路经,可以用它的上下文菜单进行设置:如图一:
图一 察看全路经
对于静态加载的情况(即应用程序在链接过程中将 dlls 对应的 lib 文件链接到程序中),这个工具非常好用,但对于版本较新的系统,大多使用 COM 编程接口,或者说是用 COM 对象编程模型,而 COM 对象的实例化都是运行时加载或者说动态加载某个 DLL 文件,然后通过 LoadLibrary 和 GetProcAddress 调用其中某个特殊的函数来实现的。你不知道这个 DLL 是何时、从哪里被加载的。
一种确定 DLLs 被动态加载的方法是找出需要被每一个进程加载的 DLL。Sysinternal 公司(http://www.sysinternals.com)提供了一个工具软件 ListDlls.exe。它是一个控制台程序,其图形用户界面(GUI)版本为 Process Explorer。如图二:
图二 Process Explorer 运行画面
除了列出被某个进程使用的 DLLs 之外,还可以用这个工具了解某个程序用到了哪个 kernel 对象,从版本3.11之后,Process Explorer 还可以让你在两个快照之间轻松扫描到新的或未使用的对象。
有时候在你用 Process Explorer 扫描到某个进程之前,它可能已经被加载然后又在很短的时间内被卸载了。碰到这种情况时,你需要另外一种类型的工具,我们将在后文中讨论。
为了操纵进程和 DLLs,首先你必须知道每一个被加载的 DLL 被哪些进程使用。本文的例子程序 DllSpy 实现目的即在于此。如图三所示:
图三 DllSpy 运行画面
DllSpy程序上面的窗格列出的是所有已经加载的 DLL,每选中一个DLL,在下面的窗格中就会列出使用该 DLL 的所有进程。
而 ProcessSpy 例子程序的功能正好与 DllSpy 相反,它在上面窗格列出系统中所有的运行进程,每选中一个进程,在下面窗格便显示出此进程使用的所有 DLLs,如图四所示:
图四 ProcessSpy 运行画面
下面窗格还反映了 DLL 加载的地址是实际地址还是首选地址,以及它们的从属性是静态的还是动态的。这些工具的源代码和可执行程序都可以从本文的下载链接中下载,它们也许不完全满足你的需要,但可以作为技术参考,对编程工作肯定是有所裨益的。
如何获取运行进程列表
有三种方法来获取Win32运行进程的信息,参见表一:
(表一)
方法 | 平台 | 备注 |
PSAPI | Windows NT,Windows2000,Windows XP | 获取进程,驱动器,模块,内存和工作集信息 |
性能计数器 | Windows NT,Windows2000,Windows XP | 提供除进程清单以外的关于进程的更多信息,可在远程机器上使用。 |
TOOLHELP32 | Windows 9x,Windows2000,Windows XP | 获取进程,线程,模块和堆信息 |
进程状态 API(PSAPI 全称是 Process Status API)是微软 SDK 中一个很有用的工具,在例子程序中有一个类 CProcessList,其实现文件 Process.cpp 对 PSAPI 进行了打包,用这个类可以获取进程清单。只要 Refresh 一被调用,通过某个进程的ID便可获得此进程的描述信息,并很容易用 GetFirst 和 GetNext 列举出其它进程:
//用 CProcessList 列出运行进程 // 一个挨一个获取进程 CProcess* pProcess = NULL; POSITION Pos = 0; for ( pProcess = ProcessList.GetFirst(Pos); (pProcess != NULL); pProcess = ProcessList.GetNext(Pos) ) { if (pProcess != NULL) { // 对进程信息进行处理 } }
Refresh 的实现用到了 EnumProcesses 函数(在PSAPI中):
//刷新进程列表 void CProcessList::Refresh() { // 不要忘了重置和释放当前的进程列表 DefaultReset(); // 存储当前进程列表 DWORD aProcesses[MAX_PROCESS]; DWORD cbNeeded = 0; // 获取进程快照 if (!g_PSAPI.EnumProcesses(aProcesses, sizeof(aProcesses), &cbNeeded)) return; // 计算返回了多少个进程IDs DWORD cProcesses = cbNeeded / sizeof(DWORD); // 将CProcess 对象捆绑到每一个进程ID DWORD dwProcessID; CProcess* pProcess; for ( DWORD dwCurrentProcess = 0; dwCurrentProcess < cProcesses; dwCurrentProcess++ ) { dwProcessID = aProcesses[dwCurrentProcess]; // 将进程信息添加到映射 pProcess = new CProcess(TRUE); if (pProcess != NULL) { // 填写当前进程ID的进程信息--捆绑 if (!pProcess->AttachProcess(aProcesses[dwCurrentProcess])) delete pProcess; else // 存到映射表中 m_ProcessMap[(LPVOID)dwProcessID] = pProcess; } } // 第二次循环需要知道此进程的子进程清单 SetChildrenList(); }
如果你至今还在支持 Windows 的 16 位代码,比如在任务管理器的 ntvdm.exe 项下列出的应用程序就属于此列,那么枚举进程更棘手。有关细节参见本文参考资料中的知识库文章,以及 Matt Pietrek 的专栏文章:August 1998 和 September 1998。
如何获取进程信息
有了运行进程的列表,接下来就是根据 EnumProcesses 返回的进程IDs尽可能多的获取每一个进程的详细信息,然后根据这些信息建立有用的工具。用PROCESS_QUERY_INFORMATION | PROCESS_VM_READ作为参数,调用OpenProcess获取进程句柄,然后用AttachProcess(参见Process.cpp文件中的CProcess类实现)方法创建进程描述。表二中列出的是CProcess用于获取进程细节信息的函数:
(表二)
方法 | 描述 |
GetName | 以NULL作为参数,调用 GetModuleBaseName ,最后去掉扩展名 “.EXE” |
GetFileName | 以NULL作为参数,调用 GetModuleFileNameEx |
GetMainWindowHandle | 参见GetMainWindowHandle |
GetMainWindowTitle | |
GetParentProcessID | 用ProcessBasicInformation作为参数调用NtQueryInformationProcess |
GetKERNELHandleCount | 用ProcessHandleCount作为参数调用NtQueryInformationProcess |
GetUSERHandleCount | 用GR_USEROBJECTS作为参数调用GetGuiResources |
GetGDIHandleCount | 用GR_GDIOBJECTS作为参数调用GetGuiResources |
GetWorkingSet | 调用GetProcessMemoryInfo |
GetCmdLine | 参见GetProcessCmdLine |
GetOwner | 参见GetProcessOwner的细节 |
GetSessionID | ProcessIdToSessionId (参见对快速用户转换的讨论部分——Windows XP的一个新特性) |
GetModuleList | CModuleList是一个对EnumProcessModules 和GetModuleFileNameEx的打包类 |
GetChildrenCount 以及子进程清单 | 要获取某个进程的子进程列表,目前还没这样的API(即便有也未公开)可供使用。但是,因为某个进程的父进程是已知的,所以将某个进程加到其父进程的子进程列表中不难(参见SetChildrenList的实现) |
注意PSAPI中的GetModuleFileNameEx函数返回的文件名很奇怪:如:"/SystemRoot/ System32/ smss.exe"或者"/??/C:/WINNT/system32/winlogon.exe"等等。谁知道这是什么意思?在Helper.cpp中有一个函数TranslateFilename专门对此进行转换,将这些文件名转换成更容易理解的名字。稍候会我们还会谈到这个函数。
接下来我们讨论如何寻找某个进程的主窗口,EnumWindows有一个参数是回调函数,此回调函数的作用是接收顶层窗口句柄,在这个问调函数中,我们要调用GetWindowThreadProcessId来获取创建相应窗口的进程ID,如果找到这个窗口(可见的)便停止枚举(详情请参见GetMainWindow实现)。函数GetWindowText可以被用来获取某个不同进程的窗口标题。
在Windows NT 和Windows 2000里,为了获取与创建某个特定窗口的进程对应的文件名字,不能像以前那样用GetWindowModuleFileName函数,你会毫无所获,这个函数总是返回当前运行进程的路径名。
获取某个进程主窗口的详细过程描述可以参考Jeff Prosise在MSJ Aug99上的Wicked Code专栏文章。现在你已经知道了如何通过某个已知的进程ID,调用PSAPI函数来获取全路径名。然后利用这个路径名并调用GetWindowThreadProcessID函数获取创建某个特定窗口的进程文件名。
在AttachProcess中必须调用OpenProcess来获取大多数的进程信息,但是有可能出现拒绝访问的错误。如果出现这种情况,我用了一个Keith Brown给出的方法,参见他在MSJ Aug99中的"Security Briefs"专栏文章,其中详细讨论了如何用高级别权限获取进程句柄。细节请参见例子代码的Helpers.cpp文件,其中有一个函数名叫GetProcessHandleWithEnoughRights,就是出自Keith Brown之手。
当某个进程是作为另外一个用户账号计划任务而运行的时候,就会出现上述提到的拒绝访问问题。即便是Windows任务管理器都无法终止这样的进程,它只显示一个象下面这样的对话框。如图九:
图九 无法终止的进程
如果你在ProcessSpy例子程序里的某个进程上双击鼠标按键,它将终止这个进程。你看一下在例子代码中的SlayProcess函数就会知道。此辅助函数调用了GetProcessHandleWithEnoughRights来获取进程句柄,但访问权限参数是PROCESS_TERMINATE,而不是在AttachProcess里所用的PROCESS_QUERY_INFORMATION | PROCESS_VM_READ。
最后是用GetProcessOwner获取进程运行的用户账号(格式为//Domain/User),通过用TokenUser作为参数调用GetTokenInformation,然后用LookupAccountSid将返回的用户SID转换为人可读的域名和用户名。有时OpenProcessToken会因为遇到象System这样的进程而调用失败,甚至是Windows 2000 资源开发包中的PULIST.EXE遇到这种情况都无法显示出拥有进程的用户。只有ProcessExplorer(Sysinterals公司开发的一个工具软件)能成功找到此"安全的"应用的所有者。本文稍后会讨论Windows XP中如何用WTS APIs(也就是Windows Terminal Services API--Windows终端服务API)来获取进程的宿主。
|
任何时候通过 PSAPI 或 TOOLHELP32 都可以列出某个进程加载的 DLLs 列表。在写此文前的调研过程中,我研究了 Matt Pietrek 以前在 MSJ Under The Hood 专栏中的一篇文章,其内容是讨论如何使用 TOOLHELP32 来实现前述的功能,我发现在 Windows 2000 和 Windows XP 环境中是有问题的,代码不能正常工作,现将其代码摘录如下:
用TOOLHELP32遍历模块
// //通过取得ToolHelp32 进程快照,枚举此进程的模块列表 // HANDLE hSnapshotModule; hSnapshotModule = pfnCreateToolhelp32Snapshot( TH32CS_SNAPMODULE, procEntry.th32ProcessID ); if ( !hSnapshotModule ) continue; // 迭代快照中每一个模块 MODULEENTRY32 modEntry = { sizeof(MODULEENTRY32) }; BOOL fModWalkContinue; for (fModWalkContinue = pfnModule32First(hSnapshotModule,&modEntry); fModWalkContinue; fModWalkContinue = pfnModule32Next(hSnapshotModule,&modEntry) ) { // 确定是否为EXE文件本身,如果是,则不将它加入模块列表 if ( 0 == stricmp( modEntry.szExePath, procEntry.szExeFile ) ) continue; // 确定是否为我们已有的DLL PModuleInstance pModInst = modList.Lookup(modEntry.hModule, modEntry.szExePath ); // 如果以前没有见过,则将它加入列表 if ( !pModInst ) pModInst = modList.Add( modEntry.hModule, modEntry.szExePath ); // 将此进程加入到使用此DLL的进程列表 pModInst->AddProcessReference( procEntry.th32ProcessID ); } CloseHandle( hSnapshotModule ); // 完成模块列表快照其实并不是程序有什么瑕疵,主要是时过境迁,导致代码中一个if语句的使用无效,毕竟 Matt Pietrek 写那篇文章的时候(其代码是1998.9 在 MSJ 上发布的),Windows 2000 还不知道在哪里呢!
那个无效的 if 语句是这样的:由于 CreateToolhelp32Snapshot 调用失败时不会返回 NULL,所以下面的错误处理代码是无效的:
if ( !hSnapshotModule ) continue;实际上,如果失败,hSnapshotModule的值为INVALID_HANDLE_VALUE或-1,并且这个if语句是捕获不到它的,这到没什么,关键是如何发现这个bug。当我在Windows 2000上测试ProcessSpy时,一切运行正常,只是当列表框即便为空的时候,程序也没有返回某些进程的出错信息。由于错误处理代码本身是错的,执行跳过了循环,Module32First调用失败,但没有任何实质性的错误。如果你在Windows 2000环境用Matt Pietrek的这篇文章提供的ModuleList工具,你将得到不正确的结果。
为了搞清楚代码运行中发生的事情,用本文实例代码包含的Helpers.cpp 文件中提供的GetLastErrorMessage辅助函数可以有助于你看得更清楚。他调用GetLastError 和 FormatMessage以纯文本形式获取相应的失败原因。失败原因都一样:Access Denied,也就是拒绝存取。但是使用PSAPI函数时,当获得相同进程的模块列表时不存在存取问题。
之所以发生存取问题,是由于缺乏优先级。使用TOOLHELP32 的代码要正常工作必须得有 SE_DEBUG_NAME 优先级。有关这个问题的详细信息,请参考 1998.3 MSJ 的 Q&A Win32 专栏以及 1999.8 的 Security Briefs 专栏
关于 DLL 的方方面面
用 PSAPI 和 TOOLHELP32 两种途径获得的某个进程所加载的模块列表只反映地址,在这个地址处,DLL被映射到地址空间。下一步便是尽可能完整地获取关于DLL的描述。我的实现并不象在CProcess中所做的那样提供单独的 AttachModule 方法。因为要获取某些细节信息代价实在太高,因此我选择将它们分割成不同的函数。最不值钱的信息从 CModule 的构造函数获得,其它信息的获取要到相应的存取器方法被调用(通过 Refresh 函数)。实现细节请参考 Module.cpp 文件。其 Refresh 方法模仿了 Matt Pietrek 的 CModuleList 中的Refresh/RefreshTOOLHELP32 方法。表三列出了 CModule 的存取器方法:
存取器 | 说明 |
HMODULE GetModuleHandle | DLL被映射的地址 |
CString& GetFullPathName | 源自TOOLHELP32::Module32xxx 或PSAPI::GetModuleFilenameEx |
CString& GetPathName | 同GetFullPathName |
CString& GetModuleName | 同GetFullPathName |
我用 Refresh 方法采集其余的模块描述,具体实现请参考 Module.cpp 文件,下面是对它的一个概述,详细的存取函数见表四:
存取器 | 说明 |
DWORD GetBaseAddress | 使用PE_EXE::GetImageBase 来获得首选的加载地址 |
void GetFileTime(FILETIME& ft) | 用KERNEL32.DLL 输出的API GetFileTime来获悉何时被创建、修改和最后一次存取 |
CString& GetFileTime | 获得与上一个函数相同的信息,但这里是文本格式,使用GetFileDateAsString/GetFileTimeAsString 辅助函数 |
DWORD GetFileSize | 用PE_EXE::GetFileSize 获取文件的大小,以字节为单位 |
CString& GetSubSystem | 用PE_EXE::GetSubSystem 获悉IMAGE_SUBSYSTEM_xxx模块子系统之一,在winnt.h 文件中定义,在这个文件的最新版本中可以找到IMAGE_SUBSYSTEM_XBOX |
void GetLinkTime(FILETIME& ft) | 用PE_EXE::GetTimeDateStamp 获取模块的链接时间 |
CString& GetLinkTime | 获得与上一个函数相同的信息,但这里是文本格式,使用GetFileDateAsString/GetFileTimeAsString辅助函数 |
WORD GetLinkVersion | 用PE_EXE::GetLinkerVersion 获取用于构造此模块的链接器版本 |
如果你想了解更多有关 PE 文件的细节,请阅读 Matt Pietrek 的这些文章,其中重点是 PE_EXE 类和 PEDUMP 实现。其代码对于诸位具有很高的参考价值。
GetBaseAddress 一个有趣的使用方法是将它的返回值与 GetModuleHandle 的返回值进行比较。后者是实际的地址,正是在这个地址,模块被加载到进程地址空间里,而前者的地址是模块希望被加载的地址。这正好用来发现加载是否冲突。
当一个进程启动时,Windows 加载程序自动加载静态DLLs。这些静态链接的东西很容易用PE格式和 MODULE_DEPENDENCY_LIST 类通过编程获得。没有哪个API能扫描到这些模块与那些用 LoadLibrary 或 CoCreateInstance 动态加载的模块之间的差别。如果一个DLL被某个进程使用,但它又不在静态链接之列,那么它就应该是动态加载的。
在 ProcessSpy 的输出画面中,如图四,底下的窗格中每一个模块都有一个前缀图符,圆形的D表示动态加载的,方形的S表示静态加载的。它们的颜色也有不同的意思,红色表示这个模块的基地址与其加载地址是不同的,反之则为浅蓝色。
除了从文件本身吸取描述信息外,还可以从它的资源版本中获取其它描述信息。Paul DiLascia 在他的 C++Q&A 专栏(MSJ 1998.4)文章中为我们提供了一个很帅的打包类 CModuleVersion,用这个类可以方便地获得资源版本中对模块的描述信息。对于每一项VS_VERSION_INFO 细节都有存取函数,这些函数返回 CString 引用,都是由 CModuleVersion::GetFileVersion 用相应的串填写。GetCompanyName 就是一个很好的例子。
为了满足我的需要,我对 Paul DiLascia 的代码进行了修改。GetFileVersionInfo 方法应该得到模块的名字,而不是真正的文件名。为了获取相应的文件名,调用 GetModuleHandle。如果在当前的进程空间中查找模块失败(这种情况罕见)。为了解决这个问题,当给定的模块名就是实际的执行文件名时(用 GetFileAttributes 可以判断出来),则直接使用它即可。
Windows 提供的资源信息不仅仅限于公司名这么简单,通常还有更多的东西,例如,从中可以很容易知道应用程序是否为Debug版本,是否是私隐或特别版。你必须看一下 VS_FIXEDFILEINFO 结构中的 dwFileFlags 标志。MSDN文档对它的描述是包含一个位码(bitmask)值,这些位码值的含义请参考表五:按照版本信息对文件进行分类:
(表五)
标志 | 描述 |
VS_FF_DEBUG | 包含调试信息或者编译时是按可调试方式编译的 |
VS_FF_INFOINFERRED | 动态创建版本结构,因此这个结构中的某些成员可能为空或不正确。在文件的VS_VERSIONINFO数据中决不能设置此标志 |
VS_FF_PATCHED | 已经被修改并且与原来同一版本号的文件不相同了 |
VS_FF_PRERELEASE | 开发版本,非商业发布产品 |
VS_FF_PRIVATEBUILD | 没有用标准的发布过程构造,如果设置了此标志,则StringFileInfo 结构应该包含PrivateBuild 项 |
VS_FF_SPECIALBUILD | 由原公司用标准的发布过程构造,但是相同版本号的标准文件的变种。如果设置此标志,则StringFileInfo 结构应该包含SpecialBuild 项 |
(表六)
标志 | 描述 |
VFT_UNKNOWN | 系统未知 |
VFT_APP | 包含一个应用程序 |
VFT_DLL | 包含一个动态链接库(DLL) |
VFT_DRV | 包含一个设备驱动程序,如果dwFileType 是VFT_DRV,则dwFileSubtype 包含进一步的关于此驱动程序的描述 |
VFT_FONT | 包含一种字体,如果dwFileType 是VFT_FONT,则dwFileSubtype 包含进一步的字体文件描述 |
VFT_VXD | 包含一个虚拟设备 |
VFT_STATIC_LIB | 包含一个静态链接库 |
下一回内容预告
本文以后的内容将讨论几种用非常规方式来获取一些附加的信息源。也就是说如果在没有可借助的 API 的情况下,你就可以用这几种非常规方式。其中包括我至今未曾提到的一个主要信息源,那就是 Windows 的外壳(Shell)。在模块文件中隐藏一个文件的时候, 关于某个文件的信息,没有人比 Windows 资源管理器知道的更多。如图十八所示:
图十八 用资源管理器查看文件信息
那么如何从自己的程序中打开或者调用 Windows 资源管理器文件属性对话框呢?关于这个请参考精华区的一小段代码。其关键是先填写 SHELLEXECUTEINFO 结构,注意结构中的 fMask 成员一定要用 SEE_MASK_INVOKEIDLIST 赋值,然后调用 ShellExecuteEx API 函数,如:
SHELLEXECUTEINFO sei; ZeroMemory(&sei,sizeof(sei)); sei.cbSize = sizeof(sei); sei.lpFile = szFilename; sei.lpVerb = _T("properties"); sei.fMask = SEE_MASK_INVOKEIDLIST; ShellExecuteEx(&sei);
在 ProcessSpy 程序界面底部窗格中任何一个模块记录上双击鼠标便可以调出文件的属性对话框,相应模块文件的描述信息一目了然。注意 Windows XP 中不支持多个 ShellExecuteEx 调用,当你调用第二次时,线程冻结,也不会有任何提示。
正如你所看到的,有许多方法都可以获得加载 DLLs 以及活动进程的信息。我在本文中提供的几个工具可以作为一个很好的学习开端,你完全可以借鉴文本描述的方法以及所提供的 C++ 类来定制满足自己需要的调试工具。
本文前面的部分讨论了如何用有着良好文档描述的 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 所示。
为了接收这些事件,调试器必须调用 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。
当某个 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 调试事件处理例程发送
第三个消息需要所解释几句,为了创建 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 函数调用的调试事件中提供这个信息。” |
防止泄漏
文档中关于 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 自动处理。
不论你使用哪种清除方法,每次你调试某个进程时,系统不可避免地要泄漏两个句柄:信号机(semaphore )和端口(port),两者都没有命名。为了让你确信 CApplicationDebugger 不负责处理这种泄漏,请允许我指出:用 sysinternals 的 ProcessExplorer 或 Windows Resource Kit 中的 DH.EXE 可以观察到 Visual Studio 6.0 和 Visual Studio .NET 中同样的泄漏行为。
现在你已经看到了如何用 Win32 调试 API 来获取某个进程执行期间在其地址空间中加载和卸载的 DLLs 确切列表。Windows 本身提供了另外一个途径来获取有关 DLLs 的其它详细信息。
Windows Loader 知道一切
除了 Win32 调试 API 之外,Windows 还提供另外一种很好的关于 DLL 加载地址冲突的信息源。那就是在注册表中设置的一些全局标志(或 GFlags):
HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Windows NT/CurrentVersion/Image File Execution Options
从而改变 Windows 处理应用程序的方式。GFlags.exe (see Figure 8) 是一个微软调试工具之一,用它可以轻松更改上述的注册表项值。
Figure 8 全局标志
在 1999 九月的 “Under the Hood”专栏中, Matt Pietrek 解释了如何将 FLG_SHOW_LDR_SNAPS 与上述 GFlags 结合使用让 Windows Loader 产生一些有用的跟踪信息。如果你想捕获这些跟踪信息,你有两个选择:第一个是调试应用程序,然后象所做的 CApplicationDebugger 那样解释 OUTPUT_DEBUG_STRING_EVENT。另外一个方法比较容易:使用全局捕获工具。如果你想要生成自己的跟踪信息,使用 Sysinternals 或《Inside Windows 2000, Third Edition》CD 中的 DbgView,这个工具还可以显示内核跟踪信息。
在 LoadLibrarySpy 工具中,启动被调试程序之前,与被调试程序对应的 GFlags 值会被 CApplicationDebugger 的 PreLoadingProcess 更新,其以前的值会被保存在 PostMortem 中,也就是说,因为使用“LDR”作为前缀,所以调试器从 Windows Loader 得到的专用输出信息很容易在 OnOutputDebugStringDebugEvent 中过滤。
这种 Loader 日志的一个主要优点是输出信息前都有一个 LDR:自动化 DLL 重定位信息。它解释哪个 DLL 与另外的 DLL 有地址冲突。这既是 CModuleListCtrl 获取 Reason 栏数据的方法。不幸的是,Windows 2000 Loader 好像抑制这种特定的输出信息。如果你过去习惯于通过加载某个进程来存取其资源,如 explorer.exe 的动画或图标,0x400000 加载地址通常已经被你的程序使用,Loader 会自动进行重定位。在这种特殊情况下,即便是在 Windows NT 4.0,它都不会发出 LDR:为动态加载进程自动化 DLL 重定向。
另外一个解决方案是枚举每个加载的 DLL 并与专用的地址空间区域(从 hModule 开始)进行比较,从而找到冲突者(实现细节参见 CLoadLibraryDebugger::OnLoadDLLDebugEvent)。加载器还提供另外一个带前缀“LDR:Loading (DYNAMIC)”的有趣的信息,同时其后跟随模块的全路径名。当某个 DLL 被显示通过 LoadLibrary 加载时,似乎就是这种情况。
使用这些来自 Windows Loader 的线索,LoadLibrarySpy 根据加载状态为每个 DLL 提供了一个专门的图标,详情参见 Figure 9。
带方形图标的 DLLs 是在进程初始化期间加载的,称为静态加载。带圆形图标的则是在进程初始化之后加载的,因此称为动态加载。图标的颜色预示着是否有加载地址冲突,红色表示有,蓝色表示没有。
那些带黑色背景与其它动态 DLLs 之间的区别很微妙:带黑色图标的 DLL 已被加载,要么是用 LoadLibrary 显式加载,要么是用其它类似 CoCreateInstance 的 API 函数加载。没有黑色背景图标的 DLL 已被加载,因为另外一个 DLL需要它。例如,在 Figure 4 中,BROWSEUI.dll 有一个黑色图标,因为它已被动态加载。而 SHDOCVW.dll 图标没有黑色背景,因为它已被 Windows 自动加载。理由很简单:BROWSEUI.dll 是静态链接到 SHDOCVW.dll 的,所以为了加载 BROWSEUI,Windows 也得加载 SHDOCVW。
另一种“盗取”信息的途径
在结束 Win32 调试 API 的讨论之前,我想用一点点时间讨论异常处理机制。当被调试程序中有异常发生时,调试器通过 EXCEPTION_DEBUG_EVENT 收到通知,并且 u.Exception.ExceptionRecord.ExceptionCode 域中会包含此异常编码。异常编码都分布在 WINNT.H 和 WINBASE.H 文件中,因此要获得一个全面而且清晰易读的异常编码清单并不是件容易的事。CApplicationDebugger 的 GetExceptionDescription 方法将这些异常编码转换成可读性更强的字符串。
另一个异常编码清单信息源是 Visual C++ 本身。在调试应用程序时,“Debug”菜单中有一个“Exception”菜单项,它允许你选择调试器处理异常的方式,如 Figure 10 所示:
Figure 10 Exceptions Dialog
你可能会感到惊讶,在这里能发现没有定义过的异常编码。不用动手拷贝,从这个列表框“盗取”信息不是很好吗。这便是 WindowDump 的目的。它允许你用鼠标拾取某个窗口(通过其句柄值)并将信息 dump 到一个编辑框中。此外,它还能收集类信息和式样描述信息,如 Figure 11 所示。
Figure 11 WindowDump 中的异常编码
WindowDump 的背后并没有什么玄机。唯一有趣的地方是 Windows 通常允许 GetWindowText 和 WM_GETTEXT 操作不同的进程。但对于列表视图和树型视图这样的公用控件除外。Jeffrey Richter 在他的 Q&A Win32(MSJ September 1997)专栏中解释了如何 dump 另外一个进程中列表视图的内容,附带一个范例程序 LV2Clip。下面是一些 WindowDump 能盗取其内容的窗口类:Edit、ScrollBar、ListBox、ComboBox、ListView 和 TreeView。根据这些类的窗口内容,你还能得到 Figure 12 所列出的信息。
有关 WindowsDump 实现的最后一个重点是进程 ID。从窗口句柄入手,使用 GetWindowThreadProcessId 不难确定线程以及负责创建该线程的进程。如果你还想知道模块名,用 GetWindowModuleFileName 可能会碰壁。与在文档中给出的信息相反,这个 API 函数在 Windows NT、Windows 2000 或 Windows XP 下调用失败。你得钻研知识库的文章 Q228469 查明原因。
在这样情况下,你应该用 PSAPI 及其 GetModuleFileNameEx 函数。它以进程和 hModule 模块句柄为参数,返回对应的路径名。为了查出某一个进程的可执行文件路径名,hModule 应该为 0。不要使用 0x400000:某些进程被加载到不同的地址,如 winlogon 和 Task Manager 在 0x1000000,ntvdm 在 f000000 以及Microsoft Word 2000 在 0x30000000。
相关文章推荐
- BlackBerry网络诊断程序,调试网络必备工具
- 的引用(请使用 TLBIMP 实用工具来引用 COM DLL): 提供程序 DLL 未能正确初始化。----------解决方法
- 定制调试诊断工具和实用程序
- 定制自己的Log日志工具以及调试程序
- vc++6.0环境下如何调试DLL程序
- 微信小程序开发工具调试界面鼠标看不见
- 程序调试工具
- 非常实用的mobile web application远程调试工具 weinre
- 回归简单实用的java调试工具JDB
- 实用工具集合(包括css/js/html压缩格式化,json相关,编码/加密,调试工具等)
- 摆脱DLL"地狱"的困扰之获取进程的命令行
- 淘宝的流氓DLL.调试程序的时候都加载上来.
- 使用IE8开发工具调试时,遇到“无法附加进程,进程可能附加了另一个调试程序”的解决方案
- LINUX学习笔记7——使用GDB程序调试工具
- 程序开发工具(Java反编译及Linux等)与调试技巧(eclipse,linux)部分数据库插件
- 如何摆脱开发板在本机实用printk调试模块
- VC中如何调试DLL程序
- PHPLog php 程序调试追踪工具
- 使用单元测试工具TestDriven.NET调试程序
- 解决Jetbrains的开发工具不能运行或调试(run/debug)go/golang程序[Intellj Idea,Webstorm,PyCharm,PhpStrom,Android Studio]