PE文件格式与API HOOK

对于 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,怎么样,听见那段音乐了吗?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值