一、
序言对大多数的
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++写的,这里不得不佩服老外的水平,文章写得很有深度,而且每个细节都讲的十分详细)。
不过翻着翻着,就觉得自己真是水平有限,很多地方虽然自己明白了,就是不知怎样用中文来表达意思才明确,于是只得把已经翻译出来的觉得还可以一用的那部分加上一些自己在实际操作中的体会与心得,杂凑出了上面的这