今天分享的新技术是怎么调用游戏当中的函数,这就引出一个新问题,为啥要调用游戏中的函数?虽然我们可以自己为游戏编写任何形式的函数来执行,但是有一部分复杂的函数是很难自己去写的。比如射线检测函数,游戏中成功定位到此函数的话就没必要单独再编写一份复杂的射线检测了,直接调用自带的函数即可。
一,技术解析
这种技术一般被称为 “函数指针重定向” ,这项技术允许我们直接调用游戏内存中的函数,就跟调用自己写的函数一样。这项技术通常用于游戏模组开发,游戏外挂开发,调试工具开发等技术。以下是C++形式的大致原理讲解:
1 2 3 4 5 6 7 8
| typedef cvar_t*(__cdecl * _Cvar_Get)(const char *var_name, const char *var_value, int flags);
_Cvar_Get Cvar_Get = (_Cvar_Get)0x043F688;
Cvar_Get("cl_gamepath", "OpenArena", 0);
|
1.函数指针的定义
第一行代码定义了一个新的类型,这个类型是一个函数指针名为 _Cvar_Get 。函数指针在C++当中的一般语法为:
这里的 cvar_t*
是函数的返回类型,说明函数会返回一个指向 cvar_t
结构体的指针。在IDA Pro的伪代码当中的第一行就能看到函数的返回类型。比如知道了游戏中的某一个需要调用的函数返回类型,那么调用时可以把返回类型写成IDA中看到的类型。
__cdecl
指的是函数的调用约定,调用约定是个很重要的概念,参数传递和栈的清理都是由这个来决定的。以下是常见的调用约定:
- cdecl —— 参数通过栈来传递(从右向左入栈),调用者负责清理栈(谁调用谁清理)
- stdcall —— 参数通过栈来传递(从右向左入栈),被调用函数负责清理栈(调用的不负责清理)
- fastcall —— 部分参数通过寄存器传递(前2-4个参数)剩下的从右向左入栈,被调用函数负责清理栈(调用的不负责清理)。通过寄存器传递的目的是提高性能,相当于是stdcall的进化版。
- thiscall —— this指针通过特定的寄存器传递(x86是ECX)剩下的从右向左入栈,被调用函数负责清理栈(调用的不负责清理)
后面的 *
是用来修饰后面的定义类型,后面的 _Cvar_Get
是我们定义的新类型名称,加上星号的修饰就表示这是个指针类型。
1
| (const char *var_name, const char *var_value, int flags)
|
最后一个括号当中的是函数的参数列表,包含三个参数(两个字符串和一个整数)。
那么最前面加的 typedef
主要作用是告诉编译器我们在定义一个新的类型名称而不是声明变量。所以整行的意思如下:
1
| typedef cvar_t*(__cdecl * _Cvar_Get)(const char *var_name, const char *var_value, int flags);
|
创建一个名为 _Cvar_Get 的新类型,这是个函数指针类型,指向的这个函数使用 __cdecl 调用约定并且接受三个参数(两个 const char 和一个 int ),最后返回一个 cvar_t 类型的值。
2.函数指针的赋值
我们来看第二行代码,主要操作时函数指针变量的声明和赋值:
1
| _Cvar_Get Cvar_Get = (_Cvar_Get)0x043F688;
|
_Cvar_Get
是我们在前面定义的类型名,后面的 Cvar_Get
是新定义的变量名( _Cvar_Get 类型的函数指针变量),0x043F688 是个内存地址(游戏内函数的内存地址), (_Cvar_Get)
是类型转换把内存地址转换为函数指针类型。
通俗一点来讲在实际用途当中,通过分析IDA中函数的参数类型,返回值和调用约定。再使用 typedef 将这个原型定义为一个函数指针类型,然后声明一个该类型的变量指向IDA或者动态调试找到的函数内存地址。这样就可以像调用普通C++函数一样调用游戏内部函数了。
3.函数指针的调用
下面来详细解释第三行代码:
1
| Cvar_Get("cl_gamepath", "OpenArena", 0);
|
这行代码是对函数指针的调用,看起来跟普通函数的调用一样,但是实际上这里调用的是指向内存地址 0x043F688 的函数。
1.执行过程是先找到 Cvar_Get 变量,一看是个函数指针,那就获取该指针指向的内存地址( 0x043F688 )。
2.根据 __cdecl 调用约定,参数从右向左入栈,首先将第三个参数 0 压入栈,然后把第二个参数 OpenArena 的地址压入栈,最后把第三个参数 cl_gamepath 的地址压入栈。
3.程序跳转到地址为 0x043F688 处执行那里的代码,执行过程中被调用函数可以通过栈来访问传入的三个参数,函数执行完之后函数返回一个 cvar_t* 类型的指针。
4.因为是 __cdecl 调用约定,所以调用者负责清理栈。
4.举例说明
假设我们在IDA中发现了一个我们需要的游戏内部函数,这个函数负责创建爆炸效果:
1 2 3 4 5
| 函数地址: 0x00487A2C 函数名: Game_CreateExplosion 传入参数: float x, float y, float z, float radius, int damage 返回值: void 调用约定: __cdecl
|
根据上面内容,实现方式如下:
1 2 3 4 5 6 7 8
| typedef void(__cdecl *_Game_CreateExplosion)(float x, float y, float z, float radius, int damage);
_Game_CreateExplosion Game_CreateExplosion = (_Game_CreateExplosion)0x00487A2C;
Game_CreateExplosion(100.0f, 200.0f, 50.0f, 30.0f, 100);
|
因为我们的函数指针定义和游戏内的函数内存布局是完全匹配的,调用约定也必须跟游戏内部的保持一直,所以CPU会调转到游戏代码的正确位置执行原始代码不会导致游戏崩溃。
二,实际演示
1.无参函数的调用
(1)示范程序编译
为了实现上面的技术,可以自己写一段程序来模拟游戏中的函数。以下一段无参函数调用源码:
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
| #include <iostream> #include <Windows.h>
void printword_A() { std::cout << "This is A !!!\n"; }
void printword_B() { std::cout << "This is B !!!\n"; }
int main() { while (true) { if (GetAsyncKeyState(VK_F1) & 1) { printword_A(); } if (GetAsyncKeyState(VK_F2) & 1) { printword_B(); } } }
|
Visual Studio 在编译时得把优化选项全部关闭,不然编译出来的汇编代码都是彻底优化之后的代码整体调用都会发生变化。在项目设置中进行关闭:
(2)调试反汇编
编译之后的程序通过x32dbg进行调试,以下是main函数部分的反汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| push ebp mov ebp,esp mov eax,1 test eax,eax je hooktestproject1.F1107A push 70 call dword ptr ds:[<GetAsyncKeyState>] movsx ecx,ax and ecx,1 je hooktestproject1.F11062 call <hooktestproject1.?printword_A@@YAXXZ> nop push 71 call dword ptr ds:[<GetAsyncKeyState>] movsx edx,ax and edx,1 je hooktestproject1.F11078 call <hooktestproject1.?printword_B@@YAXXZ> nop jmp hooktestproject1.F11043 pop ebp ret
|
可以看到调用两个 printword 函数call之前是没有进行任何传参操作,就可以断定此函数是无参函数。下一部判断返回类型,在call指令出下断点然后按快捷键触发断点,进入函数内部,以下是函数内部实现:
1 2 3 4 5 6 7 8 9
| push ebp ;通过栈保存ebp mov ebp,esp push hooktestproject1.F13140 mov eax,dword ptr ds:[<?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A>] push eax ;传字符串并调用printf函数 call <hooktestproject1.??$?6U?$char_traits@D@std@@@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@0@AAV10@PBD@Z> add esp,8 ;栈平衡 pop ebp ;恢复ebp ret
|
调用完 printf 函数之后进行了栈平衡操作,这里的栈平衡是针对 printf 函数的两个参数,并没有看到任何返回值。在调用者部分能看到调用 printword 函数后有个 nop 操作,这是由于没有任何参数通过栈来传递也就是没有必要清理栈就用了 nop 来代替。通过上述分析就可以确定这是一个调用约定为 cdecl 的无返回值的函数。
(3)Hook程序
已经确定了我们需要调用函数的返回类型,调用约定,参数情况。可以通过 DLL hook 程序的方式来调用这个函数。下面需要找到的是函数地址值,x32dbg 下双击进入对应的call指令后,点击函数第一行后在下方会显示函数的偏移地址:
注意:这里要获取的不是call指令处的地址而是函数地址(call之后的函数首行地址)
因为函数的地址不是静态地址,所以调用时需要先获取模块地址再加上偏移地址就能定位函数地址,下面是实现方法:
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
| typedef void(__cdecl* _PrintA)(); typedef void(__cdecl* _PrintB)();
uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL);
_PrintA PrintA = (_PrintA)(modbase + 0x105C); _PrintB PrintB = (_PrintB)(modbase + 0x1072);
DWORD WINAPI HackThread(HMODULE hModule) { while (true) { if (GetAsyncKeyState(VK_NUMPAD7) & 1) { PrintA(); } if (GetAsyncKeyState(VK_NUMPAD8) & 1) { PrintB(); } if (GetAsyncKeyState(VK_END) & 1) { break; } } FreeLibraryAndExitThread(hModule, 0); return 0; }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)HackThread, hModule, 0, nullptr); break; default: break; } return TRUE; }
|
2.有参函数的调用
(1)示范程序修改
把第二个函数改成带参的并且打印输出特定的字符串:
1 2 3 4 5 6 7
| void printword_B(const char* word) { std::cout << word << "\n"; }
printword_B("Hello World");
|
(2)调试反汇编
x32dbg 跳转到 main 函数查看第二个函数的汇编代码:
1 2 3 4 5 6 7 8 9 10 11
| push 62 call dword ptr ds:[<GetAsyncKeyState>] movsx edx,ax and edx,1 je hooktestproject_1.2C108F push hooktestproject_1.2C315C ;通过栈传参 call <hooktestproject_1.void __cdecl printword_B(char const *)> ;第二个函数的调用 add esp,4 ;调用者平衡栈 jmp hooktestproject_1.2C1053 pop ebp ret
|
汇编代码可以知道 函数B 通过栈传递了一个参数,查看此参数的地址值发现是一个以0结尾的字符串:
调用完 函数B 之后调用者平衡了栈,查看函数B内部也看不到任何返回值。因此这个函数的调用约定为 cdecl ,有一个字符串参数并无返回值。
(3)Hook程序
修改一下我们的Hook程序把 函数B 改成带参的类型,把原有的字符串改成自己想要的字符串:
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
| typedef void(__cdecl* _PrintA)(); typedef void(__cdecl* _PrintB)(const char * changeWord);
uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL);
_PrintA PrintA = (_PrintA)(modbase + 0x1000); _PrintB PrintB = (_PrintB)(modbase + 0x1020);
DWORD WINAPI HackThread(HMODULE hModule) { while (true) { if (GetAsyncKeyState(VK_NUMPAD7) & 1) { PrintA(); } if (GetAsyncKeyState(VK_NUMPAD8) & 1) { PrintB("Hack Success !"); } if (GetAsyncKeyState(VK_END) & 1) { break; } } FreeLibraryAndExitThread(hModule, 0); return 0;
|
展示效果图:
3.thiscall函数的调用
(1)示范程序修改
为了调用thiscall创建一个玩家类,里面写一个减生命值函数。创建实例,把实例地址传递给一个变量再通过此变量来调用成员函数和传递参数:
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
| #include <iostream> #include <Windows.h>
class player { public: void subHealthFunc(int damage) { health -= damage; std::cout << "damage is : " << damage << "\n"; }
int health; };
player* l_player;
int main() { player player_sam; l_player = &player_sam;
l_player->health = 1000;
while (true) { if (GetAsyncKeyState(VK_NUMPAD1) & 1) { std::cout << "health is : " << l_player->health << "\n"; } if (GetAsyncKeyState(VK_NUMPAD2) & 1) { l_player->subHealthFunc(10); std::cout << "new health is : " << l_player->health << "\n"; } } }
|
(2)调式反汇编
x32dbg 跳转到 main 函数查看成员函数调用部分的汇编代码:
1 2 3 4 5 6 7 8
| push 62 call dword ptr ds:[<GetAsyncKeyState>] movsx edx,ax and edx,1 je hooktestproject_1.1C1125 push A ;第二个参数(damage) mov ecx,dword ptr ds:[<class player *l_player>] ;第一个参数this指针 call <hooktestproject_1.public: void __thiscall player::subHealthFunc(int)>
|
在实际情况下大部分通过ecx传递第一个参数的大概率为thiscall调用约定,还可以发现此函数有两个参数,函数内部也没有返回值。
(3)Hook程序
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
| struct _player { typedef void(__thiscall* _bigDamage)(void* ptr_this, int damage); _bigDamage bigdamage;
int damage; void* ptr_this = *(void**)0x1c53c8; } player;
uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL);
DWORD WINAPI HackThread(HMODULE hModule) { player.bigdamage = (_player::_bigDamage)(modbase + 0x1000); while (true) { if (GetAsyncKeyState(VK_NUMPAD7) & 1) { player.bigdamage(player.ptr_this, 100); } if (GetAsyncKeyState(VK_END) & 1) { break; } }
FreeLibraryAndExitThread(hModule, 0); return 0; }
|
展示效果图:
好了现在你已经掌握了此技术,可以随意调用自己所需要的程序函数了。这种技术最主要的还是以分析为主,找出函数的调用约定,参数,返回值就能任意调用了。文章当中有需要更改的部分请在评论区指出或者联系我个人邮箱进行反馈,谢谢观看!