什么是HOOK API:
Windows下暴露的对开发人员的接口叫做应用程序编程接口,就是我们常说的API。我们在写应用层应用程序软件的时候都是通过调用各种API来实现的。有些时候,我们需要监控其他程序调用的API,也就是,当其他应用程序调用我们感兴趣的API的时候,我们在他调用前有一个机会做自己的处理,这就是HOOK API的涵义。
思路:
我们知道Windows系统API函数都是被封装到DLL中,在某个应用程序要调用一个API函数的时候,如果这个函数所在的DLL没有被加载到本进程中则加载它,然后保存当前环境(各个寄存器和函数调用完后的返回地址等)。接着程序会跳转到这个API函数的入口地址去执行此处的指令。由此看来,我们想在调用真正的API之前先调用我们的函数,那么可以修改这个API函数的入口处的代码,使他先跳转到我们的函数地址,然后在我们的函数最后再调用原来的API函数。下面以拦截WS2_32.dll中的recv函数为例说明拦截的主要过程。首先把自己编写的DLL挂接到系统当前运行的所有进程中(要排除一些Windows系统自身的进程,否则会出现问题,影响系统正常工作),挂接的意思是,要我们的DLL运行在目标进程的地址空间中。可以使用列举系统进程然后用远程线程注入的方法,但是这种方法只适用于Win2000以上的操作系统。
当我们的DLL被所有目标进程加载后,我们就可以进行真正的工作了。首先使用Tool Help库的相关函数列举目标进程加载的所有模块,看看是否有ws2_32.dll,如果没有,说明这个进程没有使用Winsock提供的函数,那么我们就不用再给这个进程添乱了。如果找到ws2_32.dll模块,那么OK,我们可以开工了。先是用GetProcAddress函数获得进程中ws2_32.dll模块的recv函数的入口地址,也就是函数的起始地址。刚才说过,我们想把recv函数起始位置加入一条跳转指令,让它先跳转到我们的函数中运行。跳转指令可以用0xE9来表示(0xE9是汇编语言中CALL指令的机器码),后面还有4个字节的我们函数的相对地址。也就是我们要修改recv函数前5个字节。这5个字节由1个字节的跳转指令和4个字节的地址组成。这样当程序运行到这里的时候,将会跳转到这4个字节表示的地址处去运行代码。还要注意的是这4个字节的地址是偏移地址,而偏移地址 = 我们函数的地址 – 原API函数的地址 – 5(我们这条指令的长度)。好了,别忘了我们要先读取稍后要被覆盖的recv函数入口处的5个字节的内容,把它保存起来留着以后恢复时使用。因为在我们的函数中要想调用真正的recv的时候,必须把它前5个字节恢复了,他才能正常工作呢。
通过上面的说明,我们可以整理出这样的一个流程:
1. 保存recv的前5个字节的内容
2. 把recv的前5个字节的内容改变成CALL xxxx(xxxx是我们的函数的偏移地址)
3. 在我们的函数中恢复recv的前5个字节的内容,并作处理。
4. 我们的函数返回后,再把recv的前5个字节的内容改变成CALL xxxx
慢着,你一定发现问题了吧?当我们为了调用原来的recv函数而刚刚把recv入口处的5个字节恢复,这时系统中的其他线程调用了recv函数,而这个调用将会成为漏网之鱼而不会进入到我们的函数中来。简单的解决办法是使用临界对象CriticalSection来保证同时只能有一个线程对recv函数入口处5个字节进行读写操作。
最后记得在你想要停止拦截的时候恢复所有你修改过的进程和这些进程中被修改的API的前5个字节。其实原理讲着容易,在实现的时候会遇到各种各样的问题,如98下这些系统的DLL被加载到系统内存区供应用程序共享,所以这些内存是受保护的,不能随意修改,还有nt/2000下权限问题,还要考虑到不要拦截某些系统进程,否则会带来灾难性的后果。这些都是在实践当中遇到的实际问题。
下面结合代码给大家讲解一下吧,首先我们要实现HOOK模块,我们给它起个名字叫做MainHookDll.DLL。在此模块中,主要要实现一个CHookApi的类,这个类完成主要的拦截功能,也是整个项目的技术核心和难点,后面将具体介绍它。而且,MainHookDll模块就是将来要注入到系统其它进程的模块,而远程调用函数是非常困难的事情,所以我们设计此模块的时候应让其被加载后自动执行拦截的初始化等工作。这样,我们只需要让远程的进程加载HOOK,然后MainHookDll.dll就能够自动执行其它操作从而HOOK该进程的相关API。
MainHookDll模块中的CHookApi类拥有2个向外部提供的主要的方法,HookAllAPI,表示拦截指定进程中的指定API和UnhookAllAPI,表示取消拦截指定进程中的指定API。进行具体设计的时候,会遇到一个问题。大家看到,上文所说的开始将原始API的前5个字节写成CALL XXXX,而在我们的替换函数中要恢复保存的API原始的5个字节,在调用完成后又要把API前5个字节改为CALL XXXX。如果我们拦截多个API要在每个替换函数中按照如上的方法进行设置,这样虽然我们自己明白,但是可能您只是实现HOOKAPI部分,而别人实现调用,这样会使代码看起来很难维护,在别人写的替换函数中加上这些莫名奇妙的语句看来不是一个好主意,当需要拦截多个感兴趣的API函数,那样的话将会在每一个要拦截的函数里都有这些莫名其妙的代码将会是件很恶心得事情。而且对于CALL XXXX中的地址,要对于不同的API设置不同的替换函数地址。那么能不能把这些所有的函数归纳为一个函数,所有的API函数前5字节都改为CALL到这个函数的地址,这个函数先恢复API的前5字节,然后调用用户真正的替换函数,然后再设置API函数的前5字节,这样可以使真正的替换函数只做自己应该做的事情,而跟HOOK API相关的操作都由我们的通用函数来干。
这样的想法是好的,但是有一个突出问题,因为替换函数的函数声明与原API一致,所以对于要拦截的不同的API,它们的的参数和返回值是不一样的。那我们怎样通过一个函数获得用户传递给API的参数,然后使用这些参数调用替换函数,最后把替换函数的返回值再返回给调用API的客户?要想实现这个功能,我们需要了解一个知识,也就是C++究竟是怎样调用一个函数的。我们以ws2_32.dll中提供的recv函数为例进行说明,recv函数的声明如下:
int recv(
SOCKET s,
char* buf,
int len,
int flags
);
可以看出它具有4个参数,返回值类型是int。我们作如下调用:
recv(s,buf,buflen,0);
那么在调用recv前,这四个参数将按照从右向左的顺序压到栈中,然后用Call指令跳转到recv函数的地址继续执行。recv可以从栈中取出参数并执行其他功能,最后返回时返回值将被保存在寄存器EAX中。最后还要说明一点的是,在汇编语言看来这些参数和返回值都是以DWORD类型表示的,所以如果是大于4字节的值,就用这4个字节表示值所在的地址。
有了这些知识我们就可以想到,如果用户调用recv函数并被拦截跳转到我们的函数中运行,但是我们并不知道有多少个参数和返回值,那么我们可以从栈中取出参数,但是参数的个数需要提供,当然我们可以在前面为每个API函数指定相应的参数个数,然后运行真正的替换函数,最后在返回前把替换函数的返回值放到寄存器EAX中,这样就解决了不知道参数和返回值个数的问题。那么我们的函数应该是看起来无参数无返回值的。
基本原理我们大家都清楚了,但是继续之前我还是想讲一讲几个汇编的知识,如果没有这些知识那么看下面的代码就好像天书一样。
关于参数
我们讲过,在调用一个子函数前要把参数按顺序压栈,而子函数会从栈中取出参数。对于栈操作,我们一般使用EBP和ESP寄存器,而ESP是堆栈指针寄存器,所以多数情况下使用EBP寄存器对堆栈进行暂时操作。还是用调用recv函数为例,假设调用前ESP指向0×00000100处(程序运行时ESP是不可能为这个值的,此处只是为了举例说明问题)。先将参数一次压栈
push 0 // flags入栈
lea eax, [len]
push eax // len入栈
lea eax, [buf]
push eax // buf入栈
lea eax, [s]
push eax // s入栈
下面使用call调用真正的recv函数,
call dword ptr [recv] // 调用recv
call指令先将返回地址压入栈中,返回地址就是CALL指令的下一条指令的地址,然后跳转到recv入口地址处继续执行。进入recv后,recv使用EBP临时访问堆栈之前,要保存EBP的当前内容,以便以后再使用(在 关于调用函数时保存各个寄存器的值 部分将详细讨论)。所以位于recv函数开始可能是这样的
push ebp // 保存ebp的当前值
mov ebp,esp // 使把esp负给ebp
堆栈指针 [ESP] | 堆栈的内容 | 堆栈内容的含义 |
0×00000100 | flags | 参数 |
0×000000fc | len | 参数 |
0x000000f8 | buf | 参数 |
0x000000f4 | s | 参数 |
0x000000f0 | RetAddress | 返回的地址 |
0×00000ec | OldEBP | 保存EBP的当前值 |
到此,我们可以知道,如果现在要想通过EBP获得最后一个入栈的参数,那么需要用EBP+8来获得,因为最后一个入栈的参数被保存在返回地址和EBP原始值的上面(一定记住,栈是由高地址到低地址的)。而返回地址被放在EBP+4处,EBP的原始值放在EBP+0处。
关于调用函数时保存各个寄存器的值
当我们要调用其它函数的时候,程序应该先保存各个寄存器的值,然后转去调用其它函数,最后会恢复各个寄存器的值使它们恢复成调用其它函数之前的状态。当然我们使用高级语言写程序的时候,编译器为我们做了这些事情。使用vc调试程序,打开反汇编窗口。运行一个简单的程序,该程序调用一个我们自己写的简单函数,在这个简单函数中设置断点,可以看到,编译器生成的汇编代码使用堆栈保存各个寄存器的值,上面提到当执行一个函数的时候,首先保存的是EBP的值,然后依次压入栈中保存的寄存器为EBX、ESI、EDI,我们在恢复这些寄存器的值的时候将逆向出栈来完成。
关于函数调用的返回
调用子函数前ESP指针会因为压栈参数而改变,然后压入返回地址等,子函数中会使用ret指令从栈中取出返回地址并跳转到返回地址,而在子函数返回到CALL的下一条指令时栈中还保存着参数,所以我们需要手工的将栈中的参数所占用的空间释放,如在调用完成一个4个参数的子函数后,我们应该将ESP指针上移4*4个字节,如
add esp,16
这个操作在调用API的时候是不需要的,因为,windows API在函数中自己将参数弹出堆栈了。所以这就有一个调用约定的问题,默认情况下调用约定是__cdecl,表示参数从右向左入栈,由调用者清理参数。而windows API使用的是__stdcall调用,表示参数从右向左入栈,由函数自己清理参数。我们的程序API的替换函数使用__stdcall调用约定。所以不用考虑清理栈中的参数的问题,由替换函数自己处理。
有了上面的知识,让我们回顾一下先前讨论的问题,首先,用户调用API的recv函数,程序运行到recv的入口地址处,此时堆栈中拥有用户调用recv的参数和用户代码中CALL [recv]的下一条指令的地址。堆栈如下图:
堆栈指针 [ESP] | 堆栈的内容 | 堆栈内容的含义 |
0×00000100 | 0 | 参数 |
0×000000fc | len | 参数 |
0x000000f8 | buf | 参数 |
0x000000f4 | S | 参数 |
0x000000f0 | RetUserAddress | 用户调用recv的下一条指令的地址 |
然后程序指针EIP被修改为recv入口处的地址,而入口地址处有一条简单的CALL指令,它使程序将recv的第6个字节的地址压入栈中(因为CALL XXXX占用5个字节,第六个字节被认为为返回地址),然后跳转到我们的无参数无返回值的通用替换函数中去了,好了看看现在堆栈中都有些什么?如图:
堆栈指针 [ESP] | 堆栈的内容 | 堆栈内容的含义 |
0×00000100 | 0 | 参数 |
0×000000fc | len | 参数 |
0x000000f8 | buf | 参数 |
0x000000f4 | s | 参数 |
0x000000f0 | RetUserAddress | 用户调用recv的下一条指令的地址 |
0×00000ec | RetrecvAddress | recv的第六个字节的地址 |
首先是参数,其次是用户调用recv后的返回值,然后是recv调用我们的替换函数中的返回值,紧接着就像刚才提到的那样,程序将EBP当前内容压入栈中。如图
堆栈指针[ESP] | 堆栈的内容 | 堆栈内容的含义 |
0×00000100 | 0 | 参数 |
0×000000fc | len | 参数 |
0x000000f8 | buf | 参数 |
0x000000f4 | S | 参数 |
0x000000f0 | RetUserAddress | 用户调用recv的下一条指令的地址 |
0×00000ec | RetrecvAddress | recv的第六个字节的地址 |
0×000000e8 | OldEBP | 保存的旧的EBP的内容,然后[EBP]= 0×000000e8 |
0×000000e4 | OldEBX | 保存的旧的EBX的内容 |
0×000000e0 | OldESI | 保存的旧的ESI的内容 |
0×000000dc | OldEDI | 保存的旧的EDI的内容,此时[ESP]=0×000000dc |
此时我们可以看到,[EBP]为保存的ebp的值,现在对我们没有用处,函数返回前用于恢复EBP的值,[EBP+4]是recv函数的CALL XXXX后面指令的地址(也就是第六个字节的地址),我们可以通过将此值减去5来得到recv的入口地址,这样在我们所有hook的api函数的列表中进行检索,就可以匹配出用户调用的是哪一个API函数,从而为后面恢复和再次改变该API的入口5字节做准备,因为调用任何我们需要HOOK的API程序都会进入到这个无返回值无参数的函数,所以通过这种方法找到当前HOOK的是哪一个API,从而可以区分不同的API进行特殊的处理。[EBP+8]保存的是用户调用recv后的返回地址,由于我们执行完替换函数后,应该返回到这个地址,而不应该返回到recv的第6个字节处执行,所以我们还是需要保存下这个值,以便在我们用ret返回前把它压栈从而使程序返回到用户调用recv的下一条指令处继续运行。
我们现在实现它,请注意参考上面堆栈表格。
void CHookApi::CommonFunc(void)
{
DWORD* pdwCall;// 保存被调用前压在栈中的返回地址,也就是Call XXXX 的地址
DWORD* pdwESP;// 保存ESP内容
DWORD* pdwParam; // 第一个参数的地址
DWORD dwParamSize; // 所有参数所占用的大小应该=4* dwParamCount
DWORD dwRt; // 保存返回值
DWORD dwRtAddr; // 我们的函数真正要返回的地址
PROCESS_INFORMATION *pPi;// 进程信息
// 得到栈中第一个参数的位置
_asm
{
mov EAX,[EBP+8]
mov [dwRtAddr],EAX
lea EAX,[EBP+4] // call XXXX所在的地址
mov [pdwCall],EAX
lea EAX, [EBP+12] // 第一个参数所在地址
mov [pdwParam],EAX
}
(*pdwCall) -= 5;
vector<APIINFO*>::iterator it;
APIINFO* pai = NULL;
for(it = m_vpApiInfo.begin(); it != m_vpApiInfo.end(); it++)
{
APIINFO* papiinfo = *it;
if((DWORD)papiinfo->lfOrgApiAddr == *pdwCall)
{
pai = *it;
break;
}
}
if(pai == NULL)
return;
BYTE* pbtapi = (BYTE*)pai->lfOrgApiAddr;
dwParamSize = 4*pai->ParamCount;
EnterCriticalSection(&pai->cs);
// 恢复被修改的5个字节
memcpy(pbtapi,pai->OrgApiBytes,5);
pai->bIsHooked = FALSE;
// 构造参数
_asm
{
sub esp,[dwParamSize]
mov [pdwESP],esp
}
memcpy(pdwESP, pdwParam, dwParamSize);
COMMONFUNC myapifunc = (COMMONFUNC)pai->lfMyApiAddr;
_asm
{
call myapifunc // 调用替换API的函数
mov [dwRt],eax // 保存返回值
}
// 如果是CreateProcess,那么继续hook它
pPi = (PROCESS_INFORMATION*)pdwParam[9];
if(strcmpi(pai->szOrgApiName,"CreateProcessA") != 0 || strcmpi(pai->szOrgApiName,"CreateProcessW") != 0)
{
InjectDll(pPi->dwProcessId,m_szDllPathName);
}
// 再修改5字节
pbtapi[0] = CALLCODE;//jmp
DWORD* pdwapi = (DWORD*)&(pbtapi[1]);
pdwapi[0] = (DWORD)CommonFunc – (DWORD)pbtapi – 5;// 我的api的地址偏移
pai->bIsHooked = TRUE;
LeaveCriticalSection(&pai->cs);
// 下面准备返回的操作
_asm
{
mov EDX,[dwRtAddr] // 保存返回地址
mov EAX,dwRt // 设置返回值
mov ECX,[dwParamSize] // 获得参数的大小
// 下面弹出所有保存的寄存器值(按照入栈的逆顺序)
pop EDI // 恢复EDI
pop ESI // 恢复ESI
pop EBX // 恢复EBX
// 我们没有改动过EBP的值,所以EBP指向堆栈中OldEBP的位置
mov ESP,EBP
pop EBP // 恢复EBP
// 由于堆栈中还剩下参数和两个返回地址(我们真正要返回的地址和原始API中的第6个字节的地址),所以我们把这些数据也清除出堆栈
add ESP,8 // 清除两个返回地址
add ESP,ECX // 清除参数
// 由于调用ret返回时,程序先从堆栈中取出返回地址,所以我们把要真正返回的地址压入堆栈中
push EDX
ret // 返回
}
}
要说明的一点是,如果要执行得API函数是CreateProcess,那么应该把它新开启得进程也HOOK掉。以上我们了解了通用替换函数的原理,那么让我们深入的讨论CHookApi类,并且实现它。
前面提到过,我们的CHookApi类主要向外部提供2个方法,HookAllAPI方法和UnhookAllAPI方法。当调用HookAllAPI的时候,将拦截系统所有用户程序中我们感兴趣的API函数。当调用UnhookAllAPI的时候,将撤销拦截。当拦截启动之前,我们应该将所有我们感兴趣的,即需要拦截的API信息(如API名称,对应的替换函数名称、参数个数等)交给CHookApi,CHookApi类内部才能完成所有的拦截工作。以下是CHookApi类的声明
(HookApi.h)
#include "../include/CommonHeader.h"
#include <Psapi.h>
#include <tlhelp32.h>
#pragma comment(lib,"Psapi.lib")
// 通用替换函数指针声明
typedef void(*COMMONFUNC)(void);
/*
CHookApi类实现了Hook的核心功能
*/
class CHookApi
{
public:
// 构造函数
CHookApi(void);
// 析构函数
~CHookApi(void);
private:
// 自身路径
static char m_szDllPathName[MAX_PATH];
// 此容器包含所有要Hook的API信息(在类中使用的有效信息)
static vector<APIINFO*> m_vpApiInfo;
// 此容器包含所有要Hook的API信息(用户填写的信息)
vector<HOOKAPIINFO*>* m_vpHookApiInfo;
// 防火墙策略模块句柄
HMODULE m_hFireWall;
// 加载防火墙策略模块
BOOL LoadFireWallModule(void);
// 卸载防火墙策略模块
BOOL UnloadFireWallModule(void);
public:
// 使用远程线程注入的方法将我们的DLL注入到指定进程
static int WINAPI InjectDll(DWORD dwProcessId, LPTSTR lpDllName);
// 使用远程线程注入的方法将我们的DLL卸载
static int WINAPI EjectLib(DWORD dwProcessId, LPTSTR lpDllName);
protected:
// 通用替换函数,这是技术核心部分
static void CommonFunc(void);
private:
// 初始化函数
BOOL Init(void);
// 挂钩一个指定API的函数
BOOL HookOneApi(APIINFO* pai);
// 挂钩所有指定API的函数
BOOL HookAllApis(void);
// 取消挂钩一个指定API的函数
BOOL UnhookOneApi(APIINFO* pai);
// 取消挂钩所有指定API的函数
BOOL UnhookAllApis(void);
// 设置进程内存区域的存取权限
BOOL SetMemmoryAccess(APIINFO* pai,BOOL CanWritten);
// 修改/恢复指定API的前5个字节
BOOL SetCallMemmory(APIINFO* pai,BOOL bHook);
};
其中HOOKAPIINFO结构填写基本的拦截信息,在CHookApi内部将会把它转化为APIINFO结构。HOOKAPIINFO和APIINFO结构定义在CommonHeader.h文件中,CommonHeader.h文件如下:
(CommonHeader.h)
#define FIREWALLDLLNAME "ReplacelDll.Dll" // 实现被拦截的API替换函数的名字
#define MAINHOOKDLLNAME "MainHookDll.Dll" // 拦截API主模块的名字
#define GETHOOKINFOFUNCNAMEINREPLACEDLL "GetHookApiInfo" // 替换模块导出函数,它返回要拦截的信息
#define FREEHOOKINFOFUNCNAMEINREPLACEDLL "FreeHookApiInfo" // 替换模块导出函数,它释放要拦截的信息
#define CALLCODE 0xE8
#ifndef COMMONHEADER_H
#define COMMONHEADER_H
#include <vector>
using namespace std;
typedef void(*CMAPIFUNC)(void);
// 关于API函数信息的结构
typedef struct _APIINFO
{
// 要拦截的API函数名
char szOrgApiName[100];
// 要拦截的API的地址
CMAPIFUNC lfOrgApiAddr;
// 我们要替换原来API的地址
CMAPIFUNC lfMyApiAddr;
// 要保存的原来API入口点的前5个字节
BYTE OrgApiBytes[5];
// 对进程内存的保护状态,调用VirtualProtect来改变对内存访问权限时得到的。
DWORD dwOldProtectFlag;
// 参数的个数
int ParamCount;
// 临界对象,为了互斥用,避免同时修改原始api的前5个字节
CRITICAL_SECTION cs;
// 是否已经HOOK了
BOOL bIsHooked;
// 用户需要使用的结构,通过这个结构来了解用户需要拦截的API的信息
typedef struct _HOOKAPIINFO
{
union
{
struct
{
// 我们要替换的API所在的DLL的名字
char szMyModuleName[100];
// 我们要替换原来API的函数名字
char szMyApiName[50];
} MyApi;
CMAPIFUNC lfMyApi;
};
// 要拦截的API所在DLL的名字
char szOrgModuleName[100];
// 要拦截的API的名字
char szOrgApiName[50];
// 参数的个数
int ParamCount;
// 提供MyAPI的方式
BOOL bMyApiType; // 如果是0代表提供地址,1代表使用模块名和函数名指定
} HOOKAPIINFO;
// 获得要拦截的API的信息函数指针
typedef vector<HOOKAPIINFO*>*(*GETHOOKAPIINFO)(void);
// 释放要拦截的API的信息函数指针
typedef void(*FREEHOOKAPIINFO)(void);
// 远程注入函数指针
typedef int (*INJECTDLL)(DWORD dwProcessId, LPTSTR lpDllName);
#endif
在这个头文件中,除了定义了HOOKAPIINFO和APIINFO结构还有一些其它定义,FIREWALLDLLNAME宏指定防火墙策略模块的文件名称,MAINHOOKDLLNAME宏指定本模块的文件名称,GETHOOKINFOFUNCNAMEINREPLACEDLL宏指定我们写的API替换函数所在的DLL(我们成为替换模块)中用来返回用户的HOOKAPIINFO结构容器指针的函数名称,程序将会自动加载替换模块并调用这个函数获得用户的HOOKAPIINFO结构容器的指针,根据后面的函数指针的定义不难看出,这个函数必须是一个返回值为vector<HOOKAPIINFO*>*,并且没有参数的导出函数。FREEHOOKINFOFUNCNAMEINREPLACEDLL 是释放返回的vector<HOOKAPIINFO*>*指针的导出函数名字。CALLCODE宏指定要替换的跳转指令,这里只用CALL指令,他的机器码是E8。
在构造函数中调用Init函数和HookAllApis函数,这样使该类被构造的时候就能够自动进行初始化和hook工作。Init函数的主要工作是将用户提供的HOOKAPIINFO结构转化成APIINFO结构。HookAllApis函数内部循环调用HookOneApi函数进行真正的Hook操作。详见源代码中的注释。
CHookApi类完成了Hook的核心工作后,我们需要让系统所有的进程加载我们的MainHookDll.Dll,从而对系统所有进程中的指定API进行拦截。我们的想法是,MainHookDll模块提供2个导出函数,HookAllProcesses和UnhookAllProcesses函数完成这个功能,当UserUI调用HookAllProcesses函数的时候,系统所有的进程将加载MainHookDll.Dll,在加载的同时让CHookApi类开始工作,对目标进程中的所有指定API的前5个字节进行替换。当调用UnhookAllProcesses函数的时候,系统所有的进程将卸载MainHookDll.Dll,从而取消对指定API的拦截。在HookAllProcesses和UnhookAllProcesses函数中实际上是列举当前系统的所有进程,并对所有进程进行相同的操作。所以我们可以再实现两个函数,用CHookApi类的静态成员InjectDll和EjectLib函数完成真正的功能,而HookAllProcesses和UnhookAllProcesses中实现列举系统进程并对所有进程调用InjectDll或EjectLib函数。我们使用远程线程来实现InjectDll和EjectLib函数。远程线程是指在当前进程中使目标进程启动一个线程。可以通过以下方法完成远程线程的调用。首先使用OpenProcess打开目标进程得到进程句柄,要启动远程线程最少需要用PROCESS_CREATE_THREAD,PROCESS_QUERY_INFORMATION,PROCESS_VM_OPERATION,PROCESS_VM_WRITE, and PROCESS_VM_READ权限打开目标进程(我们使用PROCESS_ALL_ACCESS)。然后调用VirtualAllocEx函数在目标进程中分配内存,使用WriteProcessMemory函数将当前进程中的线程函数的参数写入在目标进程分配的内存中,最后调用CreateRemoteThread函数使目标进程执行线程函数。(实现过程请参考源代码中的注释)。下面给出InjectDll函数的代码,该函数将指定的Dll文件注入到指定ID号的进程中。
int WINAPI CHookApi::InjectDll(DWORD dwProcessId, LPTSTR lpDllName)
{
PTHREAD_START_ROUTINE pfnRemote =(PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("Kernel32"), "LoadLibraryA");
if(pfnRemote ==NULL)
return -1;
HANDLE hProcess =OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
if(hProcess ==NULL)
{
return -1;
}
int iMemSize = (int)strlen(lpDllName)+1;
void *pRemoteMem =VirtualAllocEx(hProcess, NULL, iMemSize, MEM_COMMIT, PAGE_READWRITE);
if(pRemoteMem ==NULL)
{
CloseHandle(hProcess);
return -1;
}
if (!WriteProcessMemory(hProcess, pRemoteMem, lpDllName, iMemSize,NULL))
{
VirtualFreeEx(hProcess, pRemoteMem, 0, MEM_RELEASE);
CloseHandle(hProcess);
return -1;
}
HANDLE hThread =CreateRemoteThread(hProcess, NULL, 0, pfnRemote, pRemoteMem, 0, NULL);
if(hThread ==NULL)
{
VirtualFreeEx(hProcess, pRemoteMem, 0, MEM_RELEASE);
CloseHandle(hProcess);
return -1;
}
WaitForSingleObject(hThread, INFINITE);
VirtualFreeEx(hProcess, pRemoteMem, 0, MEM_RELEASE);
CloseHandle(hProcess);
CloseHandle(hThread);
return 0;
}
函数一开始,指定了要被注入的线程函数的位置,在这里,我们的线程函数就是LoadLibraryA函数,远程线程中的线程函数应该是PTHREAD_START_ROUTINE类型的函数指针,该函数指针的声明如下:
typedef DWORD (*PTHREAD_START_ROUTINE)(LPVOID)
由声明可知,该函数是一个只有一个参数的,且返回值是DWORD类型的函数,而LoadLibraryA函数符合要求的,所以它可以直接作为远程线程的线程函数。当远程进程调用LoadLibraryA把我们的MainHookDll.Dll加载后,拦截工作就会自动进行。紧接着,用VirtualAllocEx函数在远程进程中开辟一块内存,开辟的内存的大小是MainHookDll的全路径名(注意最后的’\0’)。接着使用WriteProcessMemory函数将参数(即MainHookDll的全路径名)写入到刚刚开辟的远程进程中的地址空间。最后调用CreateRemoteThread函数启动远程线程。要注意的是,当线程结束后,要用VirtualFreeEx函数释放在远程进程内开辟的内存。
下面的代码列出了EjectLib函数,它是使系统当前进程卸载我们的MainHookDll文件:
int WINAPI CHookApi::EjectLib(DWORD dwProcessId, LPTSTR lpDllName)
{
// open the process
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS,FALSE,dwProcessId);
if(hProcess == NULL)
return -1;
// 枚举进程中的模块
HANDLE hModuleSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwProcessId);
if(hModuleSnap == INVALID_HANDLE_VALUE)
{
CloseHandle(hProcess);
return -1;
}
MODULEENTRY32 me32;
me32.dwSize = sizeof(MODULEENTRY32);
BOOL bFound = FALSE;
HMODULE hmod = NULL;
if(Module32First(hModuleSnap, &me32))
{
do
{
if(strcmpi(me32.szModule,lpDllName) == 0)
{
hmod = me32.hModule;
bFound = TRUE;
}
}while(!bFound && Module32Next(hModuleSnap, &me32));
}
CloseHandle(hModuleSnap);
if(hmod == NULL)
{
// 没有指定的模块
CloseHandle(hProcess);
return 0;
}
// 创建远程线程
PTHREAD_START_ROUTINE pfnRemote =(PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("Kernel32"),"FreeLibrary");
if(pfnRemote ==NULL)
{
CloseHandle(hProcess);
return -1;
}
HANDLE hThread =CreateRemoteThread(hProcess,NULL,0,pfnRemote,hmod,0,NULL);
if(hThread ==NULL)
{
CloseHandle(hProcess);
return -1;
}
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hProcess);
CloseHandle(hThread);
return 0;
}
我们在函数中使用了Psapi中定义的函数列举指定进程中加载的模块,看看是否加载了我们的MainHookDll文件,如果加载了,那么使用FreeLibrary函数卸载它。
我们写一个要被测试的程序叫test.exe,在里面调用要被拦截的API,比如是MessageBox。我们的主程序是MainApp.exe。HOOK模块是MainHookDll.dll,所有要替换的函数都在Replace.dll里面导出。当MainApp.exe运行起来后,他先加载MainHookDll.dll,并且调用MainHookDll.dll的InjectDll方法把MainHookDll.dll注入到目标进程。MainHookDll.dll被加载后,先主动加载Replace.dll并调用Replace.dll的GETHOOKINFOFUNCNAMEINREPLACEDLL方法获得要HOOK的信息。然后就按照刚才分析的机制对目标进程进行拦截了。由此可见,我们在写程序的时候,拦截某个API的处理函数都在Replace.dll中,其他部分的代码都是固定的。