win32 api拦截---------hips核心技术
拦截 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” 的进程的地址空间。
每当 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() ,它必须在各进程的地址空间中使用。就是说,该句柄的值必须被客户进程和拦截服务器共享。因此,钩子句柄应该放置在共享数据段中。
以下例子调用了 #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()
同时应该添加 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() 作为线程体,这样它就可以在远程线程产生后被执行。接下来看下面的示例代码:
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 标志作为实参,这样对目标进程会有最高访问权限。这种情况下, 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 系统上,各个模块的关系以及它们的依赖性。
在实现上主要的不同点在于内核级拦截引擎用内核模式驱动程序实现,而用户级钩子通常以用户模式 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" 。尽管如此,所有上述的拦截策略都超出了本文的讨论范围。
2 .Win32用户级钩子
A. 窗口子类
这种方法适用于那些会根据不同窗口过程的实现而具有不同行为的应用程序。要完成上述工作(通过更改窗口过程来执行用户自定义代码),只需对该特定窗口简单调用 SetWindowLongPtr() ,传递 GWLP_WNDPROC 和用户自定义窗口过程的指针作为实参即可。一旦建立好用户自定义窗口过程,以后 windows 每次分发消息到目标窗口时,都会调用用户自定义的窗口过程了。
这种机制的缺点是,子类只在指定进程(当前进程)边界范围内( the boundaries of a specific process )有效。就是说,应用程序不能为其它进程创建的窗口建立窗口子类。
通常,这种方法适用于通过插件拦截应用程序,这样就能够取得要替换窗口过程的窗口的句柄了。
例如, IE 插件( BHO ),它通过窗口子类把 IE 的浮动菜单替换掉。
B. 代理DLL(DLL木马)
拦截 API 的另一种简单方法是,用具有相同名称、相同导出符号的 DLL 替换掉应用程序原来的 DLL 。借助函数导出节( function forwarders )实现这种技术会很容易。从根本上说,函数导出节就是 DLL 入口处的导出节,它代表本模块与其它 DLL 的函数调用关系。
你可以简单使用 #pragma comment 完成以上工作:
#pragma comment(linker, "/export:DoSomething=DllImpl.ActuallyDoSomething")
如果你决定使用这种方法,你应该自行处理库新旧版本之间的兼容性问题。更多信息请参考 [ 参考 13a]“Export forwarding” 和 [ 参考 2]“Function Forwarders” 。
C. 代码重写(Code Overwriting)
很多函数拦截的方法都基于代码重写。其中一种通过改变 call 指令的目标地址实现代码重写。这种方法使用困难,而且容易出错。基本思想是,拦截内存中所有的 call 指令并以用户提供的地址替换其原来的函数地址。
代码重写的另一种方法实现起来更复杂。简单说,基本思想就是先定位原有 API 函数地址,然后通过 jmp 指令改变函数体前几个字节来重定向到用户自定义的 API 函数去执行。这种方法需要极强技巧性,并涉及到对每个 call 调用的一系列恢复和拦截操作。要指出的一点是,如果函数处于未被拦截的状态( unhooked mode )并且该函数正被调用,则将不能拦截到对该函数的下一次调用。
上述方法的主要问题是,它跟多线程环境中的线程规则相冲突。
尽管如此,还是有巧妙解决方法的,它解决了一些问题并提供了可以基本实现 API 拦截的成熟的方法。如果对上述问题有兴趣,可以查看 Detours 解决方案。
D .通过调试器拦截api调用
另一个替代的方法是在目标函数内插入断点。但这种方法也有些缺点。主要问题是抛出调试异常时( debugging exceptions )会挂起当前应用程序所有子线程的执行。还需要一个调试线程处理这个异常。另一个问题是,当调试过程( debugger )完成时, windows 就会把调试器关闭。
E .通过改变导入地址表拦截api调用
这种技术最初由 Matt Pietrek 公布,后来由 Jeffrey Ritcher ( [ 参考 2] “ 通过操作模块的导入节实现 API 拦截 ” )和 John Robbins ( [ 参考 4]“ 拦截导入函数 ” )加以详细描述。这是种强大简单而且容易实现的方法,也满足在 winNT/2k 和 9x 上运行拦截系统的大部分需求。这种技术基于 windows 的可执行文件结构。要理解这种方法的工作过程,必须熟悉 PE 文件结构,它是通用文件对象格式( COFF )的扩展。 Matt Pietrek 在 [ 参考 6]“ 深入 PE 格式 ” 以及 [ 参考 13a/b]“win32PE 格式深度透视 ” 中详述了 PE 格式的相关细节。我将给出 PE 格式的概述,旨在让读者明白通过操作导入表实现 api 拦截的思想。
通常来说,一个 PE 二进制文件的格式是经过组织的,因此它具有代码节( code sections )和数据节( data sections ),这与可执行文件在内存中的格式是一致的。 PE 文件格式在逻辑上由几个节组成,每个节维护特定的数据,并符合操作系统程序加载器的特定要求。
请注意 .idata 节,它包含导入地址表的信息。这部分信息对于一个更改 IAT 拦截 api 调用的系统相当重要。