[翻译]API hooking revealed(自己翻译的)
发表于:
2011-6-26 22:25
8264
[翻译]API hooking revealed(自己翻译的)
来看雪都一年多了,至今还没转正。光看和下资源了,至今还没100Kx。来看雪首发一个自己翻译的英文文献,感觉对钩子的初学者还蛮有用的。申请邀请码,不足之处高手勿喷。直接从自己的doc copy过来的,格式可能有点问题,图请查看原文,或下载附件的doc。
原文:http://www.codeproject.com/KB/system/hooksys.aspx
API hooking解密
——《API hooking revealed》译文
Ivo Ivanov 原著
简介
拦截Win32 API的调用对于大多数Windows程序的开发人员来说一直是一个充满挑战的课题。我不得不承认,这也是我最爱的课题之一。术语“Hooking”是一种控制特定代码段的执行的基础技术。它提供了一种简单的机制,使得不需要源代码就能轻易地改变操作系统和第三方产品的行为。
许多现代系统将注意力放在通过采用监控技术来利用现有的Windows应用程序的能力上。而Hooking的主要目的不仅是有助于更高级的功能的实现,还有注入自定义代码以实现调试。
不像一些相对较老的操作系统,如DOS、Windows 3.xx,现在的Windows操作系统,如NT/2K、9x,提供了复杂的机制来隔离每个进程的地址空间。这种架构提供了一种内存保护,使得没有一个进程能破坏另一个进程的地址空间,或者出现更糟的情况,破坏操作系统自身。但这也使得更难开发系统级的钩子。
我写这篇文章主要是因为我需要一个真正简单的Hooking框架,它能提供易用的接口和拦截各种API。本文将介绍一些能帮助你编写自己的监控系统的技巧,并且提供了一个单一的解决方案,用于在NT/2K和98/Me(在文中统称为9x)系列Windows上建立一组拦截Win32 API的函数。为了使代码更简单,我决定不加入对UNICODE的支持。然而,你可以通过修改少量的代码来达到支持UNICODE的目的。
监控应用程序的好处有:
监控API的调用
监控API的调用非常有用,它能使开发人员跟踪到API调用过程中一些特殊的“不可见”的动作。这有助于参数验证以及报告那些隐藏的错误。例如通过监控与内存相关的API函数有助于找出内存泄漏的地方。
调试和逆向工程
除了传统的调试方法外,API hooking是首屈一指的调试机制。很多开发人员使用API hooking来分析组件的实现及它们之间的相互关系。拦截API是一种获取二进制可执行文件信息的有效方法。
深入研究操作系统
开发人员经常想深入研究操作系统,并且从调试过程中获得了部分这方面的知识。Hooking也是一种破译没有文档化或者相关文档很少的API的有用方法。
扩展现有功能
在定制的模块中使用Hooking,将模块加载到现有应用中可以轻松改变和扩展现有功能。例如很多第三方产品并不满足特定的安全要求,因此它们不得不进行调整以满足这些要求。Hooking允许开发人员围绕原始的API,在其处理前或处理后增加其他处理。这种能力对改变已编译代码的行为非常有用。
Hooking系统的功能需求
在你实现任何类型的API hooking系统之前,需要做出几点很重要的决策。首先你应该决定是要挂钩(hook)某个应用程序,还是要挂钩整个系统。例如,如果你只想监控某个应用程序,你不必安装一个系统级钩子(hook),但如果你是要监控所有对TerminateProcess()或WriteProcessMemory() API的调用,就需要一个系统级钩子。选择何种实现取决于特定的情况和问题。
API监控框架的一般设计
通常一个Hooking系统至少由两部分组成——Hook Server(钩子服务端)和Driver(钩子驱动)。Hook Server负责在适当的时候将Driver注入到目标进程。同时它对Driver进行管理,并从中获取Driver的活动信息,而Driver实现真正的拦截。
这种粗略的设计当然没有囊括所有可能的实现方式,但它勾勒出了一个Hooking框架。
一旦你有了一个Hooking框架的需求说明书,那么有以下几点需要考虑:
需要挂钩哪些应用程序
怎样将DLL注入目标进程
需要使用哪种拦截机制
我希望下面的章节能解答这些问题。
注入技术
注册表
为了注入一个DLL到链接了USER32.DLL的进程,你可以简单地在下面的注册表键中添加DLL的名称作为值:
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
它的值应该包含一个单独的DLL的名称或者一组以逗号或者空格分隔的DLLs的名称。MSDN文档指出,该键值指定的DLLs将被每一个基于窗口的(Windows-based)应用程序加载。有趣的是这些DLLs实际上被当作USER32初始化的一部分被加载。USER32读取这些键值,并在它的DllMain中通过调用LoadLibrary()来加载这些DLLs。但这种方法只能用在使用了USER32.DLL的应用程序上。另外一个限制是这种内建的机制仅在NT和2k的操作系统上支持。尽管这是一种无害的DLL注入方式,但它依然有如下缺点:
需要重启来激活
你想注入的DLL仅能映射到使用了USER32.DLL的进程。你不能期望它能注入到控制台应用程序,因为它们通常不用从USER32.DLL中导入函数。
另一方面你不能控制注入。这意味着不管你想不想,它将注入每个GUI应用程序。如果你只想注入部分应用程序的话这将是个冗余负担。
系统范围的Windows Hooks
另一种流行的DLL注入方法是Windows Hooks提供的。如MSDN所述,钩子是系统消息处理机制的一个陷阱。应用程序可以安装一个自定义的过滤函数来监控消息在系统中的传递,并在它们到达目标窗口处理过程前,先处理特定类型的消息。
为了满足一个系统范围的钩子的基本要求,通常一个钩子在一个DLL中实现。这样要求是因为一个钩子的回调例程是在被挂钩的应用程序的地址空间中执行的。安装一个钩子你需要正确调用SetWindowsHookEx()。一旦应用程序安装了一个系统范围的钩子,操作系统就会将DLL映射到它的每一个客户端进程的地址空间中去。这样DLL中的全局变量将会在每个被挂钩的进程中各持一份而不能共享。所有包含共享数据的变量必须放在一个共享数据段中。图1展示了一个例子,Hook Server将DLL注入到Application one和Application two的地址空间。
图1
当SetWindowsHookEx()被执行的时候一个系统范围的钩子被注册。如果没有错误发生的话将返回该钩子的句柄。在自定义的钩子函数的最后,调用CallNextHookEx()时需要用到该句柄。当成功调用SetWindowsHookEx()后,操作系统将自动地(但不一定立刻)注入DLL到所有满足特定的钩子过滤器的进程中。让我们仔细看下WH_GETMESSAGE钩子过滤器的函数:
//---------------------------------------------------------------------------
// GetMsgProc
//
// Filter function for the WH_GETMESSAGE - it's just a dummy function
//---------------------------------------------------------------------------
LRESULT CALLBACK GetMsgProc(
int code, // hook code
WPARAM wParam, // removal option
LPARAM lParam // message
)
{
// We must pass the all messages on to CallNextHookEx.
return ::CallNextHookEx(sg_hGetMsgHook, code, wParam, lParam);
}
一个系统范围的钩子被多个进程加载,但这些进程并不共用地址空间。
例如钩子句柄sg_hGetMsgHook,从SetWindowsHookEx()中取回,并作为参数传递给CallNextHookEx(),它几乎要在所有的进程地址空间中使用。这意味着它的值应该在所有的钩子处理例程以及Hook Server应用中共享。为了达到这个目的我们应该将它存放在共享数据区。
下面是一个使用#pragma data_seg()的例子。这里我不得不得提醒,共享数据区的变量必须初始化,否则它们将被放在普通数据区,#pragma data_seg()将没有效果。
//---------------------------------------------------------------------------
// Shared by all processes variables
//---------------------------------------------------------------------------
#pragma data_seg(".HKT")
HHOOK sg_hGetMsgHook = NULL;
BOOL sg_bHookInstalled = FALSE;
// We get this from the application who calls SetWindowsHookEx()'s wrapper
HWND sg_hwndServer = NULL;
#pragma data_seg()
你还要在DLL的DEF文件中加上对段的声明,如下:
SECTIONS
.HKT Read Write Shared
或者使用
#pragma comment(linker, "/section:.HKT, rws")
一旦一个钩子的DLL注入到目标进程,除了Hook Server调用UnhookWindowsHookEx()或者被挂钩应用关闭外,没有其他方法卸载它。当Hook Server调用UnhookWindowsHookEx()时,操作系统将遍历一张内部的表,该表记录着哪些进程被强制加载了钩子的DLL。操作系统将递减DLL的引用计数,直到它为0时,DLL将自动地从进程地址空间中卸载。
这种实现方式的好处有:
这种机制是由NT/2k和9x的Windows自己提供的,并且未来的Windows版本中将会对它进行维护。
不像通过注册表机制注入DLL,这种方法能使DLL卸载,当Hook Server决定DLL不再需要时调用UnhookWindowsHookEx()即可。
尽管我觉得Windows Hooks是非常方便的注入方式,但它也有自身的缺点:
Windows Hooks会严重影响系统的性能,因为它增加了系统对消息的处理。
很难调试。当你同一时间运行多个VC++实例来进行调试时,很明显情况复杂很多。
最后但不是最重要的是,它影响系统运行,在某些时候不得不重启才能消除影响。
调用CreateRemoteThread()注入DLL
这是我最喜欢的一种方式。但它仅在NT和2k的系统上有效。奇怪的是你也可以在Win 9x的系统上调用这个API,只是它不做任何事,返回值为NULL。
通过远程线程注入DLLs是Jeffrey Ritcher提出的方法,在他的文章《Load Your 32-bit DLL into Another Process's Address Space Using INJLIB》中有很好的介绍。
这种方法的原理很简单,但是很巧妙。任一进程可以通过调用LoadLibrary()来动态加载一个DLL。问题是我们在没有权限访问一个外部进程的线程时,如何强制使这个外部进程调用LoadLibrary()呢?CreateRemoteThread()就能创建一个远程线程。这里需要一个小技巧,看下线程函数的声明形式,该函数地址将作为参数传给CreateRemoteThread():
DWORD WINAPI ThreadProc(LPVOID lpParameter);
而LoadLibrary()的函数原型是:
HMODULE WINAPI LoadLibrary(LPCTSTR lpFileName);
它们有相似的原型。相同的调用约定WINAPI,都接受一个参数,相同的返回值大小。这使得我们可以把LoadLibrary当作一个线程函数,它将在远程线程创建后开始执行。让我们看下下面的示例代码:
hThread = ::CreateRemoteThread(
hProcessForHooking,
NULL,
0,
pfnLoadLibrary,
"C:\\HookTool.dll",
0,
NULL);
通过使用GetProcAddress()我们可以得到LoadLibrary()的函数地址。对我们很有帮助的是,Kernel32.DLL总是被映射到每个进程地址空间的相同位置,这样LoadLibrary()的函数地址总是一样的。这就保证了将一个有效的函数地址传给CreateRemoteThread()。
作为线程函数的参数,我们使用DLL的绝对路径,并将它转换为LPVOID类型。当远程线程运行时,将传递DLL的绝对路径给线程函数(这里是LoadLibrary)。这就是使用远程线程注入DLL的方法。
通过CreateRemoteThread()注入有一点很重要。每次我们的注入程序在目标进程操作虚拟内存并调用CreateRemoteThread()时,先要调用OpenProcess()并传入参数PROCESS_ALL_ACCESS标识来打开进程。当我们想得到进程的最大访问权限时使用这个标识。在这种情况下对于一些PID较低的进程OpenProcess()返回NULL。这种错误是因为不具备足够的权限。如果你思考一下,你会觉得这样很好。那些受限制的进程是操作系统的一部分,普通进程不允许操作它们。不然如果有些应用有bug或者意外终止了一个系统进程怎么办?为了保护操作系统不因为这种情况而崩溃,所以要求应用程序必须有足够的权限去执行那些可以改变操作系统行为的APIs。通过调用OpenProcess()来访问一个系统资源,需要被授予调试权限。这种方式很强大,并提供了访问系统资源的途径,但通常应该受限使用。调整程序的权限是一个比较容易的工作,可以用以下逻辑操作描述:
打开进程令牌,该令牌具有调整特权的权限
给SeDebugPrivilege一个特权名,并应该分配它的临时LUID。特权都被命名了,并且能在SDK的winnt.h文件中找到
调用AdjustTokenPrivileges来调整令牌在SeDebugPrivilege中赋予的特权
关闭由OpenProcessToken()获得的进程令牌句柄
通过BHO(浏览器帮助对象)附带实现
有时你仅仅要把自定义代码注入IE中。微软为这种情况提供了一种简单明了的方法——BHO。一个BHO的实现为COM DLL,一旦它被正确注册,当IE运行时将加载所有的实现了IObjectWithSite接口的COM组件。
通过Office附带实现
与BHO相似,如果你需要在MS Office中注入自己的代码,你可以通过提供的标准机制实现Office的附带。有很多这类例子。
拦截机制
将一个DLL注入到外部进程的地址空间是监控系统的关键一步。它提供了控制进程的线程活动的很好的机会。但拦截进程的API调用仅仅注入DLL是不够的。
这部分文章将简单介绍工作中的hooking的几个方面。我们将关注它们的基本要素,并提出它们的优缺点。
根据钩子应用在哪个层次,有两种API监控机制——内核层和用户层监控。为了更好的理解这两种层次,你必须了解Win32子系统API和原生API的关系。图2展示了在Win 2k上不同层次的钩子以及模块间的关系和依赖。
图2
它们在实现上的主要区别在于内核层钩子的拦截引擎被封装成驱动,而用户层钩子通常使用用户模式的DLL。
NT内核级hooking
在内核模式有几种实现NT系统服务hooking的方法。最有名的拦截机制最初在Mark Russinovich和Bryce Cogswell的文章《Windows NT System-Call Hooking》中提出。他们最初的想法是在用户模式下注入一种拦截机制来监控系统调用。这种技术很强大,提供了一种非常灵活的方法来挂钩在所有用户线程必经,但又在操作系统内核提供服务之前的这个点上。
你也可以在《Undocumented Windows 2000 Secrets》中找到一种很好的设计和实现。Sven Schreiber在他的这本书中介绍了如何开发一个内核级hooking框架。
另一种出色的分析和实现方法由Prasad Dabak在他的书《Undocumented Windows NT》中提出。
但以上这些方法不在本文的探讨范围之内。
Win32用户级hooking
a. 窗口子类化
这种方法适合那些应用程序的行为可能要被新实现的窗口过程改变的情况。这种实现你只要简单的调用SetWindowLongPtr()传入GWLP_WNDPROC和你自己的窗口过程函数指针。一旦你设置了一个新的子类窗口过程,每当Windows分发消息到指定窗口时,它将先寻找与特定窗口相关的窗口过程地址,调用你的窗口过程而不是原来的。
这种机制的缺点是子类化只能在指定进程内可用。换句话说就是不能将另一个进程创建的窗口类子类化。
通常这种方法适用于你通过一个附带物(如DLL或COM组件)挂钩一个应用,并且能获得你想替换窗口过程的窗口句柄。
比如前不久我写了个简单的IE附带物(BHO)通过窗口子类化来替换IE原来的弹出菜单。
b. 代理DLL(木马DLL)
一种黑掉API的简单方法是用一个相同名字、导出符号相同的DLL替换原有的DLL。
这种技术可以很容易的通过函数转发器来实现。函数转发器简单来说就是DLL导出段里的一个项,它代表一个函数去调用其他DLL中的函数。
你可以使用#pragma comment来实现:
#pragma comment(linker, "/export:DoSomething=DllImpl.ActuallyDoSomething")
如果你决定采用这种方法,你必须负责与原始版本的新版本的兼容性。
c. 代码重写
有好几种方法基于代码重写。其中有一种是通过CALL指令改变函数地址。这种方法有点难,并且容易出错。它的底层思想是跟踪内存中的全部CALL指令,用用户提供的函数地址来替换原来的函数地址。
另一种代码重写需要更复杂的实现。简单来说它的原理是定位原始API的地址,通过JMP指令改变函数的前几个字节,重定向这个调用到用户提供的API。这种方法相当有技巧,对每一个调用都包含了一系列的恢复与挂钩操作。特别需要指出的是,如果一个函数处于没有被挂钩的状态时另一个调用来了,系统将捕捉不到这个调用。最大的问题是它与多线程的环境有冲突。
但也有一个很杰出的解决方案来解决这些问题,并提供一个复杂的方式来完成API拦截的大部分目的。如果你有兴趣你可以看看Detours的实现。
d. 通过调试器监控
一种可选的挂钩API的方法是对目标函数设置一个断点。但这有几个问题。最大的问题是当出现调试异常时将挂起应用的所有线程,并且需要一个调试器来处理这个异常。另一个问题是当调试结束时,Windows将自动关闭调试器。
e. 通过修改导出地址表监控
这种技术最初由Matt Pietrek提出,接着由Jeffery Ritcher和John Robbins详细阐述。这是一种简单而又健壮的实现。针对Windows NT/2K和9x操作系统,它满足了一个hooking框架的大部分需求。这种技术的原理依赖于Windows PE文件格式的良好结构。要了解这种方法如何工作,你需要对PE文件格式(COFF的扩展)的一些基础知识很熟悉。Matt Pietrek在他的《Peering Inside the PE》和《An In-Depth Look into the Win32 PE》两篇文章中详细介绍了PE格式。我这里将简单介绍PE标准,使你能理解通过操纵导出地址表实现hooking的原理。
一个PE二进制文件是组织良好的,在它的布局中包含了全部代码和数据段,它将一致地映射入虚拟内存中。PE文件格式由几个逻辑段组成。每一段包含了特定类型的数据和OS加载器需要的特定的地址。
.idata段,我特别强调它,它包含了导入地址表的信息。PE结构的这个部分对于通过修改IAT(导出地址表)监控非常重要。
每一个可执行文件都满足PE文件格式,它的布局如图3:
图3
程序加载器负责加载一个应用程序及它所链接的DLLs到内存中。由于事先无法知道这些DLLs将被加载到哪,加载器就无法确定导入函数的真实地址。加载器就不得不做一些额外的工作来保证程序能成功调用导入函数。但是遍历内存中的可执行文件映象,一个一个的确定导入函数的地址将需要无法预知的处理时间,并且导致性能大幅下降。那么加载器如何解决这个问题呢?关键的一点是导入函数的每一次调用一定会被分发到相同的地址,即函数在内存中的地址。导入函数的每一次调用实际上是一个间接调用,根据IAT通过一个间接的JMP指令分发。这种设计的好处是加载器不需要搜索整个映象文件。这种解决方法似乎就简单了——只需要修正IAT中的导入地址。下面有一个简单的Win32应用程序的PE文件格式的快照,使用PEView来查看的。你可以看到TestApp的导入表包含了两个从GDI32.DLL中导入的函数——TextOutA()和GetStockObject():
图4
事实上一个导入函数的hooking过程并不像第一次看到它的时候那么复杂。简单来说一个通过修改IAT实现的拦截系统要找到定位导入函数地址的地方,并使用用户提供的重写的函数的地址来替换。一个重要的要求是新提供的函数要与原始函数有一样的函数原型。下面是替换的逻辑步骤:
定位每个被加载了的DLL模块及本身的IAT中的导入段
找到导出函数的DLL中的IMAGE_IMPORT_DESCRIPTOR块,我们将在DLL中通过名字搜索到
定位包含导入函数地址的IMAGE_THUNK_DATA块
使用用户提供的函数地址替换
通过修改IAT中导入函数的地址,我们能确保对所有被挂钩函数的调用将被分发给拦截器函数中。
修改IAT中地址.idata段不一定可写。这需要我们保证其可以修改。我们可以使用VirtualProtect()来完成。
另一个值得关注的问题是Windows 9x操作系统上GetProcAddress()的行为。当应用程序在调试器外调用这个API,将返回函数的指针。但当你在调试器中调用,它将返回一个不同的地址。这是由于在调试器中调用GetProcAddress()返回的是真实指针的一个封装。GetProcAddress()的返回值指向真实地址前的一个PUSH指令。这就是说在Windows 9x上遍历块时,我们得检查这个返回的函数地址是不是一个PUSH指令,以此来得到真正的地址。
Windows 9x没有实现写时复制,因此操作系统要防止调试器调试高于2GB地址的函数。这就是GetProcAddress()返回一个调试块而不是真实地址的原因。John Robbins在《Hooking Imported Functions》谈到过这个问题。
何时注入钩子DLL
上一章节揭示了当所选的注入机制不是操作系统提供的功能时开发人员面对的一些挑战。例如,当你使用内建的Windows Hooks来注入DLL时,你就不需要关心如何注入。这是操作系统的责任,让每一个运行中的满足特定钩子要求的进程强制加载DLL。事实上Windows还会跟踪新创建的进程,让它们也加载钩子DLL。通过注册表实现注入与Windows Hooks相似。所有内建方法的最大优势就是它们是操作系统的一部分。
不像上面谈到注入技术,通过CreateRemoteThread()实现注入需要维护当前运行的进程。如果注入得不及时,钩子系统将错过一些应该拦截的调用。很重要的一点是当进程创建或者关闭时,Hook Server应用需要一个很智能的机制来接受这些通知。推荐的一种做法是拦截CreateProcess()系列的API,监视它们的调用。这样当用户的函数被调用时就能去调用原始的CreateProcess(),传入dwCreationFlags | CREATE_SUSPENDED。目标进程的主线程将处于挂起状态,Hook Server就有机会注入DLL并通过ResumeThread()来唤醒线程。更多细节你可以参考《Injecting Code with CreateProcess()》。
另一种监视进程执行的实现是通过一个简单的设备驱动。它提供了最大的灵活性,更值得关注。Windows NT/2K提供了一个从NTOSKRNL导出的特殊函数PsSetCreateProcessNotifyRoutine()。该函数允许添加一个回调函数,当进程创建或者关闭时将调用该回调函数。
枚举进程和模块
有时候我们更倾向于使用CreateRemoteThread()来注入DLL,尤其是在Windows NT/2K上。在这种情况下Hook Server启动时需要枚举所有活动的进程,并将DLL注入到它们的地址空间。Windows 9x和2K提供了一种内建的工具辅助库的实现。另一方面Windows NT也使用PSAPI库来达到同样的目的。我们需要让Hook Server运行起来,然后动态检测以上哪种方法可用。这样系统就能知道支持哪种库,再正确地使用API了。
我将展示一个面向对象的在NT/2K和9x上枚举进程和模块的框架。我的设计允许你根据需求进行扩展。它的实现是相当明了的。
CTaskManager实现系统的处理。它负责创建一个能正确使用的支持库的句柄实例。CTaskManager创建并管理一个容器对象,该容器用一个链表存储当前活跃的进程。当应用程序实例化CTaskManager后将调用Populate()方法。该方法将枚举进程和DLL,将它们存储到一个层次结构,这个结构是CTaskManager的m_pProcesses成员。
下面的UML图展示了这个子系统中各个类之间的关系:
图5
有一点需要指出的是NT的Kernel32.dll没有实现任何ToolHelp32函数。因此要明确指出链接,并要使用运行时动态链接。如果我们使用静态链接,那么无论程序中有没有调用这些方法,NT上都将加载失败。详细请参考我的文章《Single interface to enumerating processes and modules under NT and Win9x/2K》。
钩子工具系统的需求
现在我已经简单介绍了各种hooking原理了,也花了时间决定一些基本的要求,并设计了一个特定的hooking系统。下面是钩子工具系统的一些问题:
提供一个用户级钩子系统来监控任何导出的Win32 API
提供通过Windows Hooks或者CreateRemoteThread()注入Hook Driver的能力。框架应该能通过提供的INI文件配置
采用修改IAT的拦截机制
提供面向对象的可重用、可扩展的架构
提供有效、可伸缩的hooking API的机制
满足性能要求
提供Server和Driver间可靠的沟通机制
实现自定义的TextOutA/W()和ExitProcess()函数
在文件中记录事件
系统实现在x86上可运行Windows 9x、Me、NT或者Windows 2K
设计和实现
这个章节讨论框架的关键组件及它们间的相互作用。该框架将能捕捉到任何通过名字导出的WINAPI。
在我介绍系统的设计之前,我们先关注几个注入和挂钩的方法。
首先也是最重要的,需要选择一种注入方法满足能注入DLL到所有进程的要求。因此我使用了两种注入技术来设计了一个抽象的方法,根据INI文件中的配置及操作系统版本来选择使用它们。这两种技术分别是Windows Hooks和CreateRemoteThread()。该框架在NT/2K上以上两种技术都能使用,使用哪种根据INI文件中的配置决定。
另一个重要的抉择是hooking机制的选择。毋庸置疑,我决定使用修改IAT这种非常强壮的监控Win32 API的方法。
为了实现既定目的,我设计的框架由以下组件和文件组成:
TestApp.exe——一个简单的Win32测试应用程序,仅仅使用TextOut() API输出一段文本。这个应用程序的目的是演示它是怎样挂钩的。
HookSrv.exe——控制程序
HookTool.DLL——实现成Win32 DLL的监控库
HookTool.ini——配置文件
NTProcDrv.sys——一个用来监控进程创建和终止的简单的Windows NT/2K内核模式的驱动。这是个可选组件,仅在基于NT的系统上用于解决检测进程运行的问题。
HookSrv是一个简单的控制程序。它扮演着加载HookTool.DLL和激活监控引擎的角色。加载DLL后,它调用InstallHook()方法,传入一个隐藏的窗口句柄,DLL的所有消息都将交给这个窗口处理。
HookTool.DLL是Hook Driver部分,是整个监控系统的核心。它实现了拦截并提供了三个自定义方法TextOutA/W()和ExitProcess()。
尽管这篇文章的重点在Window内部的机制,没有必要使用面向对象的设计,但我还是决定将相关活动设计成可复用的C++类。这种方式提供了更多灵活性和可扩展性。它也为开发人员在这个项目外使用个别类提供了便利。
下面的UML类图展示了HookTool.DLL中一系列类的关系:
图6
在这部分我希望你关注到HookTool.DLL的实现。在开发过程中细分各个类的职责是很重要的一部分。每一个类分装了一个特定的功能或者代表了一个逻辑实体。
CModuleScope是系统的主要部分。它以单例的模式来实现并工作在线程安全的环境中。它的构造函数接受三个在共享段定义的数据的指针,这些数据将在所有进程中使用。这就是说这些系统范围的变量将在类中很便于进行管理,这就保证了封装性。
当一个应用加载HookTool库时,DLL接到DLL_PROCESS_ATTACH通知时将创建一个CModuleScope的实例。这步将初始化唯一的一个CModuleScope实例。在CModuleScope的构造函数中一个重要的片段是创建了一个注册器对象。使用哪个注册器将在解析了配置文件后确定,一并确定了UseWindowsHook的参数。如果系统运行在Windows 9x上,这个参数将不会被系统检查,因为Windows 9x并不支持远程线程注入。
当主要的处理对象被实例化后,将调用ManageModuleEnlistment()。下面是它的一个简化版本:
// Called on DLL_PROCESS_ATTACH DLL notification
BOOL CModuleScope::ManageModuleEnlistment()
{
BOOL bResult = FALSE;
// Check if it is the hook server
if (FALSE == *m_pbHookInstalled)
{
// Set the flag, thus we will know that the server has been installed
*m_pbHookInstalled = TRUE;
// and return success error code
bResult = TRUE;
}
// and any other process should be examined whether it should be
// hooked up by the DLL
else
{
bResult = m_pInjector->IsProcessForHooking(m_szProcessName);
if (bResult)
InitializeHookManagement();
}
return bResult;
}
ManageModuleEnlistment()的实现很简单,它检查是否被Hook Server调用,检查m_pbHookInstalled指针指向的值。如果是Hook Server发起的调用,它将直接设置sg_bHookInstalled标识为TRUE。这意味着Hook Server启动了。
下一个动作是Hook Server调用DLL中导出的InstallHook()来激活引擎。实际上间接调用了CModuleScope的InstallHookMethod()方法。这个方法的主要目的是强制挂钩目标进程去加载或者卸载HookTool.DLL。
// Activate/Deactivate hooking
engine BOOL CModuleScope::InstallHookMethod(BOOL bActivate, HWND hWndServer)
{
BOOL bResult;
if (bActivate)
{
*m_phwndServer = hWndServer;
bResult = m_pInjector->InjectModuleIntoAllProcesses();
}
else
{
m_pInjector->EjectModuleFromAllProcesses();
*m_phwndServer = NULL;
bResult = TRUE;
}
return bResult;
}
HookTool.DLL提供了两种机制将自己注入到外部进程的地址空间——Windows Hooks和CreateRemoteThread()。系统的架构定义了一个抽象了CInjector类,它提供了纯虚函数来注入和反注入DLL。CWinHookInjector和CRemThreadInjector类都继承这个基类。它们提供了CInjector中两个纯虚函数接口InjectModuleIntoAllProcesses()和EjectModuleFromAllProcesses()的不同实现。
CWinHookInjector实现了Windows Hooks注入机制,它通过下面的调用安装了一个过滤器函数:
// Inject the DLL into all running processes
BOOL CWinHookInjector::InjectModuleIntoAllProcesses()
{
*sm_pHook = ::SetWindowsHookEx(
WH_GETMESSAGE,
(HOOKPROC)(GetMsgProc),
ModuleFromAddress(GetMsgProc),
0
);
return (NULL != *sm_pHook);
}
正如你所见到的,它向系统请求注册一个WH_GETMESSAGE钩子。Hook Server仅执行这个函数一次。SetWindowsHookEx()最后一个参数为0是因为GetMsgProc()被设计成一个全局钩子。回调函数将在一个窗口处理一个特定消息时被系统调用。有趣的是我们得提供一个新的虚假的GetMsgProc()回调函数的实现,因为我们不打算监控消息处理。我们使用这种实现是想通过操作系统获得免费的注入机制。
调用SetWindowsHookEx()后,操作系统将检查导出GetMsgProc()函数的DLL是否已经映射到所有的GUI进程中。如果没有,操作系统将强制它们加载。一个有趣的事实是,一个全局钩子的DLL在DllMain()中不能返回FALSE。这是因为操作系统会验证DllMain()的返回值,不断尝试加载DLL直到DllMain返回TRUE为止。
CRemThreadInjector类是另一种不同的实现。它实现的基础是使用远程线程来注入DLL。CRemThreadInjector通过接受进程创建和终止的通知扩展了Windows进程的维护。它有一个CNtInjectorThread类的实例来观察进程的运行。CNtInjectorThread对象负责接受来自内核模式驱动的通知。这样每当一个进程被创建或者终止时将自动调用CNtInjectorThread::OnCreateProcess()和CNtInjectorThread::OnTerminateProcess()。不像Windows Hooks,依赖于远程线程的方法当一个新进程被创建时需要手动注入。监控进程活动提供了让我们获得新进程创建消息的能力。
CNtDriverController类通过封装API实现了管理服务和驱动的功能。它被设计出来管理内核模式驱动NTProcDrv.sys的加载和卸载。它的实现将在稍后讨论。
HookTool.DLL成功注入指定进程后,DllMain()中将调用ManageModuleEnlistment()。回忆下我们之前提到过的这个方法的实现。它通过CModuleScope的m_pbHookInstalled成员来检查共享的全局变量sg_bHookInstalled。由于Hook Server在初始化时已经将sg_bHookInstalled设置为TRUE,系统将检查这个应用是不是要挂钩,如果是,它将为这个进程激活引擎。
开启钩子引擎在CModuleScope::InitializeHookManagement()中实现。这个函数的主要目的是为LoadLibrary()、GetProcAddress()系列重要API安装钩子。这意味着我们能监控到在进程初始化后DLLs的加载。当一个新的DLL要加载时,它需要修正它的IAT,这样就能保证系统不会错过任何被挂钩的函数的调用。
在InitializeHookManagement()函数的最后我们提供了真正需要监控的函数的初始化。
由于我们的示例代码展示的是捕捉多个自定义函数,因此我们要提供每个被挂钩函数的单独的实现。也就是说使用这种方法不能将IAT中的多个导出函数地址修改成一个公用的拦截函数地址。这个拦截函数需要知道这个调用来自真实的哪个导出函数。另一个重点是拦截函数声明必须和原始的WINAPI原型一样,否则将毁坏堆栈。例如CModuleScope实现了三个静态的方法TextOutA()、TextOutW()、ExitProcess()。一旦HookTool.DLL加载到进程的地址空间并激活了监控引擎,那每次对原来TextOutA()的调用将被替换为CModuleScope::TextOutA()。
上面提出的监控引擎的设计很有效,并且提供了很大的灵活性。但它只是适合实现知道需要拦截的一系列函数,并且函数个数是有限。
如果你想在系统中增加钩子,你只需要像我对MyTextOutA/W()和MyExitProcess()做的一样,增加声明和实现。然后你需要像InitializeHookManagement()的实现一样注册它。
拦截并跟踪进程的运行对需要操纵外部进程的系统很有用。通知一个感兴趣的进程的创建是开发进程监控系统和全局钩子的经典问题。Win32 API提供了一系列很好的库来让你枚举系统中运行的进程。尽管这些API很强大,但它不能通知你进程的创建和终止。幸运的是,NT/2K提供了一系列从NTOSKRNL中导出的API,在Windows DDK的文档中描述为“Process Structure Routines”。其中一个PsSetCreateProcessNotiyRoutine()提供了注册一个回调函数的功能,每当一个进程创建、退出或者终止时操作系统将调用它。使用这个API来跟踪进程可以简单地实现为一个内核模式的驱动和一个用户模式的控制应用。驱动的角色用来检测进程的执行并通知控制应用这些事件。Windows进程观察者NTProcDrv的实现提供了一组小规模的功能性函数来实现基于NT的系统进程监控。驱动的代码可以在NTProcDrv.c中找到。因为是在用户模式进行驱动的动态加载和卸载,因此登录用户需要有管理员权限。否则你就无法安装驱动,影响进程的监控。你可以手动的以管理员身份安装驱动或者使用Windows 2K提供的“以其他身份运行”的选项来运行HookServ.exe。
最后但并不是最重要的,可以通过简单地修改INI配置文件来管理系统。这个文件决定使用哪种注入方法。它也提供了途径来指定挂钩哪些进程,而又不挂钩哪些进程。如果你想监控进程,有一个可选的[Trace]节设置是否记录系统活动,它通过CLogFile导出的函数来记录详细的错误信息。CLogFile以线程安全的方式实现,你不必担心访问共享的系统资源引起的同步问题。详细请参看CLogFile和HookTool.ini文件。
超出讨论范围的部分
为了使文章简单易懂,有些内容我只在文章中简单提出:
监控原始API调用
在Windows 9x上通过驱动监控进程运行
UNICODE的支持
总结
文章到目前为止没有提供一个挂钩无限个API的主题向导,当然也有些细节不够完善。但我已经尽量指出了重点,使它适用于对用户模式下监控Win32 API感兴趣的开发人员。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
上传的附件: