函数Hook技术(Trampoline 蹦床)

这一期主要分享Hook中的Trampoline(蹦床)技术,这个相当于是Detour的进化版。Trampoline的最大的优势是解决Detour调用被Hook函数带来的无限递归问题。


一,技术介绍

Trampoline和Detour的区别

1.Dertour(转向)

结合上一期内容Detour是拦截目标函数并把代码重定向到自己的函数(Hook函数)当中。比如当程序正常调用一个目标函数时实际上执行的是被我们修改后的指令,通过插入的Jmp指令让CPU跳转到我们的代码。本质就是拦截和重定向目标函数。

举例:汽车正常形式的一条公路上,我们设立了个路障(拦截)和指示牌(转向),强制让所有车开往我们指定的一条小路(Hook函数)上。

2.Trampoline(蹦床)

跟Detour不同的是,在实行Detour之前先备份被窃取的字节(原始指令),再分配一块内存空间(Trampoline空间)存放原始指令。在这原始指令后面紧接着一个Jmp指令用来跳转回被窃取的字节之后的位置,继续执行原始函数的剩余部分。这样就达到了备份原始指令的目的,我们先通过Detour跳转到我们的Hook函数执行想要的功能逻辑(如果Hook函数当中我们需要调用原始指令的话直接调用Trampoline的地址就行了)。执行完Hook函数之后CPU会跳转到Trampoline,Trampoline会执行之前备份好的原始指令,然后通过Trampoline中的Jmp指令跳转回原始函数继续执行。本质上可以在自己的函数当中可以随意调用原函数避免出现递归循环问题。

举例:跟上面的公路举例中,在设置的路障旁边修建一条匝道(Trampoline),这个匝道包含被我们通过路障覆盖掉的原始公路的一部分(备份原始指令),匝道的尽头就是原始公路路障后面的部分。当车进入我们的小路(Hook函数)后,必须经过我们新修建的匝道(Trampoline)才能会到主公路的后面部分继续行驶。

S1

二,Hook函数(Trampoline)

1.函数的定义

首先是函数的声明,跟Detour创建的Hook函数一样需要三个参数。分别是想要拦截的目标函数地址,指向我们自定义的Hook函数的地址,需要被窃取的字节长度:

1
void * TrampolineHook(void * toHook, void * ourFunct, int len);

第一步跟之前一样先判断被窃取的字节长度是否小于5个字节,是的话就没有办法完整的写入Detour所需要的跳转指令返回NULL表示失败:

1
if (len < 5) return NULL;

之后可以为存放 Trampoline 代码分配一个新的内存空间,这里通过调用 Windows API 函数 VirtualAlloc 来实现。这个函数接受4个参数:

1
2
3
4
5
6
LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flAllocationType,
[in] DWORD flProtect
);

根据介绍第一个参数是分配的内存起始地址填写 NULL 参数会让系统自动选择分配的基地址,第二个参数是分配内存区域的大小填写 len+5 后面的5代表跳转指令的长度,第三个参数填写 MEM_COMMIT | MEM_RESERVE 表示直接完成内存的预留和提交可以立即让内存区域可用,第四个参数 PAGE_EXECUTE_READWRITE 让这块地址的拥有执行权限和读写权限。

此函数返回成功能得到新创建的内存区域地址,把这个地址存储在 trampoLine 指针当中:

1
void * trampoLine = VirtualAlloc(NULL, len + 5, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

得到了新内存区域后通过 memcpy 函数把原始指令复制到 trampoLine 区域当中:

1
memcpy(trampoLine, toHook, len);

原始指令后需要跳转到原函数的后面部分去才行,所以这里要先计算跳转回原始函数的地址:

1
uintptr_t  trampoJmpAddy = ((uintptr_t)toHook - (uintptr_t)trampoLine) - 5;

将 Jmp 指令的操作码写入内存区域,0xE9 是Jmp指令的操作码。 (uintptr_t)trampoLine + len 计算出原始指令之后的第一个字节处:

1
*(BYTE*)((uintptr_t)trampoLine + len) = 0xE9;

Jmp指令后面紧接着需要跳转的地址,这个地址在上面计算过,直接写入即可:

1
*(uintptr_t *)((uintptr_t)trampoLine + len + 1) = trampoJmpAddy;

现在就可以执行Detour操作来修改目标函数了,这里为了方便可以直接调用在上一期创建的myHook函数:

1
myHook(toHook, ourFunct, len);

根据前面所讲执行完我们的Hook函数之后就可以返回 Trampoline 的地址:

1
return trampoLine;

下面是完整的代码:

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

void * trampoLine = VirtualAlloc(NULL, len + 5, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

memcpy(trampoLine, toHook, len);

uintptr_t trampoJmpAddy = ((uintptr_t)toHook - (uintptr_t)trampoLine) - 5;

*(BYTE*)((uintptr_t)trampoLine + len) = 0xE9;

*(uintptr_t *)((uintptr_t)trampoLine + len + 1) = trampoJmpAddy;

Hook(toHook, ourFunct, len);

return trampoLine;
}

2.Trampoline函数的实现

这里我就拿经典的 FPS 游戏 AssaultCube 来举例,这个游戏的渲染是通过 OpenGL 中的 wglSwapBuffers 函数来实现的。通过 IDA 查看 ac_client.exe 中的 Imports 就能搜到此函数的依赖:

1
004DA3E0		SDL_GL_SwapBuffers	SDL

确认了是OpenGL库之后通过 IDA 打开 opengl32.dll 搜索 Exports 窗口中的 wglSwapBuffers 函数:

1
wglSwapBuffers	6923D080	362

双击进入此函数查看函数声明,因为不修改函数内部逻辑所以不用分析函数实现:

1
int __stdcall wglSwapBuffers(HDC hDC);

可以看到 stdcall 调用约定,返回类型为 int ,接受一个HDC类型参数的函数。那么我们通过 typedef 定义一个函数指针类型 wglSwapBuffers ,再声明一个全局函数指针变量用来存储原始函数的地址Trampoline 地址

1
2
typedef int(__stdcall* p_wglSwapBuffers)(HDC hDC);
p_wglSwapBuffers o_wglSwapBuffers;

创建我们的Hook函数用来“替代”原始的 wglSwapBuffers ,每次调用 wglSwapBuffers 时会先调用我们的Hook函数。在 Hook 函数中先执行一段自己想要的代码最后再跳转到 Trampoline 函数:

1
2
3
4
5
int __stdcall hook_wglSwapBuffers(HDC hDC)
{
//自己的代码
return o_wglSwapBuffers(hDC); //跳转到 Trampoline 函数
}

这样以来我们就写完了Hook函数,之后在我们的线程创建处要调用此钩子函数之前先把原始函数 wglSwapBuffers 的地址存放到之前声明的 o_wglSwapBuffers 当中:

1
o_wglSwapBuffers = (p_wglSwapBuffers)GetProcAddress(GetModuleHandle(L"opengl32.dll"), "wglSwapBuffers");		//自动获取原始 wglSwapBuffers 的地址

然后就可以正常调用我们的 TrampolineHook 函数了,最重要的一点是要把此函数返回的新内存地址(Trampoline函数地址)赋值给 o_wglSwapBuffers 变量。这样就能实现完整的Hook了,要是这里不改 o_wglSwapBuffers 变量的值,一直保持原始函数地址的话会无限递归导致栈溢出程序崩溃:

1
o_wglSwapBuffers = (p_wglSwapBuffers)mem::TrampolineHook((void*)o_wglSwapBuffers, (void*)hook_wglSwapBuffers, 5);

在游戏中注入线程之后通过 Cheat Engine 查看 wglSwapBuffers 函数:

1
2
3
4
5
6
7
8
OPENGL32.dll+3D07D - CC                    - int 3 
OPENGL32.dll+3D07E - CC - int 3
OPENGL32.dll+3D07F - CC - int 3
OPENGL32.dll+3D080 - E9 C644BA19 - jmp AssaultCube_TrampolineHook.dll+1154B ;这里被Hook了
OPENGL32.dll+3D085 - 83 E4 F8 - and esp,-08 { 248 }
OPENGL32.dll+3D088 - 83 EC 14 - sub esp,14 { 20 }
OPENGL32.dll+3D08B - 53 - push ebx

加个断点一步步执行就能调转到Hook函数:

1
2
3
4
5
6
7
8
9
10
11
AssaultCube_TrampolineHook.dll+158CC - CC                    - int 3 
AssaultCube_TrampolineHook.dll+158CD - CC - int 3
AssaultCube_TrampolineHook.dll+158CE - CC - int 3
AssaultCube_TrampolineHook.dll+158CF - CC - int 3
AssaultCube_TrampolineHook.dll+158D0 - 55 - push ebp ;Hook函数开始处
AssaultCube_TrampolineHook.dll+158D1 - 8B EC - mov ebp,esp
AssaultCube_TrampolineHook.dll+158D3 - 81 EC 8C010000 - sub esp,0000018C { 396 }
AssaultCube_TrampolineHook.dll+158D9 - 53 - push ebx
AssaultCube_TrampolineHook.dll+158DA - 56 - push esi
AssaultCube_TrampolineHook.dll+158DB - 57 - push edi

Visual Studio 的调试器显示了所有变量的地址,Cheat Engine 调转到蹦床函数的地址就能看到新创建的内存空间:

1
2
3
4
5
6
7
00FFFFFF -                       - ?? 
01000000 - 8B FF - mov edi,edi ;Trampoline 函数开始处执行原始指令
01000002 - 55 - push ebp
01000003 - 8B EC - mov ebp,esp
01000005 - E9 7BD05360 - jmp OPENGL32.dll+3D085 ;跳转回原函数后续
0100000A - 00 00 - add [eax],al

对此产生迷惑可以通过 Visual Studio 和 Cheat Engine进行配合调试一步步执行代码进行学习。

3.完整代码

下面是完整带注释的代码可自行实践学习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// 获取游戏主模块的基址,用于后续计算游戏内存中各功能的地址
uintptr_t baseAddress = (uintptr_t)GetModuleHandle(L"ac_client.exe");
// 获取OpenGL模块的基址,用于定位图形API函数
uintptr_t openglAddress = (uintptr_t)GetModuleHandle(L"opengl32.dll");

// 定义函数指针类型,用于存储原始的wglSwapBuffers函数
// __stdcall是Windows API常用的调用约定,确保参数从右到左入栈,由被调用者清理栈
typedef int(__stdcall* p_wglSwapBuffers)(HDC hDC);
// 声明一个函数指针,将用于保存原始的wglSwapBuffers函数地址
p_wglSwapBuffers o_wglSwapBuffers;


// 自定义的wglSwapBuffers钩子函数,在每帧渲染完成后被调用
// 参数hDC是设备上下文句柄,标识了要交换缓冲区的窗口
int __stdcall hook_wglSwapBuffers(HDC hDC)
{
// 声明三个布尔变量,用于跟踪各个作弊功能的开关状态
bool bHealth = false, bAmmo = false, bRecoil = false;

// 计算玩家对象的基址,通过游戏基址加上固定偏移0x10f4f4
uintptr_t playerBase = baseAddress + 0x10f4f4;
// 使用多级指针寻址找到玩家血量的内存地址,偏移为0xf8
uintptr_t healthPtr = mem::findDMMAddy(playerBase, { 0xf8 });
// 使用多级指针寻址找到玩家弹药的内存地址,偏移链为0x374->0x14->0x0
uintptr_t ammoPtr = mem::findDMMAddy(playerBase, { 0x374,0x14,0x0 });

// 设置要写入的新值为1337,用于修改血量和弹药
int newValue = 1337;

// 检测小键盘1键是否被按下(按一次切换一次状态)
// GetAsyncKeyState返回值的最低位(0x1)表示按键是否在上次调用后被按下
if (GetAsyncKeyState(VK_NUMPAD1) & 1)
{
// 切换无限血量功能的开关状态
bHealth = !bHealth;
}

// 检测小键盘2键是否被按下
if (GetAsyncKeyState(VK_NUMPAD2) & 1)
{
// 切换无限弹药功能的开关状态
bAmmo = !bAmmo;
}

// 检测小键盘3键是否被按下
if (GetAsyncKeyState(VK_NUMPAD3) & 1)
{
// 切换无后座力功能的开关状态
bRecoil = !bRecoil;

if (bRecoil)
{
// 如果启用无后座力,则使用NOP指令(0x90)覆盖游戏中处理后座力的代码
// 这会使游戏在执行到这段代码时不做任何操作,从而消除后座力
mem::nopPatch((BYTE*)(baseAddress + 0x63786), 10);
}
else
{
// 如果禁用无后座力,则恢复原始的后座力处理代码
// 这段十六进制代码是游戏原本用于计算后座力效果的指令
mem::writePatch((BYTE*)(baseAddress + 0x63786), (BYTE*)("\x50\x8d\x4c\x24\x1c\x51\x8b\xce\xff\xd2"), 10);
}
}

// 检查玩家基址是否有效,防止在玩家不存在时进行内存操作导致崩溃
if (playerBase)
{
// 如果无限血量功能已启用
if (bHealth)
{
// 将新值(1337)写入到玩家血量地址,4表示写入4个字节(int类型大小)
mem::writePatch((BYTE*)healthPtr, (BYTE*)&newValue, 4);
}

// 如果无限弹药功能已启用
if (bAmmo)
{
// 将新值(1337)写入到玩家弹药地址
mem::writePatch((BYTE*)ammoPtr, (BYTE*)&newValue, 4);
}
}

// 调用原始的wglSwapBuffers函数完成缓冲区交换,确保游戏正常显示
// 这是钩子函数的最后一步,必须执行以保持游戏画面更新
return o_wglSwapBuffers(hDC);
}


// 作弊功能的主线程函数,在DLL注入后由一个新线程执行
DWORD WINAPI HackThread(HMODULE hModule)
{
// 获取OpenGL32.dll中wglSwapBuffers函数的原始地址
// GetProcAddress用于从DLL中获取导出函数的地址
o_wglSwapBuffers = (p_wglSwapBuffers)GetProcAddress(GetModuleHandle(L"opengl32.dll"), "wglSwapBuffers");

// 使用蹦床钩子技术钩住wglSwapBuffers函数
// 将原始函数入口点的前5个字节替换为跳转指令,指向我们的hook_wglSwapBuffers函数
// 同时创建一个蹦床,保存原始函数的执行流程,返回值是可以调用原始函数的地址
o_wglSwapBuffers = (p_wglSwapBuffers)mem::TrampolineHook((void*)o_wglSwapBuffers, (void*)hook_wglSwapBuffers, 5);

// 线程函数返回0,表示成功完成
return 0;
}

// DLL入口点函数,当DLL被加载或卸载时由系统调用
BOOL APIENTRY DllMain( HMODULE hModule, // 当前DLL的句柄
DWORD ul_reason_for_call, // 调用原因
LPVOID lpReserved // 保留参数
)
{
// 根据不同的调用原因执行不同的操作
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: // 当DLL被加载到进程时
{
// 创建一个新线程来执行作弊功能
// 这样做是为了避免在DllMain中执行过多代码,因为DllMain有严格的限制
HANDLE hThread = nullptr;
hThread = CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)HackThread, hModule, 0, nullptr);

// 如果线程创建成功,关闭线程句柄
// 我们不需要等待线程结束,所以可以立即关闭句柄
if (hThread)
{
CloseHandle(hThread);
}
}
case DLL_THREAD_ATTACH: // 当进程创建新线程时
case DLL_THREAD_DETACH: // 当线程退出时
case DLL_PROCESS_DETACH: // 当DLL从进程中卸载时
break;
}
// 返回TRUE表示DLL初始化成功
return TRUE;
}

每一种Hook技术都有各自的优势和缺点,根据当时的情况来选择适合的技术才是最优解,后续我会继续补充其他的Hook技术。文章当中有需要更改的部分请在评论区指出或者联系我个人邮箱进行反馈,谢谢观看!