对于
windows
低层编程来说,进行
API
拦截始终是一件让人激动的事,用自己的代码来改变其它程序的行为,还有比这个更有趣吗?而且,在实现
API
拦截的过程中我们还有机会去熟悉许多在
RAD
编程环境中很少接触的东西,如
DLL
远程注入、内存管理,
PE
文件格式等知识。许多商业软件,如金山词霸等词典软件,各种即时汉化软件、甚至一些网络游戏的外挂中都用到了这种技术,各种调试工具中多多少少也要用到这种技术。
实现
API
拦截的一种方法是修改
PE
文件中的输入地址表。在
32
位
windows
中,无论是
.EXE
文件,还是
.DLL
文件都是采用
PE
文件格式,
PE
文件格式将程序所有调用的
API
函数的地址信息存放在输入地址表中,而在程序码中,对
API
的调用使用的地址不是
API
函数的地址,而是输入地址表中该
API
函数对应的地址。我们只要修改输入地址表中函数地址就可以拦截
API
了。
首先我们来熟悉一下
PE
文件格式,由于
PE
文件格式本身比较复杂,涉及到的数据类型较多,所以在这里只介绍一部分内容。我已经画了一幅示意图,大致描绘出
PE
文件格式,其中有的结构中的数据是一个
RVA
,凡是这样数据在图中都已注明。

PE
文件是由一个
DOS
文件头开始的,紧接在它后面的是一个
DOS stub
,它们合在一起实际上是一个完整的
DOS
程序,在
PE
文件中提供它们最主要的目的是由于兼容性,如果我们在
DOS
中去执行一个
win32
程序,这个
DOS
程序就会显示出“
This program can not run in dos mode”
之类的语句。在它们的后面才是真正的
PE
文件头,所以这两个部分并不重要,但是由于每一个
DOS stub
的大小并不一样,所以我们必须要用
DOS
文件头中一个成员
e_lfanew
来定位
PE
文件头,
DOS
文件头被定义成
IMAGE_DOS_HEADER
结构。它的成员
e_lfanew
中含有
PE
文件头的“相对虚拟地址”(
RVA
)。
在这里我们要解释一下
RVA
(相对虚拟地址),在
PE
文件中经常见到这个名词,所谓
RVA
指的是相对于模块起始地址的偏移量,所以
RVA
必须要加上模块的起始地址才能得到真正的地址。之所以称它为“虚拟”的是因为在一个
PE
格式文件没有被装入内存之前,
RVA
是没有意义的,只有
PE
格式文件被装入内存后,
RVA
才是有意义的。

举例说明:如上图所示:
假设某个
PE
文件的装入虚拟地址(
VA
)为
400000h
,而这个
PE
文件中的
DOS
头中的成员
e_lfanew
的值为
40h
(
RVA
)的话,
那么它所指的
PE
文件头的虚拟地址(
VA
)就是
400040h
。
在
DOS stub
后面才是我们感兴趣的
PE
文件头,它被定义成
IMAGE_NT_HEADERS
结构,这个结构中含有整个
PE
文件的信息,它的定义如下:
(
这里用汇编语言定义,在
winnt.h
中有基于
C
语言的定义
)
IMAGE_NT_HEADERS STRUCT
Signature dd ?
FileHeader IMAGE_FILE_HEADER <>
OptionalHeader IMAGE_OPTIONAL_HEADER32<>
IMAGE_NT_HEADERS ENDS
而这个结构中,与我们
API
拦截有关的是最后一项
OptionalHeader
,它被定义成
IMAGE_OPTIONAL_HEADER32
结构,这个结构共有
31
个域,定义如下:(省略了一部分与
API
拦截无关的)
IMAGE_OPTIONAL_HEADER32 STRUCT
…
…
NumberOfRvaSizes dd ?
DataDirectory IMAGE_DATA_DIRECTORY 16 dup(<>)
IMAGE_OPTIONAL_HEADER32 STRUCT
其中我们需要的是最后的
DataDirectory
域,这个域被称为“数据目录”,它是由
16
个
IMAGE_DATA_DIRECTORY
结构组成的数组,每个数组中存放了
PE
文件的一个重要的数据结构的信息,其中第二个元素称为“引入表”,在“引入表”中存放了
PE
文件所调用的
DLL
及外部函数的信息,包括引入函数所在
DLL
名,引入函数名,引入函数地址等。我们实现
API
拦截的方法就是要将“引入表”中的引入函数地址改成我们自已的函数地址。
IMAGE_DATA_DIRECTORY
定义如下:
IMAGE_DATA_DIRECTORY STRUCT
VirtualAddress dd ?
isize dd ?
IMAGE_DATA_DIRECTORY ENDS
其中
VirtualAddress
是数据结构的相对虚拟地址,
isize
含有
VirtualAddress
所指向的数据结构的大小。举例来说,一个关于
“引入表”的
IMAGE_DATA_DIRECTORY
结构中,
VirtualAddress
包含了“引入表”的
RVA
。利用这个
RVA
我们就可以找到“引入表”。
“引入表”本身是一个由
IMAGE_IMPORT_DESCRIPTOR
结构组成的数组,数组中的每个
IMAGE_IMPORT_DESCRIPTOR
元素包含一个
PE
文件引用的
DLL
的信息,所以数组中元素个数与
PE
文件引用的
DLL
个数有关。这个数组以一个全
0
的
IMAGE_IMPORT_DESCRIPTOR
结构结束。下面看一下
IMAGE_IMPORT_DESCRIPTOR
结构的定义:
IMAGE_IMPORT_DESCRIPTOR STRUCT
union
Characteristics dd ?
OriginalFirstThunk dd ?
ends
TimeDataStamp dd ?
ForarderChain dd ?
Name1 dd ?
FirstThunk dd ?
IMAGE_IMPORT_DESCRIPTOR ENDS
这个结构中的成员并不是每一个都和我们讨论的
API
拦截有关,但是它实在是太有趣了,所以在这里介绍一下它的部分成员。
第一个成员是一个
union
子结构,这个子结构其实只是给
OriginalFirstThunk
加了个别名而已,该成员含有指向一个
IMAGE_THUNK_DATA
结构数组的
RVA
。
那么什么是
IMAGE_THUNK_DATA
呢?它的定义如下:
IMAGE_THUNK_DATA STRUCT
union u1
ForwarderString dd ?
Function dd ?
Ordinal dd ?
AddressOfData dd ?
ends
IMAGE_THUNK_DATA ENDS
虽然看起来很复杂,其实它不过是一个
DWORD
型的变量,一般我们将它看作是一个指向
IMAGE_IMPORY_BY_NAME
结构的
RVA
。至于
IMAGE_IMPORY_BY_NAME
结构它存放了一个引入函数的信息。定义如下:
IMAGE_IMPORT_BY_NAME STRUCT
Hint dw ?
Name1 db ?
IMAGE_IMPORT_BY_NAME ENDS
其中
Hint
指示本函数在
DLL
的“引出表”中的索引号,而
Name1
含有函数名。(这个成员本来的定义应该是
Name
,但是
Name
是汇编语言的伪指令,所以用
Name1
代替,注意
Name1
本身就含有函数名,它不是一个
RVA
。)
真正和我们讨论的主题
API
拦截有关的是
FirstThunk
。它也是指向一个
IMAGE_THUNK_DATA
结构数组的
RVA
,这个
IMAGE_THUNK_DATA
和前面所说的
OriginalFirstThunk
所指向的
IMAGE_THUNK_DATA
并不是同一个数组,不过它们是有联系的,在
PE
文件未被装入内存之前,这两个数组的内容完全相同,但是在
PE
文件被装入内存后,
OrigianalFirstThunk
所指向的
IMAGE_THUNK_DATA
结构数组的内容保持不变,还是指向
IMAGE_IMPORT_BY_NAME
结构,而
FirstThunk
所指向的
IMAGE_THUNK_DATA
结构数组的内容就改成了引入函数的真实地址了,这时我们称这个结构数组为输入地址表
IAT
(
Import Address Table
)。我们实现
API
的关键就是修改
IAT
中的数据,将它改成我们自己的函数的地址。
看了上面的介绍你是否已经知道我们
API
拦截的实现方法了,对,我们先取得模块的起始地址,然后利用
IMAGE_DOS_HEADER
结构中的
e_lfanew
域来定位到
IMAGE_NT_HEADER
结构,获取
OptionalHeader
结构中的数据目录地址,取数据目录的第二个成员,提取其
VirtualAddress
的值,这样,我们得到了
IMAGE_IMPORT_DESCRIPTOR
结构数组
,
也就是“引入表”。关键代码如下:
mov eax,hMoudle ;hMoudle为模块起始地址
mov esi,eax
assume esi :ptr IMAGE_DOS_HEADER ;假设esi指向一个IMAGE_DOS_HEADER结构
add esi,[esi].e_lfanew ;此时esi指向PE header
assume esi :ptr IMAGE_NT_HEADERS ;假设esi指向一个IMAGE_NT_HEADERS结构
mov ebx,[esi].OptionalHeader.DataDirectory[sizeof IMAGE_DATA_DIRECTORY].VirtualAddress ;
取引入表的
RVA
add eax,ebx ;
由
RVA
加上模块起始地址得到引入表的实际地址
.
mov esi,eax
assume esi :ptr IMAGE_IMPORT_DESCRIPTOR;
假设
esi
是指向一个
IMAGE_IMPORT_DESCRIPTOR
结构
我们遍历这个数组中的每一个
IMAGE_IMPORT_DESCRIPTOR
结构,检查其中由
FirstThunk
所指向的
IAT
表,如果其中有函数地址和我们要拦截的
API
函数地址相同,就修改它。
invoke GetModuleHandle,addr DllName ;
取得要拦截
API
所在的
DLL
名称
invoke GetProcAddress,eax,addr ApiName
mov ProcAddr,eax ;
取得我们要拦截的
API
的地址
,
并存放在
ProcAddr
中。
.while!([esi].OriginalFirstThunk==0 && [esi].TimeDateStamp==0 && [esi].ForwarderChain==0 && [esi].Name1==0 && [esi].FirstThunk==0) ;
引入表由一个全
0
的
IMAGE_IMPORT_DESCRIPTOR
作为结束
mov edi,hMoudle
add edi,[esi].FirstThunk ;
获得
IAT
表的起始地址
assume edi :ptr IMAGE_THUNK_DATA ;
假设
edi
是指向
IMAGE_THUNK_DATA
的
.while [edi]!=0 ;
检查
IAT
表中的每一项
,
如果等于我们要拦截的
API
地址
,
则修改
mov ebx,[edi] ;
由于
IMAGE_THUNK_DATA
数组存放了引入函数的地址
,
所以此时
ebx
中是函数地址
.if ebx==ProcAddr ;
如果和我们要拦截的
API
地址相同
invoke GetCurrentProcess
mov ProcHandle,eax ;
得到当前进程的句柄并放在
ProcHandle
中
invoke VirtualProtectEx,eax,edi,sizeof DWORD,PAGE_READWRITE,addr Old ;
修改内存属性
mov eax,offset NewExitProcess ;NewExitProcess
是我们自己的
API
实现函数
mov NewAddr,eax
invoke WriteProcessMemory,ProcHandle,edi,addr NewAddr,sizeof DWORD,NULL ;
进行改写
.endif
add edi, sizeof IMAGE_THUNK_DATA
.endw
add esi,sizeof IMAGE_IMPORT_DESCRIPTOR
.endw
由模块起始地址查找
IAT
表地址的示意图如下:

在
IMAGE_IMPORT_DESCRIPTOR
结构中的
Name1
含有指向
DLL
名字的
RVA
,利用它你可以列举一个
PE
文件引用了哪些
DLL
。
好,现在我们已经知道实现
API
拦截的关键了,但是还有一些问题没有解决。
先来说说第一个问题,因为
Windows
是不允许一个进程去访问另一个进程的内存空间的,所以我们不能用一个进程去修改另一个进程的
IAT
表,要想修改进程的
IAT
表,只能由这个进程自已来做,一个已经写好的程序当然不会好好地去修改它自身的
IAT
表,不过我们可以将我们自己的
DLL
注入到它的进程空间里去,一旦
DLL
注入到一个进程的内存空间中以后,这个
DLL
就成了这个进程的一部分,它就能够访问这个进程的所有的内存空间,当然也就能修改它的
IAT
表了。将一个
DLL
注入到一个目标进程中去的方法有很多,但是考虑到兼容性,用
windows
提供给我们的系统范围的
windows
钩子来完成
DLL
注入是最好的。我们可以用
SetWindowsHookEx
来安装一个系统钩子,这个
API
的用法如下:
HHOOK SetWindowsHookEx(
int idHook, //
钩子类型,本例中指定为
WH_GETMESSAGE
钩子,其它的类型参见
MSDN
HOOKPROC lpfn, //
钩子的回调消息函数。
HINSTANCE hMod, //
指定回调消息函数所在的
DLL
句柄。
DWORD dwThreadId //
钩子监视的线程句柄,本例中因为要的是系统范围钩子,故设为
0
);
我们安装一个系统钩子的主要目的是用它来将我们的
DLL
注入到其它进程中去,所以钩子的回调消息函数并不重要,只要调用一下
CallNextHookEx
来向后传递钩子就可以了。你可以调用
UnhookWindowsHookEx
来卸载一个系统钩子,它只要一个参数:钩子句柄。
第二个问题是
DLL
被注入到目标进程的内存空间中以后,它在什么时候进行修改呢?这要用到
DLL
的入口点函数,每一个
DLL
都有一个入口点函数,当
DLL
被装入内存时
,或是它从内存中卸载时这个入口点函数都会自动地被执行,本来入口点函数主要是做一些初始化工作或是做一些收尾工作的,我们的API
拦截代码
放在这里是最恰当的。因为一个单个进程空间是由一个可执行模块和若干个DLL模块组成的,而一个程序在运行时,加载程序将可执行模块加载进内存空间后会接着加载这个进程的所有的DLL模块,在加载我们注入的DLL模块时,入口点函数自动被执行,进行IAT表的修改工作。此时,进程的主线程还没有开始运行。在进程所有的DLL被全部装入内存后,主线程才开始执行,应用程序也才开始运行,这时我们已经将它的IAT表修改了,在它调用被我们修改了地址的API时,它的调用就会转到我们自己的函数中去,这样就实现了API
拦截
。DLL的入口点函数般写法如下:
DllEntry proc hInstDll:HINSTANCE,reason:DWORD,reserved1:DWORD ;DLL
的入口点函数
.if reason==DLL_PROCESS_ATTACH ;
当
DLL
第一次被装入时调用
push hInstDll
pop DllhInst ;
保存
DLL
的句柄在变量
DllhInst
中
………
.if reason==
DLL_PROCESS_DETACH ;
当DLL从进程空间卸出时调用
………
DllEntry endp
不过,一般windows是不允许我们动态修改代码段的,因为代码段一般只具有执行属性而不具有读写属性,如果我们去写一个不具备写属性的内存空间时,windows会出现一个保护性错误,所以我们在修改之前必须要使我们想要修改的内存地址具有读写属性,这个工作可以用
VirtualProtectEx
来完成。它的具体参数在MSDN中有详细说明。有一种说法认为直接用
WriteProcessMemory
就能够修改内存,这个说法其实不一定正确,如果事先不用VirtualProtectEx来修改内存属性的话,WriteProcessMemory并不总是能成功地完成修改。代码如下:
mov ProcHandle,eax ;得到当前进程的句柄并放在ProcHandle中
invoke VirtualProtectEx,eax,edi,sizeof DWORD,PAGE_READWRITE,addr Old ;修改内存属性
mov eax,offset NewExitProcess ;NewExitProcess是我们自己的API实现函数
mov NewAddr,eax
invoke WriteProcessMemory,ProcHandle,edi,addr NewAddr,sizeof DWORD,NULL ;进行改写
另外,如果我们的DLL由于某种原因从内存中卸出,这时目标进程的IAT中的地址就会变成一个无效的值,进程如果这时调用被拦截API的话就一定会崩溃掉,所以在DLL被卸出进程的内存空间时,我们一定要将IAT表中数据恢复。这个恢复工作当然也是放在DLL的入口点函数,因为在DLL被卸出时它也被自动执行。
还有一个问题是如何取得模块的起始地址。在PE文件中所用的都是RVA,只有将RVA加上模块的起始地址才能得到真正的内存地址,而正如我们上面所说,一个进程的地址空间是由一个可执行模块和若干个DLL模块组成的,DLL模块同样有自己的引入表,我们要拦截的API有可能在可执行模块中被调用,也有可能在DLL模块中被调用,
所以为了正确的拦截,我们必须列举出进程空间中所有的模块,修改它们的IAT表。这里介绍几个需要的API:CreateToolhelp32Snapshot,作用是创建一个进程快照,它有两个参数,指定第一个参数为
TH32CS_SNAPMODULE
,第二个参数为
0
,此时这个
API
返回一个快照句柄,再利用
Module32First
和
Module32Next
这两个
API
就可以列出这个进程中的所有模块地址。这里要注意的是:我们进行修改工作的
DLL
本身也是进程中的一个模块,而且这个模块的
IAT
表中一定会有被拦截的
API
,对这个模块是不能进行修改的,所以在对进程中的模块进行修改之前先要判断一个这个模块是不是这个
DLL
自身,我们可以用
VirtualQuery
来得到进行修改工作的
DLL
的起始地址,利用这个起始地址来判断当前获取的模块是不是其自身。代码如下
:
invoke VirtualQuery,offset Modify,addr MemBaseinform,sizeof MemBaseinform ;获取DLL本身所在模块信息
invoke CreateToolhelp32Snapshot,TH32CS_SNAPMODULE,NULL ;创建一个进程快照,返回一个快照句柄
mov snapshot,eax
mov module.dwSize,sizeof MODULEENTRY32 ;在调用Module32First之前先设置module的大小,否则调用会失败
invoke Module32First,snapshot,addr module ;获取进程中第一个模块的信息
.while eax==TRUE ;检查进程空间中每一个模块
mov ebx,MemBaseinform.AllocationBase;ebx中存放我们自己的DLL本身的起始地址
.if module.hModule!=ebx
invoke Modify,module.hModule ;进行修改,module.hModule指定了被修改模块的起始地址
.endif
invoke Module32Next,snapshot,addr module ;取下一个模块
.endw
整个源程序代码是用宏汇编来写的,因为汇编语言相对于其它的语言来说,是最直接的一种编程语言,利用它能够将问题说得更清楚一点。在我的例子中我
拦截
的API是ExitProcess,当然我不会自己去写一个ExitProcess,我只是在ExitProcess的前面加了一段音乐,这样进程在调用ExitProcess退出时会先放一段音乐。为了简单起见,代码中有一部分内容没有实现,比如钩子的卸载,DLL被卸出时对IAT表的恢复,这些内容你可以自己加上去。
DLL
部分: apidll.asm
(略)
DLL
文件的
DEF
文件
: apidll.def
LIBRARY apidll
EXPORTS MouseProc
EXPORTS InstallHook
汇编命令
:ml /c /coff
apidll.asm
连接命令:link /subsystem:windows /section:.bss,RWS /dll /def:apidll.def apidll.obj
以上是DLL部分,我们必须还需要一个程序进行
系统钩子的
安装工作。下面的代码就是系统钩子的安装部分:
安装程序: me.asm
(略)
汇编命令:ml /c /coff
me.asm
连接命令:link /subsystem:windows me.obj
好,汇编、连接好这个程序之后,就可以运行了,这个安装程序只提供了安装钩子功能,没有提供卸载钩子功能,你可以自己补上,运行这个程序,按一下命令按钮,系统钩子被装入系统,这时,API拦截工作已经开始,因为我们安装的是系统范围的钩子,所以此时系统内所有的进程都会受到影响。你可以找一个程序试一下,因为这篇文章是用word 2000输入的,就试试word 2000吧,运行word 2000,好像没有什么反应,这是因为我们拦截的是ExitProcess,关闭word 2000,怎么样,听见那段音乐了吗?