inline hook在我个人看来是Windows API编程里面一个比较重要的东西,尽管按照某些方面来说这个是初级的,不过多多少少还是要点内容才能学上手的。所以这篇文章的目的也只是简单的谈一谈其实现原理等东西。这里所说到的原本也是准备在CFC上学期的分享会上说的,然而由于一些奇奇怪怪的原因我那天并没有说,所以在这里补上,等下一次有机会再说了。至于其他的嘛,我只能按照我学的深度来说了。:)
所谓的inline hook是Windows系统中“挂钩”方式的一种,大家知道Windows系统是消息驱动的工作模式,在代码段的实现部分则是一个个函数之前的调用关系。inline hook就是对函数的一种挂钩方式,通过这种挂钩,使得在调用被挂钩函数之前执行自己所期望的操作,比如跳转到自己的函数,继续执行原函数功能,或者返回特定内容,等等。通俗的来说,比如A和B两个函数存在调用关系(A调用B),而inline hook就是在两个“恩爱的”函数A和B的调用关系之间加一个“小三”C函数,这个“小三”可以在A调用B的时候随意的执行自己想要的事情,比如不执行B,或者伪装成B返回假的结果,等等,而B函数本身对此却无可奈何。
所以在此之前呢我先从C语言的一小段代码讲起。在大家写C语言程序的时候一般会声明函数,或者调用。例如:
#include "stdio.h" int add(int a,int b){ return a+b; } int main(int argc,char* argv[]){ int x = add(1 , 2); printf("result of 1+2 is %d\n" , x); return 0; }
如上述内容所示,这段C语言代码包含了一个main函数和一个add函数。main函数中调用了自定义的函数add和库函数printf。你通过调用add函数完成1和2的加法,然后调用printf打印这段输出。
上面这段代码在正常执行的情况下,应该的输出是
result of 1+2 is 3
然而某一天,写这段代码的程序猿突然脑子抽了一下,他想让add返回错误的值,但又不想修改add函数本身,于是他构造了一个fake_add函数,想让他作为那个破坏main和add之间的小三函数。
int fake_add(int a,int b){ return 1+a+b; }
但是,函数写好了,程序猿又该怎么实现这个目的呢?
然后这里我们来跳一下,看下代码在内存里面是个什么样子。
程序员们应该知道的是C语言这种编译性语言是直接生成成二进制指令的。实际上,上面这段代码在内存里面给系统所看到的就是一大段二进制内容。如果你知道一点点汇编的话,你可能会知道实际上在内存里面的代码在“反编译”之后都是类似于下面这个样子的。你可能通过网上的文档,或者其他的途径,知道在Windows的二进制中,0x8B表示汇编的MOV指令,0xe9表示汇编的jmp指令,等等。
一堆汇编指令真要看起来的话会比较头疼。那么我说这个的目的是想说明,其实你写的代码、函数,到最后都会是这种二进制的形式。某一个字节代表什么指令,后面跟的代表什么含义。就是这个样子。包括函数本身的调用也只是一条指令(汇编的call指令,二进制表示为0xE8)。在计算机组成原理课上说过,指令是一条一条按照顺序执行的,也就是说上面这些汇编指令都是按照顺序一条一条执行的,当我执行到call的时候,表示我要调用某个函数,执行到jmp的时候表示跳转到哪一条指令。
那么回到之前的问题,既然指令按照顺序执行,那么我人为的改变他的执行过程,修改内存里面指令的内容,使得他在调用目标函数之前先调用我自己的函数,这样不就可以达到挂钩的目的了吗?
这也就是inline hook的由来。因为这个钩子是通过修改函数执行指令来达到挂钩的。
那么,程序猿又应该如何去修改这段指令,使得add函数调用之前先调用fake函数呢?这里要提到两个函数。VirtualProtect和WriteProcessMemory.
这里我要补充一点点关于PE(可执行程序)本身的事情。在Windows将程序载入内存的时候,会在内存中分配多个“页”(对于不理解PE内容的,将其认为是一块内存区域就可以了),将程序的内容载入进去,并且按照数据类型分配“页”的属性,比如,数据段不能执行,所以这段内存属性是“可读可写”,代码段应该只能被执行,因此这段属性为“可执行,只读”。
了解了上面这点东西以后也就知道,如果我们要修改二进制代码内容,需要有权限修改这段区域的内容才可以,因此调用VirtualProtect修改add函数附近的内存属性,使得其可以被修改。
DWORD dwOldProtect , dwTemp; //VirtualProtect调用约束: // 基地址:内存片段中的起始位置 // 长度 :要修改内存中多少个字节的属性 // 保护 :将从基地址开始的这些字节修改为什么样的内存保护的属性。 // 旧保护:此参数用于保存该内存片段原始的内存保护属性 if(VirtualProtect(add , 5 , 64 , &dwOldProtect)){ //do hook here } //恢复内存段为可执行属性 VirtualProtect(add , 5 , dwOldProtect , &dwTemp);
如上述代码片段所示,调用VirtualProtect函数修改add函数附近的5个字节码为可读可写(第三个参数64表示修改该内存段保护为可读可写),并将该段内存原来的保护存储到dwOldProtect中。在完成do hook部分(也就是修改代码执行逻辑)的内容后,将内存保护修改回原来的值。
既然do hook部分是要修改函数的执行逻辑,让执行add函数的时候执行到fake_add函数,我们要想办法执行一条指令,使得执行跳转到fake_add函数去。在汇编语言中使用jmp进行跳转,而jmp指令对应的二进制是0xe9,那么我们构造下面这种代码
jmp <fake_add的函数地址>
并将这段代码写入add函数的开始部分,就可以达到执行add前跳转执行fake_add的目的了。涉及到内存写入,这才有了WriteProcessMemory函数
//获取当前的进程句柄。类似于一种“对象” HANDLE currentProcessHandle = GetCurrentProcess(); //构造字节码 BYTE shellCode[5] = {0}; shellCode[0] = 0xe9;//jmp指令 //获取HOOK函数和原始函数地址 LPVOID originAdd = add , fakedAdd = fake_add; //jmp跳转的目的地址计算:HOOK函数地址 - 原始函数地址 - 5 *((DWORD*)(&shellCode[1])) = (DWORD)fake_add - (DWORD)add - 5; DWORD dwWritten = 0; //写入该段内存 if(WriteProcessMemory(currentProcessHandle , originAdd , shellCode , 5 , &dwWritten) && dwWritten == 5){ MessageBoxA(NULL , "Write Hook Success !" , "Hook add" , 0); } //进程句柄使用完毕后务必关闭。 CloseHandle(currentProcessHandle);
在执行过程中,当Hook写入成功时,会弹出一个消息框,提示Write Hook Success.如下图
然后我们输出下新的结果呢?
x = add(1, 2); printf("result of 1+2 is %d\n", x);
看到了吗?现在add函数返回的结果成了4,证明fake_add函数挂钩成功。
所以经过上述简单的讲解以后,我们可以知道进行inline hook的基本操作是:
- 获取到原函数和挂钩函数的地址
- 修改原函数所在内存区段的保护
- 计算并写入跳转代码
- 修改回内存区段保护
- enjoy and check !
对于获取原函数地址,可以通过函数名(例如 LPVOID originFunction = MessageBoxA),或者如果该函数是存在在一个DLL并且已声明公开,则可以通过GetProcAddress函数来获取函数的地址。
比如要获取kernel32.dll里面的WriteProcessMemory函数:
LPVOID lpvWriteProcessMemory = GetProcAddress(GetModuleHandle("kernel32.dll") , "WriteProcessMemory");
其中GetModuleHandle函数是获取已加载到内存中的,DLL的基地址句柄。如果该DLL未被加载到内存中,你可以使用LoadLibrary函数将其加载,并且函数会返回DLL的基地址句柄。
至此,程序内的inline hook简单讲解完毕。
附:文章中的测试源代码(VS2013编译通过并正常运行):
#include "stdio.h" #include "windows.h" #include "ras.h" #pragma warning(disable:4996) int fake_add(int a, int b){ return 1 + a + b; } int add(int a, int b){ return a + b; } int main(int argc, char* argv[]){ int x = add(1, 2); printf("result of 1+2 is %d\n" , x); printf("Now HOOKING add using fake_add\n"); DWORD dwOldProtect, dwTempProtect; //获取HOOK函数和原始函数地址 LPVOID originAdd = add, fakeAdd = fake_add; printf("\tModifying Memory Protection to Read/Write\n"); if (VirtualProtect(originAdd, 5, 64, &dwOldProtect)){ //获取当前的进程句柄。类似于一种“对象” HANDLE currentProcessHandle = GetCurrentProcess(); //构造字节码 BYTE shellCode[5] = { 0 }; shellCode[0] = 0xe9;//jmp指令 //jmp跳转的目的地址计算:HOOK函数地址 - 原始函数地址 - 5 *((DWORD*)(&shellCode[1])) = (DWORD)fake_add - (DWORD)add - 5; DWORD dwWritten = 0; printf("\tWriting Shell Code to add funcion\n"); //写入该段内存 if (WriteProcessMemory(currentProcessHandle, originAdd, shellCode, 5, &dwWritten) && dwWritten == 5){ MessageBoxA(NULL, "Write Hook Success !", "Hook add", 0); } //进程句柄使用完毕后务必关闭。 CloseHandle(currentProcessHandle); } printf("\tModifying Memory Protection Back to original\n"); VirtualProtect(originAdd, 5, dwOldProtect, &dwTempProtect); printf("Now Testing our fake functions\n"); x = add(1, 2); printf("result of 1+2 is %d\n", x); system("pause"); return 0; }
By CrazyChen @ 2016/2/15
jmp指令占5字节,执行该指令后,IP + 5,然后在加上jmp指令的位移量
所以 JMP的地址(fakeAdd) – 代码地址(originAdd) – 5(字节) = 机器码跳转地址(E9 位移量)