二.API函数头部HOOK法
本方法通过改变API函数的头部字节以达到HOOK的目的。
1. 技术与实现
每个程序中需要使用到的API函数的地址都放在IAT上,通过这个地址,每个CALL能正确的到达函数的头部。其流程如下图所示:
我们的截获工作就是在这个头部的位置——我们用一个JMP指令替换掉在这儿的一条或几条指令,让它指向各自的HOOKAPIPROXY代码,而在HOOKAPIPROXY中存放一些信息到堆栈后再JMP到proxyfun()函数——一个综合处理函数名称、参数和结果的代码段,最后在proxyfun()中返回到函数调用点。其流程变成下图:
API头部HOOK法的重点还是两个结构/代码段和替换指令,下面进行详细分析:
1) 替换原来的指令
我门需要使用一个JMP指令替换掉头部的一条或几条指令。一个JMP指令共占5个字节,那么到底要替换掉原来的几个指令呢?很显然,INTEL是没有提供专门的指令来判断一条指令占用几个字节,不过有个牛人Z0MBiE,他提供了一个函数,这个函数以指令地址为参数,返回这条指令占用多少字节。
知道了需要占用替换几个字节,我们将原来的指令拷贝至HOOKAPIPROXY结构中。然后加上JMP指令(5字节),空隙部分用0x90填充。
2) HOOKAPIPROXY结构
在本方法中,这个结构被定义成:
typedef struct { byte PushCode; //0xff ULONG NameAddr; byte CallCode; //0XEA ULONG CallAddr; byte OldBytes[20]; byte JmpCode; //0xe9 ULONG JmpAddr; int nCopyBytes; }*PHOOKAPIPROXY, HOOKAPIPROXY; |
它是由四部分组成,
a) push 函数名,保存函数名地址到堆栈中
b) call proxyfun()函数。这里用到了病毒程序的一个技巧,通过call指令将下一个指令的地址(即OldBytes[20])保存到堆栈中。
c) 替换前原API函数的前面头部的部分字节,字节数就是nCopyBytes。
d) Jmp 到原API函数的头部下一条指令。
3) proxyfun()代码
下面是proxyfun()的代码:
_declspec(naked) void ProxyFun() { DWORD ByteWrite; PCHAR pFunctionName; char Str[200]; ULONG Param[PARAMNO]; char StrParam[200], StrTemp[20]; ULONG nParam, i; ULONG Result;
_asm{ push ebp mov ebp, esp sub esp, __LOCAL_SIZE push esi push edi push ebx
//保存函数名称。 mov eax, [ebp + 8] mov pFunctionName, eax
//下面拷贝参数PARAMNO * 4 mov ecx, PARAMNO mov esi, ebp add esi, 12 + PARAMNO * 4//4:Buffer地址 + 4:Name地址 + 4:CALLER地址。 PARAMNO参数
nextmove: mov eax, [esi] push eax sub esi, 4 dec ecx jnz nextmove
mov nParam, esp //调用原来的函数。 call dword ptr [ebp + 4] mov Result, eax //保存可能的返回值
mov ecx, esp //先复原ESP mov esp, nParam add esp, PARAMNO * 4 //判断到底有几个参数。 sub ecx, nParam shr ecx, 2 mov nParam, ecx jcxz noparam //若没有参数
//下面拷贝参数 lea esi, Param mov edi, ebp add edi, 16
nextparam: mov eax, [edi] mov [esi], eax add esi, 4 add edi, 4 loop nextparam noparam: //若没有参数,便直接出来。 }
//往文件中写数据。 …………………
//准备返回 _asm{ mov eax, Result mov ecx, nParam shl ecx, 2
pop ebx pop edi pop esi mov esp, ebp pop ebp
add esp, 8 //4:原来函数地址 + 4:FUNCTIONNAME PARAMNO参数 pop edx add esp, ecx push edx ret } } |
这段代码和IAT法的代码有较多的相似之处,也是围绕着堆栈来开展工作的,它的堆栈图如下:
从这张图,我们可以非常直观的看出有两点不同:
a) 保存函数名称的堆栈位置变化了。
b) 原来存放函数原来地址变成了 “原来函数的头部”这段内存的地址了,所以直接调用这个地址(CALL [EBP + 4])就可以调用原来的函数功能了。
2. 信息保存
由于和IAT法的截获方法不同,所以在Proxyfun()中信息保存的方法也是不同的。在本方法中,我们为每个需要用到的函数定义了一个有25个字节长的数组:前20个字节保存着从原API函数拷贝的头部;后5字节是个JMP指令,直接转移到API函数的后面字节。
通过这样的处理,虽然初始化时和IAT法不同,但是在Proxyfun()中却是一样的调用原函数的J
3. 优缺点
这个函数的优点很明显,它可以截获所有函数的调用。对于在IAT法中出现的保存IAT地址从而不能截获的情况能轻易解决。
但是这个问题有个很大可以说是致命的缺点:就是如何控制好函数的替换。Z0MBiE提供的代码虽然可以得出指令的长度,但是假如被替换掉的指令本身就包含指令跳转(如JMP/CALL/RET等)该如何重新计算它们的相对偏移?假如API函数的后面代码中有跳转到头部的指令(如JMP到头部的JMP指令中间的位置,因为这个位置本来可能就是一个指令的开始),该如何控制?这些问题,尤其是后一个问题,可以说是直接宣布了这个方法的死亡。
为了挽救这个方法,还有一个替代方法,就是《delphi下深入WINDOWS核心编程》中使用的——仅仅用JMP指令的5个字节覆盖API函数头部,在proxyfun()中在调用原函数前,先用原来的5个字节覆盖,等调用完,再用JMP指令替换。其实这是换汤不换药,而且带来更大的隐患——线程同步调用同一个API函数会发生错误,同时会有API“漏网”,最重要的是不能解决上文提出的关于API函数内存的JMP头部冲突。所以在《windows核心编程》中,jeffrey Richter直接否定了这个方法。
总之,对于你熟悉的API函数,没有上述的“JMP等跳转”情况的,使用本方法是简单又高效的,但是若想将本方法作为一个通用的API HOOK方法,估计是不太适合的。