翻译:
一、
序言对大多数的Windows开发者来说,如何在Win32系统中对API函数的调用进行拦截一直是项极富挑战性的课题,因为这将是对你所掌握 的计算机知识较为全面的考验,尤其是一些在如今使用RAD进行软件开发时并不常用的知识,这包括了操作系统原理、汇编语言甚至是关于机器指令代码的(听上 去真是有点恐怖,不过这是事实)。
当前广泛使用的Windows操作系统中,像Win 9x和Win NT/2K,都提供了一种比较稳健的机制来使得各个进程的内存地址空间之间是相互独立,也就是说一个进程中的某个有效的内存地址对另一个进程来说是无意义 的,这种内存保护措施大大增加了系统的稳定性。不过,这也使得进行系统级的API拦截的工作的难度也大大加大了。
当然,我这里所指的是比较文雅的拦截方式,通过修改可执行文件在内存中的映像中有关代码,实现对API调用的动态拦截;而不是采用比较暴力的方式,直接对可执行文件的磁盘存储中机器代码进行改写。
二、
API钩子系统一般框架通常,我们把拦截API的调用的这个过程称为是安装一个API钩子(API Hook)。一个API钩子至少有两个模块组成:一个是钩子服务器(Hook Server)模块,一般为EXE的形式;一个是钩子驱动器(Hook Driver)模块,一般为DLL的形式。
服务器主要负责向目标进程注入驱动器,使得驱动器工作在目标进程的地址空间中,这是关键的第一步。驱动器则负责实际的API拦截工作,以便在我们所关心的API函数调用的前后能做一些我们需要的工作。
一个大家比较常见的API钩子的例子就是一些实时翻译软件(像金山词霸)中必备的的功能:屏幕抓词,它主要是对一些GDI 函数进行了拦截,获取它们的输入参数中的字符串,然后在自己的窗口中显示出来。针对上述的两个部分,有以下两点需要我们重点考虑的: 选用何种DLL注入技术 采用何种API拦截机制
三、
注入技术的选用由于在Win32系统中各个进程的地址是互相独立的,因此我们无法在一个进程中对另一个进程的代码进行有效的修改。而你要完成 API钩子的工作就必须进行这种操作。因此,我们必须采取某种独特的手段,使得API钩子(准确的说是钩子驱动器)能够成为目标进程中的一部分,才有较大 的可能来对目标进程数据和代码进行有控制的修改。
通常有以下几种注入方式:
1.利用注册表如果我们准备拦截的进程连接了User32.dll,也就是使用了User32中的API(一般图形界面的应用程序都符合这个条 件),那么就可以简单把你的钩子驱动器DLL的名字作为值添加在下面注册表的键下: HKEY_LOCAL_MACHINE/Software/Microsoft/WindowsNT/CurrentVersion/Windows/AppInit_DLLs 值的形式可以为单个DLL的文件名,或者是一组DLL的文件名,相邻的名称之间用逗号或空格间隔。所有由该值标识的DLL将在符合条件的应用程序启动的时 候装载。这是一个操作系统内建的机制,相对其他方式来说危险性较小,但它有一些比较明显的缺点: 该方法仅适用于NT/2K操作系统。看看键的名称你就应该明白 为了激活或停止钩子的注入,必须重新启动Windows。这个就似乎太不方便了 不能用此方法向没有使用User32的应用程序注入DLL,例如控制台应用程序 不管需要与否,钩子DLL将注入每一个GUI应用程序,这将导致整个系统性能的下降
2.
建立系统范围的Windows钩子要向某个进程注入DLL,一个十分普遍也是比较简单的方法就是建立在标准的Windows钩子的基础上。 Windows钩子一般是在DLL中实现的,这是一个全局性的Windows钩子的基本要求,这也符合我们的需要。当我们成功地调用 SetWindowsHookEx函数之后,便在系统中安装了某种类型的消息钩子,这个钩子可以是针对某个进程,也可以是针对系统中的所有进程。一旦某个 进程中产生了该类型的消息,操作系统会自动把该钩子所在的DLL映像到该进程的地址空间中,从而使得消息回调函数(在SetWindowsHookEx的 参数中指定)能够对此消息进行适当的处理,在这里,我们所感兴趣的当然不是对消息进行什么处理,因此在消息回调函数中只需把消息钩子向后传递就可以了,但 是我们所需的DLL已经成功地注入了目标进程的地址空间,从而可以完成后续工作。
我们知道,不同进程中使用的DLL之间是不能直接共享数据的,因为它们活动在不同的地址空间中。但在Windows钩子DLL中,有一些数据, 例如Windows钩子句柄HHook,这是由SetWindowsHookEx函数返回值得到的,并且作为参数将在CallNextHookEx函数和 UnhookWindoesHookEx函数中使用,显然使用SetWindowsHookEx函数的进程和使用CallNextHookEx函数的进程 一般不会是同一个进程,因此我们必须能够使句柄在所有的地址空间中都是有效的有意义的,也就是说,它的值必须必须在这些钩子DLL所挂钩的进程之间是共享 的。为了达到这个目的,我们就应该把它存储在一个共享的数据区域中。
在VC++中我们可以采用预编译指令#pragma data_seg在DLL文件中创建一个新的段,并且在DEF文件中把该段的属性设置为“shared”,这样就建立了一个共享数据段。对于使用 Delphi的人来说就没有这么幸运了:没有类似的比较简单的方法(或许是有的,但我没有找到)。不过我们还是可以利用内存映像技术来申请使用一块各进程 可以共享的内存区域,主要是利用了CreateFileMapping和MapViewOfFile这两个函数。这倒是一个通用的方法,适合所有的开发语 言,只要它能使用Windows的API。
在Borland的BCB中有一个指令#pragma codeseg与VC++中的#pragma data_seg指令有点类似,应该也能起到一样的作用,但我试了一下,没有没有效果,而BCB的联机帮助中对此也提到的不多,不知怎样才能正确的使用。 一旦钩子DLL加载进入目标进程的地址空间后,在我们调用UnHookWindowsHookEx函数之前是无法使它停止工作的,除非目标进程关闭。
这种DLL注入方式有两个优点: 这种机制在Win 9x/Me和Win NT/2K中都是得到支持的,预计在以后的版本中也将得到支持 钩子DLL可以在不需要的时候,可由我们主动的调用UnHookWindowsHookEx来卸载,比起使用注册表的机制来说方便了许多尽管这是一种相当 简洁明了的方法,但它也有一些显而易见的缺点: 首先值得我们注意的是,Windows钩子将会降低整个系统的性能,因为它额外增加了系统在消息处理方面的时间 其次,只有当目标进程准备接受某种消息时,钩子所在的DLL才会被系统映射到该进程的地址空间中,钩子才能真正开始发挥作用。因此如果我们要对某些进程的 整个生命周期内的API调用情况进行监控,用这种方法显然会遗漏某些API的调用
3.
使用 CreateRemoteThread函数在我看来这是一个相当棒的方法,然而不幸的是,CreateRemoteThread这个函数只能在Win NT/2K系统中才得到支持,虽然在Win 9x中这个API也能被安全的调用而不出错,但它除了返回一个空值之外什么也不做。整个DLL注入过程十分简单。我们知道,任何一个进程都可以使用 LoadLibrary来动态地加载一个DLL。但问题是,我们如何让目标进程在我们的控制下来加载我们的钩子DLL(也就是钩子驱动器)呢?这里有一个 API函数CreateRemoteThread,通过它可在一个进程中可建立并运行一个远程的线程。
调用该API需要指定一个线程函数指针作为参数,该线程函数的原型如下: Function ThreadProc(lpParam: Pointer): DWORD;我们再来看一下LoadLibrary的函数原型: Function LoadLibrary(lpFileName: PChar): HModule;可以看出,这两个函数原型实质上是完全相同的(其实返回值是否相同关系不大,因为我们是无法得到远程线程函数的返回值的),只是叫法不同 而已,这种相同使得我们可以把直接把LoadLibrary当做线程函数来使用,从而在目标进程中加载钩子DLL。
类似的,当我们需要卸载钩子DLL时,也可以FreeLibrary作为线程函数来使用,在目标进程中移去钩子DLL。一切看来是十分的简洁方 便。通过调用GetProcAddress函数,我们可以得到LoadLibrary函数的地址。由于LoadLibrary是Kernel32中的函 数,而这个系统DLL的映射地址对每一个进程来说都是相同的,因此LoadLibrary函数的地址也是如此。这点将确保我们能把该函数的地址作为一个有 效的参数传递给CreateRemoteThread使用。
AddrOfLoadLibrary := GetProcAddress(GetModuleHandle(‘Kernel32.dll’), ‘LoadLibrary’);
HremoteThread := CreateRemoteThread(HTargetProcess, nil, 0, AddrOfLoadLibrary, HookDllName, 0, nil);
要使用CreateRemoteThread,我们需要目标进程的句柄作为参数。当我们用OpenProcess函数来得到进程的句柄时,通常 是希望对此进程有全权的存取操作,也就是以PROCESS_ALL_ACCESS为标志打开进程。但对于一些系统级的进程,直接这样显然是不行的,只能返 回一个的空句柄(值为零)。为此,我们必须把自己设置为拥有调试级的特权,这样将具有最大的存取权限,从而使得我们能对这些系统级的进程也可以进行一些必 要的操作。
4.
通过BHO来注入DLL 有时,我们想要注入DLL的对象仅仅是Internet Explorer。幸运的是,Windows操作系统为我们提供了一个简单的归档的方法(这保证了它的可靠性)―― 利用Browser Helper Objects(BHO)。一个BHO是一个在 DLL中实现的COM对象,它主要实现了一个IObjectWithSite接口,而每当IE运行时,它会自动加载所有实现了该接口的COM对象。
四、
拦截机制在钩子应用的系统级别方面,有两类API拦截的机制――内核级的拦截和用户级的拦截。内核级的钩子主要是通过一个内核模式的驱动程序来 实现,显然它的功能应该最为强大,能捕捉到系统活动的任何细节,但难度也较大,不在我们探讨的范围之内(尤其对我这个使用Delphi的人来说,还没涉足 这块领域,因此也无法探讨);
而用户级的钩子则通常是在普通的DLL中实现整个API的拦截工作,这才是我们现在所重点关注的。拦截API函数的调用,一般可有以下几种方法:
1. 代理DLL(特洛伊木马)一个容易想到的可行的方法是用一个同名的DLL去替换原先那个输出我们准备拦截的API所在的DLL。当然代理DLL也要和原来 的一样,输出所有函数。如果想到DLL中可能输出了上百个函数,我们就应该明白这种方法的效率是不高的。另外,我们还得考虑DLL的版本问题。
2.改写执行代码有许多拦截的方法是基于可执行代码的改写。其中一个就是改变在CALL指令中使用的函数地址,这种方法有些难度,也比较容易出错。它的基本思路是检索出在内存中所有你所要拦截的API的CALL指令,然后把原先的地址改成为你自己提供的函数的地址。
另外一种代码改写的方法的实现方法更为复杂,它的主要的实现步骤是先找到原先的API函数的地址,然后把该函数开始的几个字节用一个JMP指令 代替(有时还不得不改用一个INT指令),使得对该API函数的调用能够转向我们自己的函数调用。实现这种方法要牵涉到一系列压栈和出栈这样的较底层的操 作,显然对我们的汇编语言和操作系统底层方面的知识是一种考验。这个方法倒和很多病毒的感染机制相类似。
3.以调试器的身份进行拦截另一个可选的方法是在目标函数中安置一个调试断点,使得进程运行到此处就进入调试状态。然而这样一些问题也随之而 来,其中较主要的是调试异常的产生将把进程中所有的线程都挂起。它也需要一个额外的调试模块来处理所有的异常,整个进程将一直在调试状态下运行,直至它运 行结束。
4.改写输入地址表这种方法主要得益于现如今Windows系统中所使用的可执行文件(包括EXE文件和DLL文件)的良好结构――PE文件格 式(Portable Executable File Format),因此它相当稳健,又简单易行。要理解这种方法是如何运作的,首先你得对PE文件格式有所理解。
一个PE文件的结构大致如下图所示: 一般PE文件一开始是一段DOS程序,当你的程序在不支持Windows的环境中运行时,它就会显示“This Program cannot be run in DOS mode”这样的警告语句,接着这个DOS文件头,就开始真正的PE文件内容了。首先是一段称为“IMAGE_NT_HEADER”的数据,其中是许多关 于整个PE文件的消息,在这段数据的尾端是一个称为Data Directory的数据表,通过它能快速定位一些PE文件中段(section)的地址。在这段数据之后,则是一个 “IMAGE_SECTION_HEADER”的列表,其中的每一项都详细描述了后面一个段的相关信息。接着它就是PE文件中最主要的段数据了,执行代 码、数据和资源等等信息就分别存放在这些段中。
在所有的这些段里,有一个被称为“.idata”的段(输入数据段)值得我们去注意,该段中包含着一些被称为输入地址表(IAT,Import Address Table)的数据列表。每个用隐式方式加载的API所在的DLL都有一个IAT与之对应,同时一个API的地址也与IAT中一项相对应。当一个应用程序 加载到内存中后,针对每一个API函数调用,相应的产生如下的汇编指令:
JMP DWORD PTR [XXXXXXXX]
如果在VC++中使用了_delcspec(import),那么相应的指令就成为
CALL DWORD PTR [XXXXXXXX]。
不管怎样,上述方括号中的总是一个地址,指向了输入地址表中一个项,是一个DWORD,而正是这个DWORD才是API函数在内存中的真正地 址。因此我们要想拦截一个API的调用,只要简单的把那个DWORD改为我们自己的函数的地址,那么所有关于这个API的调用将转到我们自己的函数中去, 拦截工作也就宣告顺利的成功了。这里要注意的是,自定义的函数的调用形式应该是API的调用方式,也就是stdcall方式,而Delphi中默认的是 pascal的调用方式,也就是register方式,它们在参数的传递方式等方面存在着较大的区别。
另外,自定义的函数的参数形式可以和原先的API函数相同的,不过这也不是必须的,而且这样的话在有些时候也会出现一些问题,我在后面将会提 到。因此要拦截API的调用,首先我们就要得到相应的IAT的地址。系统把一个进程模块加载到内存中,其实就是把PE文件几乎是原封不动的映射到进程的地 址空间中去,而模块句柄HModule实际上就是模块映像在内存中的地址,PE文件中一些数据项的地址,都是相对于这个地址的偏移量,因此被称为相对虚拟 地址(RVA,Relative Virtual Address)。
于是我们就可以从HModule开始,经过一系列的地址偏移而得到IAT的地址。不过我这里有一个简单的方法,它使用了一个现有的API函数 ImageDirectoryEntryToData,它帮助我们在定位IAT时能少走几步,省得把偏移地址弄错了,走上弯路。不过纯粹使用RVA从 HModule开始来定位IAT的地址其实并不麻烦,而且这样还更有助于我们对PE文件的结构的了解。上面提到的API函数是在DbgHelp.dll中 输出的(这是从Win 2K才开始有的,在这之前是由ImageHlp.dll提供的),有关这个函数的详细介绍可参见MSDN。
在找到IAT之后,我们只需在其中遍历,找到我们需要的API地址,然后用我们自己的函数地址去覆盖它。下面给出一段对应的源码:
procedure RedirectApiCall; var ImportDesc:PIMAGE_IMPORT_DESCRIPTOR; FirstThunk:PIMAGE_THUNK_DATA32; sz:DWORD;
begin
//得到一个输入描述结构列表的首地址,每个DLL都对应一个这样的结构 ImportDesc:=ImageDirectoryEntryToData(Pointer(HTargetModule), true, IMAGE_DIRECTORY_ENTRY_IMPORT, sz);
while Pointer(ImportDesc.Name)<>nil do
begin //判断是否是所需的DLL输入描述
if StrIComp(PChar(DllName),PChar(HTargetModule+ImportDesc.Name))=0 then begin
//得到IAT的首地址
FirstThunk:=PIMAGE_THUNK_DATA32(HTargetModule+ImportDesc.FirstThunk);
while FirstThunk.Func<>nil do
begin
if FirstThunk.Func=OldAddressOfAPI then
begin
//找到了匹配的API地址 ……
//改写API的地址
break;
end;
Inc(FirstThunk);
end;
end;
Inc(ImportDesc);
end;
end;
最后有一点要指出,如果我们手工执行钩子DLL的退出目标进程,那么在退出前应该把函数调用地址改回原先的地址,也就是API的真正地址,因为一旦你的DLL退出了,改写的新的地址将指向一个毫无意义的内存区域,再使用它显然会出现一个非法操作。
五、
替换函数的编写 前面关键的两步做完了,一个API钩子基本上也就完成了。不过还有一些相关的东西需要我们研究一番的,包括怎样做一个替换函数。 下面是一个做替换函数的步骤: 首先,不失一般性,我们先假设有这样的一个API函数,它的原型如下:
function someAPI(param1: Pchar;param2: Integer): DWORD;
接着再建立一个与之有相同参数和返回值的函数类型:
type FuncType= function (param1: Pchar;param2: Integer): DWORD;
然后我们把someAPI函数的地址存放在OldAddress指针中。接着我们就可以着手写替换函数的代码了:
function DummyFunc(param1: Pchar;param2: Integer): DWORD; begin ……
//做一些调用前的操作
result := FuncType(OldAddress) (param1 , param2);
//调用原先的API函数 ……
//做一些调用后的操作
end;
我们再把这个函数的地址保存到NewAddress中,接着用这地址覆盖掉原先API的地址。这样当目标进程调用该API的时候,实际上是调用 了我们自己的函数,在其中我们可以做一些操作,然后在调用原先的API函数,结果就像什么也没发生过一样。当然,我们也可以改变输入参数,甚至是屏蔽调这 个API函数的调用。
尽管上述方法是可行的,但有一个明显的不足:这种替换函数的制作方法不具有通用性,只能针对少量的函数。如果只有几个API要拦截,那么只需照 上述说的重复几次就行了。但如果有各种各样的API要处理,它们的参数个数和类型以及返回值的类型是各不相同的,还是采用这种方法就太没效率了。
的确是的,上面给出的只是一个最简单最容易想到的方法,只是一个替换函数的基本构架。正如我前面所提到的,替换函数的与原先的API函数的参数 类型不必相同,一般的我们可以设计一个没有调用参数也没有返回值的函数,通过一定的技巧,使它能适应各种各样的API函数调用,不过这得要求你对汇编语言 有一定的了解。
下面我就对此详细的说一下。 首先,我们来看一下执行到一个函数内部前的堆栈情况(这里函数的调用方式为stdcall)。 由上面的例图可知,函数的调用参数是按照从右到左的顺序压入堆栈的(堆栈是由高端向低端发展的),同时还压入了一个函数返回地址。在进入函数之前,ESP 正指向返回地址。因此,我们只要从ESP+4开始就可以取得这个函数的调用参数了,每取一个参数递增4。另外,当从函数中返回时,一般在EAX中存放函数 的返回值。
了解了上述知识,我们就可以设计如下的一个比较通用的替换函数,它利用了Delphi的内嵌式汇编语言的特性。
Procedure DummyFunc;
asm add esp,4 mov eax,esp//得到第一个参数
mov eax,esp+4//得到第二个参数 ……
//做一些处理,这里要保证esp在这之后恢复原样
call OldAddress //调用原先的API函数 ……
//做一些其它的事情
end;
当然,这个替换函数还是比较简单的,你可以在其中调用一些纯粹用OP语言写的函数或过程,去完成一些更复杂的操作(要是都用汇编来完成,那可得 把你忙死了),不过应该这些函数的调用方式统一设置为stdcall方式,这使它们只利用堆栈来传递参数,因此你也只需时刻掌握好堆栈的变化情况就行了。 如果你直接把上述汇编代码所对应的机器指令存放在一个字节数组中,然后把数组的地址当作函数地址来使用,效果是一样的。
以上代码在 Win 2K/xp & Delphi 6.0 中实现。
六、后记
做一个API钩子的确是件不容易的事情,尤其对我这个使用Delphi的人来说,为了解决某个问题,经常在OP、C++和汇编语言的资料中东查 西找,在程序调试中还不时的发生一些意想不到的事情,弄的自己是手忙脚乱。不过,好歹总算做出了一个API钩子的雏形,还是令自己十分的高兴,对计算机系 统方面的知识也掌握了不少,受益非浅。当初在写这篇文章之前,我只是想翻译一篇从网上Down下来的英文资料(网址为 www.codeproject.com ,文章名叫“API Hook Revealed”,示例源代码是用VC++写的,这里不得不佩服老外的水平,文章写得很有深度,而且每个细节都讲的十分详细)。
不过翻着翻着,就觉得自己真是水平有限,很多地方虽然自己明白了,就是不知怎样用中文来表达意思才明确,于是只得把已经翻译出来的觉得还可以一 用的那部分加上一些自己在实际操作中的体会与心得,杂凑出了上面的这篇文章,尽管可能有些不伦不类,但也是化了我的很多时间,呕心沥血啊(惭愧,惭 愧!),希望高手不要见笑,请多多指教。 (转)