对于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,怎么样,听见那段音乐了吗?