揭示Win32 API拦截细节/API hooking revealed (1)

 原文出处:http://www.codeproject.com/system/hooksys.asp

简要介绍

拦截win32 API 调用对于多数windows开发人员来说都一直是很有挑战性的课题,我承认,这也是我感兴趣的一个课题。钩子机制就是用一种底层技术控制特定代码段的执行,它同时提供了一种直观的方法,很容易就能改变操作系统的行为,而并不需要涉及到代码。这跟一些第三方产品类似。
    许多系统都通过拦截技术(spying techniques)利用现有windows应用程序。而拦截的一个重要目的,并不只是为应用程序提供更高级功能,而是为完成调试。
    与老式操作系统(如dos,win3.xx)不同,现有操作系统(如WINNT/2K和win9X)使用了成熟的机制来分隔各进程的地址空间。这种架构提供了真正的内存保护,因此任何应用程序都不能破坏属于其它进程的地址空间,更不可能破坏操作系统本身。这使得开发系统相关的钩子(system-aware hooks)变得十分困难。
    我写这篇文章就是要探讨一种简单实用的钩子机制,它提供了一个简单的接口,用来拦截不同的API调用。它也示范了一些技巧,可以帮助你开发出自己的api拦截程序(spying system)。同时它还提供了一系列在WIN2K/NT和WIN98/ME(下面简称9X)等windows上拦截WIN32 API 的方法。为了简化我的描述,我没有引入UNICODE的相关内容。但你只需对代码作一些微小改动就能支持UNICODE。

拦截应用程序(Spying of applications)有许多好处:
1.监视API函数
    有助于控制API调用,也让开发人员在API调用期间跟踪到应用程序特定的“不可见”动作。它有助于开发人员全面掌握程序的细节(comprehensive validation of parameters),也有助于发现潜在问题。例如,有时候,它能便于监视内存管理API引起的资源泄漏。
2.调试和逆向工程
    除了一般的调试方法,API钩子也是一种值得称道的非常流行的调试方式。许多开发人员用钩子来区分不同组件的执行以及它们之间的关联。因此它也用于获取二进制可执行文件的信息。
3.深入操作系统内部
    通常开发人员都热衷于深入了解操作系统并扮演着“调试者”的角色。钩子机制也是用于解码未公开的或不为人知的API的有力技术。
4.扩展已有功能
    可以向外部的windows应用程序嵌入自定义模块、增强原有函数的功能,这需要借助钩子来重定向原有代码的执行序列(让系统在执行原有代码过程中执行用户自定义代码),从而扩展现有模块的功能。例如,许多第三方软件产品并不遵循指定的安全规则而只满足用户特定的使用需求。拦截应用程序允许开发者在原有API执行之前或之后添加属于用户自己的代码。这有助于改变已经编译好的代码的行为。
对拦截系统的功能需求 
    在实现任何形式的API拦截系统之前,都必须先做一些慎重考虑。首先,你要决定是开发对单个程序的钩子还是全局钩子。例如,假设你只希望拦截一个程序,就不必安装全局钩子了;但如果要监视一切对TerminateProcess() 和WriteProcessMemory()的调用,唯一的办法就是使用全局钩子。选用何种方法都取决于特定的环境和要解决的问题。

API拦截架构的概要设计
    通常拦截系统由至少两部分组成——一个钩子服务器(Hook Server)和一个驱动(Driver)。钩子服务器用于在合适时机把驱动注入目标进程,它也可以管理驱动,甚至可以通过注入点获取驱动的工作情况。这样的设计比较粗略,很明显它并未涉及所有可能的实现方式。但这已经能够描述API拦截的框架了。

    如果需要实现特定的钩子架构,应该慎重考虑下面几点:
    a 要拦截什么程序
    b 如何向目标进程注入DLL或者说应用何种注入技术
    c 使用何种拦截机制
    希望读者可以从以下章节找到答案。

注入技术
1.注册表
如果要向加载了USER32.DLL的进程注入DLL,只需向如下注册表键写入DLL的名称:
HKEY_LOCAL_MACHINE/Software/Microsoft/Windows NT/CurrentVersion/Windows/AppInit_DLLs
    上述表键的值可包含单个或成组用逗号(,)或空格分隔的DLL名称。根据MSDN文档[参考7],所有包含在上述键值内的DLL,都会被任何运行在当前用户登陆空间(current logon session)的windows应用程序所加载。有趣的是,实际上,这些DLL的加载过程其实是USER32初始化过程的一部分。USER32读取上述键值并为这些DLL的入口调用LoadLibrary()。但这种方法只适用于那些加载了USER32.DLL的程序。另外一种限制是,这种内置的机制只适用于windows2k/nt系统。这是一种安全的DLL注入方法,但有以下缺点:
    a. 激活或撤销进程注入必须重启windows
    b. 被注入的DLL只被映射到那些加载了USER32.DLL的进程,所以这种方法至少不能注入控制台程序,因为它们根本不必导入USER32的函数。
    c. 另外一方面,注入方不可能控制注入过程。就是说,DLL被注入了所有GUI程序,不管注入方是否有这样的需求。在只需要拦截少量程序的情况下,这样会显得多余。更多信息请参考[参考2]“利用注册表注入DLL”。

2.全局Windows钩子
    的确,另外一种很流行的DLL注入方法来自windows钩子。MSDN指出这种钩子是系统消息处理机制中添加的陷阱。应用程序可以通过安装钩子来监视系统中的消息流(message traffic),并在消息到达特定窗口过程之前处理它们。
    根据系统底层要求,这种全局钩子一般在DLL内实现。它的基本原理是,钩子回调过程在被拦截进程的地址空间内被调用。通过调用SetWindowHookEx()并加入合适的参数来安装一个钩子。一旦这种全局钩子安装好,操作系统就会把DLL映射到目标进程的地址空间。此时,DLL内的全局变量就变成局限于单个进程(per-process),不能被各目标进程共享。因此,所有需要共享的变量应该被放置在共享数据段。下图展示了一个例子:钩子服务器注册一个钩子并将其注入到名为“Application one”和“Application two”的进程的地址空间。

图1
 
    每当SetWindowsHookEx()执行,全局钩子就会被注册一次。一切正确时函数将返回该钩子的句柄。在用户自定义的钩子回调过程末尾调用CallNextHookEx()时将要用到上述句柄。成功调用SetWindowsHookEx()后,操作系统就会自动把这个DLL注入到符合要求的所有进程的地址空间,但并不一定是立即注入。下面具体来看过滤WH_GETMESSAGE消息的函数体:

[cpp]  view plain copy
1. //---------------------------------------------------------------------------  
2. // GetMsgProc  
3. //  
4. // Filter function for the WH_GETMESSAGE - it's just a dummy function  
5. //---------------------------------------------------------------------------  
6.  LRESULT CALLBACK GetMsgProc(  
7.   int code,       // hook code  
8.   WPARAM wParam,  // removal option  
9.   LPARAM lParam   // message  
10. )  
11....{  
12. // We must pass the all messages on to CallNextHookEx.  
13.  return ::CallNextHookEx(sg_hGetMsgHook, code, wParam, lParam);  
14.}  

 
     全局钩子被多个不共享相同地址空间的进程加载。例如,钩子句柄sg_hGetMsgHook会被SetWindowsHookEx()函数获取并作为参数用于CallNextHookEx(),它必须在各进程的地址空间中使用。就是说,该句柄的值必须被客户进程和拦截服务器共享。因此,钩子句柄应该放置在共享数据段中。
     以下例子调用了#pragma data_seg()编译预处理语句(使用共享数据段)。在此,我要提醒一下,共享数据段内的变量必须初始化,否则它们将会被放置在默认数据段,同时#pragma data_seg()语句也会失效。

[cpp]  view plain copy
1. //---------------------------------------------------------------------------  
2. // Shared by all processes variables  
3. //---------------------------------------------------------------------------  
4. #pragma data_seg(".HKT")  
5.  HHOOK sg_hGetMsgHook       = NULL;  
6.  BOOL sg_bHookInstalled    = FALSE;  
7. // We get this from the application who calls SetWindowsHookEx()'s wrapper  
8.  HWND sg_hwndServer        = NULL;   
9. #pragma data_seg()  

同时应该添加SECTIONS语句到DLL的DEF文件,如下所示:
SECTIONS
         .HKT   Read Write Shared
或使用 
#pragma comment(linker, "/section:.HKT, rws")

    一旦钩子DLL被加载到目标进程的地址空间,要卸载该钩子的话除了拦截服务器调用UnhookWindowHookEx()函数或客户进程退出就没有其他办法了。当拦截服务器调用UnhookWindowHookEx()函数时,操作系统就会扫描一个列表,这个列表包含了所有加载了钩子dll的进程。这时操作系统会(根据列表内进程数量)相应递减钩子DLL的锁定计数,当这个计数变为0,DLL就会从对应进程的地址空间删除。
     下面列举了上述方法的一些好处。
     a.这种机制都被WINNT/2K和9X系列的操作系统所支持,后继版本的windows系统也有望支持这种机制。
     b.跟注册表注入机制不同,当拦截服务器不再需要钩子DLL时,可以调用UnhookWindowsHookEx()卸载钩子。
     尽管我认为windows钩子方便实用,但它不可避免存在下列缺点:
     a.windows钩子会明显减低windows的性能,因为它增加了系统处理每个消息时所需开销。
     b.要调试全局钩子很麻烦。但如果你同时运行多于一个vc++实例,就会发现在复杂情况下这样调试反而简单些。
     c.最后一个不容忽视的问题,这种钩子会影响整个系统,在特定环境下(钩子中有bug),需要重启系统来恢复它。

3.用CreateRemoteThread()函数远程注入DLL 
    这是我最喜欢的一种注入方式。不幸的是只有NT和2K系统支持。该函数的独特之处在于,它也可以在windows9x上被执行,但只返回NULL而不做任何操作。
    远程注入DLL是Jeffrey Ritcher发明的,并记录在他的文章[参考9] "Load Your 32-bit DLL into Another Process's Address Space Using INJLIB"中。
    基本原理简单且巧妙。任何进程都可以调用LoadLibrary()来动态加载DLL。问题是当我们缺乏对目标(外部)进程子线程的访问权限时,如何根据自己的意愿强制外部进程调用这个函数?这里要用到CreateRemoteThread()函数了,它可以远程产生线程。这里有个窍门——且看线程体函数的原型,它的指针(类型为LPTHREAD_START_ROUTINE)被作为参数传递给了CreateRemoteThread():
DWORD WINAPI ThreadProc(LPVOID lpParameter);
    下面是LoadLibrary()的原型:
HMODULE WINAPI LoadLibrary(LPCTSTR lpFileName);
 
    它们都有相似之处。它们都使用相同的WINAPI调用约定,它们都接受一个参数,且返回值的长度也是一样的。上面的比较告诉我们,可以把LoadLibrary()作为线程体,这样它就可以在远程线程产生后被执行。接下来看下面的示例代码:

[cpp]  view plain copy
1. hThread = ::CreateRemoteThread(  
2.           hProcessForHooking,   
3.           NULL,   
4.           0,   
5.           pfnLoadLibrary,   
6.           "C:HookTool.dll",   
7.           0,   
8.           NULL);  

 
          
    调用GetProcAddress()可以获取LoadLibrary()的地址。很巧妙的一点是,KERNEL32.DLL总是被映射到进程地址空间的相同位置,因此在每个进程中,LoadLibrary()的地址总是相同的。这就保证了CreateRemoteThread()接收到的参数总是一个有效指针。
    我们用DLL的绝对路径作为线程体函数的实参,并转换成LPVOID类型。远程线程运行时,它会把DLL的路径传递给线程体函数(LoadLibrary)。上述就是用远程线程注入dll的全部窍门。
    在此需要慎重考虑的是,是否使用CreateRemoteThread()实现远程注入。每当注入程序访问目标进程的虚拟地址空间之前都要调用CreateRemoteThread(),它首先会用OpenProcess()打开目标进程,并传递PROCESS_ALL_ACCESS标志作为实参,这样对目标进程会有最高访问权限。这种情况下,OpenProcess()对于某些低ID(low ID)进程会返回NULL。这是因为,尽管使用合法的进程ID,但注入程序的上下文所具有的权限还不足以访问目标进程。稍思考片刻,你立即会发现这其实很必要。所有被严格限制访问的进程都是操作系统的一部分,因此普通进程不应该访问它们。如果一些存在bug的进程突然试图终止一个操作系统的进程会发生什么事情呢?为了避免操作系统上发生这些问题,应用程序需要具备足够的特权才能调用那些改变操作系统行为的API,要通过OpenProcess()访问操作系统资源(例如smss.exe,winlogon.exe,services.exe等),你必须具有调试特权级(debug privilege)。这是一种非常强大的功能,它提供一种访问操作系统资源的途径,这通常是被限制的。调整进程特权级的过程比较麻烦,可以描述如下:
    a.用目标特权级所需访问许可打开进程记号(processs token)
    b.为了指定特权级名称“SeDebugPrivilege”,必须定位它的本地LUID映射。各个特权级都被冠以名称并可以在平台SDK的winnt.h中找到。
    c.调用AdjustTokenProvileges()函数以调整进程记号(token),这样就使得“SeDebugPrivilege”特权生效。
    d. 关闭通过OpenProcessToken()函数获得的进程记号句柄。
    关于改变特权级的更多信息可以参考[参考10]“using privilege”。

4.通过BHO插件注入
    有时候我们只想把自定义代码注入Inernet Explorer。幸运的是,微软公司为这样的需求提供了一种简单且有详细文档记录的解决方法——浏览器辅助对象(BHO)。BHO用COM DLL实现,而且一旦正确注册,以后每当IE加载,所有实现了IobjectWithSite接口的COM组件都会随之一起加载。

5.微软Office插件
    与BHO插件类似,如果要向微软office系列应用程序注入用户自定义代码,只需借助微软提供的高级机制来实现office插件,以达到上述目的。很多现成代码展示了如何实现这类插件。

拦截机制
    向外部进程注入DLL是拦截系统的关键环节。它提供了控制外部线程活动的极好机会。尽管如此,这样还不足以拦截API调用。
    本部分将会对现实世界中的API拦截方法作一个概述,并着眼于每种方法的要点,同时揭示它们各自的优点和缺点。
    根据所使用钩子的层次,可以把拦截API的钩子机制分为两种——内核级和用户级。要更好的理解这两种层次,就必须掌握win32子系统API和内部API(Native API)之间的关系。下图解释了不同层次的钩子所在的位置,并说明了在Windows 2k系统上,各个模块的关系以及它们的依赖性。

图2
 
    在实现上主要的不同点在于内核级拦截引擎用内核模式驱动程序实现,而用户级钩子通常以用户模式DLL实现。
 
1.NT内核级钩子

    在内核模式中有几种方法拦截NT系统服务。最流行的方法在Mark Russinovich和Bryce Cogswell的文章[参考3]"Windows NT System-Call Hooking"中有详细描述。其基本思想是,在用户模式下实现监视NT系统调用的拦截机制。这种技术非常强大,它提供了一种灵活的方法,在系统内核处理用户线程请求前将其拦截下来。
    你可以在 "Undocumented Windows 2000 Secrets" 中找到上述机制的极好设计和实现。在这本书中,Sven Schreiber 解释了如何从零开始建立一个内核级钩子框架[参考5]。
     另外一个全面分析和高明的实现来自Prasad Dabak所著的[参考17]"Undocumented Windows NT"。尽管如此,所有上述的拦截策略都超出了本文的讨论范围。

你可能感兴趣的:(Win32,api,Hooking,API拦截,revealed)