函数Hook技术(Detour 转向)

今天分享函数Hook中的Detour技术,这项技术可以拦截修改或者完全替换目标函数的任意代码。我们最多接触的是Cheat Engine 当中的注入脚本功能,想把这个功能转换成C++程序的话只能通过detour(转向)来实现。下面这篇文章当中将介绍Hook技术的具体实现detour方法。


一,技术介绍

1.Detour和Hook的区别

在一些技术文章中总能看到两种术语,DetourHook。Hook是个更广泛的概念,一般是指拦截和修改正常执行程序的技术。Hook有多种实现方式,其中一个是detour。

那么Detour指的是通过跳转来转向原始程序的技术。先转向到另一段代码(此代码不一定是自己的代码,可以转向到进程内存的不同位置)然后通常会返回原始位置。

在实际编程开发当中,很多开发者会把这两种术语互换使用。尤其是在Windows开发领域。例如微软的Detour库本质上就是个函数hooking的库,但是名称却使用了”Detours”。

2.中间函数钩子(Mid Function Hooking)

detour技术还有一种称呼叫 Mid Function Hooking 。我们Detour函数的第一个字节的话那这个就不叫中间函数钩子,只有Detour函数的剩下部分才叫中间函数钩子。

在函数的第一个字节放置Detour的话很容易被一些反作弊系统检测到。因为反作弊总不能时时刻刻检测整个函数的完整性,大部分反作弊会检测函数的前几行代码是否正确,所以中间钩子函数不会那么容易被检测。

3.外部钩子(External Hooking)

在一些外部作弊软件当中会调用 Windows API 的 WriteProcessMemory 将代码写入到目标进程,通过相同的方式把原有的代码改成跳转代码(调转到自己的代码)来实现外部Detour(外部钩子)。

实现外部钩子要把汇编代码转换成指定的shellcode再写入目标进程,比起内部钩子要复杂得多。在内部使用钩子时就不需要这么麻烦。

4.被窃取的字节(Stolen Bytes)

我们把detour写入到目标内存时被覆盖的字节称为“被窃取的字节”(Stolen Bytes)。我们需要彻底了解我们的detour到底覆盖了多少个字节,执行detour时要执行这些字节,不然栈和寄存器的值被破环的话可能导致程序崩溃。

这里的字节数时根据实际情况来决定的,比如我们要detour的地址处有一个长度为8字节的指令,我们的detour只占5字节。这种情况下需要把8个字节都作为被窃取的字节(5个字节为跳转指令,3个字节为空指令),在detour当中执行这完整的8字节指令。说白了就是不破坏原程序的指令确保程序不会崩溃。

5.代码洞(Code Caves)

代码洞指的是目标进程中没有被进程使用的内存位置,就是已经被分配了但是进程不使用的内存。

使用detour时可以把代码写到这个内存位置,这样就不用再单独的分配内存了让自己的行为悄无声息。要使用代码洞的前提是内存页必须得有执行权限,可以使用 VirtualProtect 或 VirtualProtectEx 修改权限。但是创建了具有写入权限的进程句柄的话本身就没必要担心反作弊的问题了。

二,Hook函数(Detour)

1.函数定义

首先Hook函数需要一个要被Hook的原始函数地址,还需要一个我们自己定义的函数地址,最后需要覆盖字节的长度:

1
bool Hook(void * toHook, void * ourFunct, int len);

先检查需要覆盖的字节长度是不是至少为5个字节,因为x86架构下完整的跳转指令(jmp)需要5个字节。其中第一个字节的操作码为0xE9,剩下4个字节为相对地址偏移量。如果长度小于5直接让函数返回false:

1
2
3
if (len < 5) {
return false;
}

长度没问题之后就要修改目标内存的保护属性,这个可以通过调用 Windows API 当中的 VirtualProtect 来目标函数的内存保护属性为可读写。同时通过此API的最后一个参数来保存原始保护属性到自定义的变量当中,等修改完之后方便恢复到原来的属性:

1
2
DWORD curProtection;
VirtualProtect(toHook, len, PAGE_EXECUTE_READWRITE, &curProtection);

获取到可读写权限之后使用memset函数把目标地址处的需要覆盖的全部字节填充为0x90(Nop指令),目的是为了清楚原有的指令:

1
memset(toHook, 0x90, len);

计算从原来的函数跳转到我们的函数需要的相对偏移量。x86架构中,相对偏移量是相对于原函数跳转指令之后的地址计算的,所以得减个5(跳转指令本身的长度):

1
DWORD relativeAddress = ((DWORD)ourFunct - (DWORD)toHook) - 5;

有了跳转地址之后可以写入跳转指令了,先在目标地址的第一个字节处写入0xE9(x86汇编中是无条件跳转JMP指令的操作码)。紧接着后面四个字节处写入前面算好的跳转地址:

1
2
*(BYTE*)toHook = 0xE9;
*(DWORD*)((DWORD)toHook + 1) = relativeAddress;

全部操作完整之后可以恢复内存的保护属性为原来的属性。调用 VirtualProtect 把内存页的保护属性恢复过来,最后返回结果true表示Hook成功:

1
2
3
DWORD temp;
VirtualProtect(toHook, len, curProtection, &temp);
return true;

下面是完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool myHook(void * toHook, void * ourFunct, int len) {
if (len < 5) {
return false;
}

DWORD curProtection;
VirtualProtect(toHook, len, PAGE_EXECUTE_READWRITE, &curProtection);

memset(toHook, 0x90, len);

DWORD relativeAddress = ((DWORD)ourFunct - (DWORD)toHook) - 5;

*(BYTE*)toHook = 0xE9;
*(DWORD*)((DWORD)toHook + 1) = relativeAddress;

DWORD temp;
VirtualProtect(toHook, len, curProtection, &temp);

return true;
}

2.Hook函数实现

在我 GitHub 项目 Hitman5_InternalCheat 当中有使用此技术的片段,下面把这些片段提取出来做个介绍,更详细的查看项目源代码即可了解。

我在游戏中找到了关于子弹修改的关键函数(在杀手5逆向分析系列教程中有讲),下面是此函数写入子弹数值部分的汇编代码:

1
2
3
4
5
6
7
8
9
10
.text:006F5C70                 push    ebp
.text:006F5C71 mov ebp, esp
.text:006F5C73 sub esp, 1Ch
.text:006F5C76 mov ax, [ebp+currentAmmo]
.text:006F5C7A push ebx
.text:006F5C7B push esi
.text:006F5C7C mov esi, ecx
.text:006F5C7E mov edx, [esi]
.text:006F5C80 mov [esi+316h], ax ; 此处通过ax寄存器写入子弹数
.text:006F5C87 mov eax, [edx+180h]

我在写入子弹的位置进行了Hook(此地址处006F5C80),下面是我要修改的Hook函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void __declspec(naked) hook::ammoFunc()		
{ //每次调用更新子弹代码就修改子弹数为999
__asm
{
push ecx ;先存储ecx寄存器防止程序崩溃
mov ecx, [esi + 0x5E6]
cmp ecx, 2 ;判断是敌方还是本人
je code ;是敌方就跳转到原始指令
mov ax, 0x3E7 ;是本人就把999存到ax当中
mov[esi + 0x00000316], ax ;修改子弹数为999
pop ecx ;恢复原来的ecx寄存器
jmp ammoBackAddy ;跳转到原程序后续代码处

code : ;原代码
mov[esi + 0x00000316], ax
pop ecx
jmp ammoBackAddy
}
}

先查看并存储目标位置的原始指令包括机器码:

1
HMA.exe+2F5C80 -> 66 89 86 16 03 00 00 == mov [esi+00000316],ax

可以看到机器码总共占7个字节,也就是说要覆盖的长度为7个字节。目标地址为模块基址加上偏移量 0x2F5C80。原程序的下一行代码地址为目标地址加上被窃取的字节长度:

1
2
3
int ammoLen = 7;
void* ammoDst = (void*)(modBaseAddy + 0x2F5C80);
ammoBackAddy = (DWORD)ammoDst + ammoLen;

有了这些信息之后可以直接调用Hook函数来进行真正的Hook了:

1
mem::myHook(ammoDst, hook::ammoFunc, ammoLen);

3.恢复Hook函数

我们想要结束Hook的话需要把之前被窃取的字节恢复成原来的模样才行。这里可以写一段修改内存机器码的函数,接受的参数为目标地址,修改内容和大小:

1
2
3
4
5
6
7
void mem::writePatch(BYTE* dst, BYTE* src, unsigned int size)
{
DWORD oldPertec = 0;
VirtualProtect((BYTE*)dst, size, PAGE_EXECUTE_READWRITE, &oldPertec);
memcpy((BYTE*)dst, (BYTE*)src, size);
VirtualProtect((BYTE*)dst, size, oldPertec, &oldPertec);
}

同样的先存储原来的内存保护属性,再把内存保护属性修改为可读写,把给定的修改内容写进去,再把内存保护属性恢复回来。

下面调用此函数来恢复被窃取的字节为原来的机器码:

1
mem::writePatch((BYTE*)ammoDst, (BYTE*)"\x66\x89\x86\x16\x03\x00\x00", ammoLen);

这样以来程序就变回了原来的代码。


上面介绍的这种Detour方法是Hook技术当中的一种技术,后面会陆续分享更多的Hook技术。可以把存储原始指令的机制整合到Hook函数当中使用会更加方便。文章当中有需要更改的部分请在评论区指出或者联系我个人邮箱进行反馈,谢谢观看!