游戏函数调用技术

今天分享的新技术是怎么调用游戏当中的函数,这就引出一个新问题,为啥要调用游戏中的函数?虽然我们可以自己为游戏编写任何形式的函数来执行,但是有一部分复杂的函数是很难自己去写的。比如射线检测函数,游戏中成功定位到此函数的话就没必要单独再编写一份复杂的射线检测了,直接调用自带的函数即可。


一,技术解析

这种技术一般被称为 “函数指针重定向” ,这项技术允许我们直接调用游戏内存中的函数,就跟调用自己写的函数一样。这项技术通常用于游戏模组开发,游戏外挂开发,调试工具开发等技术。以下是C++形式的大致原理讲解:

1
2
3
4
5
6
7
8
//通过typedef定义函数指针
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++当中的一般语法为:

1
返回类型 (调用约定 *指针名称)(参数列表)

这里的 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
// 1. 定义函数类型
typedef void(__cdecl *_Game_CreateExplosion)(float x, float y, float z, float radius, int damage);

// 2. 创建函数指针实例
_Game_CreateExplosion Game_CreateExplosion = (_Game_CreateExplosion)0x00487A2C;

// 3. 调用游戏内部函数
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) //按F1打印字符串
{
printword_A();
}
if (GetAsyncKeyState(VK_F2) & 1) //按F2打印字符串
{
printword_B();
}
}
}

Visual Studio 在编译时得把优化选项全部关闭,不然编译出来的汇编代码都是彻底优化之后的代码整体调用都会发生变化。在项目设置中进行关闭:

S1

(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指令后,点击函数第一行后在下方会显示函数的偏移地址:

S2

注意:这里要获取的不是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)(); // PrintA函数的函数指针类型定义
typedef void(__cdecl* _PrintB)(); // PrintB函数的函数指针类型定义

// 获取当前进程的基地址
uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL);

// 通过基地址加偏移量定位目标函数
_PrintA PrintA = (_PrintA)(modbase + 0x105C); // 初始化PrintA函数指针,指向基地址+0x105C的位置
_PrintB PrintB = (_PrintB)(modbase + 0x1072); // 初始化PrintB函数指针,指向基地址+0x1072的位置

/**
* 注入线程的主函数
* @param hModule 当前DLL模块句柄
* @return 线程退出码
*/
DWORD WINAPI HackThread(HMODULE hModule)
{
// 无限循环监听按键
while (true)
{
if (GetAsyncKeyState(VK_NUMPAD7) & 1) // 检测小键盘7是否被按下(按下一次)
{
PrintA(); // 调用目标程序中的PrintA函数
}
if (GetAsyncKeyState(VK_NUMPAD8) & 1) // 检测小键盘8是否被按下(按下一次)
{
PrintB(); // 调用目标程序中的PrintB函数
}
if (GetAsyncKeyState(VK_END) & 1) // 检测End键是否被按下(按下一次)
{
break; // 结束循环,准备退出线程
}
}

// 释放DLL并退出线程
FreeLibraryAndExitThread(hModule, 0);
return 0; // 返回退出码(此行代码实际上不会执行,因为FreeLibraryAndExitThread会直接终止线程)
}

/**
* DLL入口点函数
* @param hModule DLL模块句柄
* @param ul_reason_for_call 调用原因
* @param lpReserved 保留参数
* @return 是否成功
*/
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH: // 当DLL被加载到进程时
// 创建一个新线程执行HackThread函数,避免在DllMain中执行过多操作
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结尾的字符串:

S3

调用完 函数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)(); // 定义无参数函数指针类型,用于调用printword_A函数
typedef void(__cdecl* _PrintB)(const char * changeWord); // 定义带字符串参数的函数指针类型,用于调用printword_B函数

uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL); // 获取当前进程的基地址

// 通过基地址加偏移量获取目标函数的实际地址
_PrintA PrintA = (_PrintA)(modbase + 0x1000); // 初始化PrintA函数指针,指向printword_A函数
_PrintB PrintB = (_PrintB)(modbase + 0x1020); // 初始化PrintB函数指针,指向printword_B函数

// 注入线程的主函数

DWORD WINAPI HackThread(HMODULE hModule)
{
// 无限循环监听按键
while (true)
{
if (GetAsyncKeyState(VK_NUMPAD7) & 1) // 检测小键盘7是否被按下(按下一次)
{
PrintA(); // 调用目标程序中的printword_A函数
}
if (GetAsyncKeyState(VK_NUMPAD8) & 1) // 检测小键盘8是否被按下(按下一次)
{
// 直接传递字符串字面量,这可能会导致问题,因为字符串存储在DLL内存空间
// 更安全的做法是在目标进程空间分配内存并复制字符串
PrintB("Hack Success !"); // 调用目标程序中的printword_B函数并传入自定义字符串
}
if (GetAsyncKeyState(VK_END) & 1) // 检测End键是否被按下(按下一次)
{
break; // 结束循环,准备退出线程
}
}

// 释放DLL并退出线程
FreeLibraryAndExitThread(hModule, 0);
return 0; // 返回退出码(此行代码实际上不会执行,因为FreeLibraryAndExitThread会直接终止线程)

展示效果图:

S4

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) //按F1打印字符串
{
std::cout << "health is : " << l_player->health << "\n";
}
if (GetAsyncKeyState(VK_NUMPAD2) & 1) //按F2打印字符串
{
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
{
// 定义一个函数指针类型,表示造成大量伤害的成员函数
// __thiscall 表示这是一个C++类成员函数的调用约定
// 参数1: this指针,指向对象实例
// 参数2: 伤害值
typedef void(__thiscall* _bigDamage)(void* ptr_this, int damage);

// 声明一个函数指针,将用于存储目标函数的地址
_bigDamage bigdamage;

// 存储伤害值的变量
int damage;

// 从内存地址0x1c53c8获取this指针
// 这个地址可能存储了游戏中玩家对象的指针
void* ptr_this = *(void**)0x1c53c8;
} player; // 创建一个全局player实例


// 获取当前进程的基址,用于计算函数的实际地址
uintptr_t modbase = (uintptr_t)GetModuleHandleA(NULL);


// DLL注入后的主线程函数
DWORD WINAPI HackThread(HMODULE hModule)
{
// 初始化bigdamage函数指针
// 通过基址加偏移量0x1000计算出目标函数的实际地址
player.bigdamage = (_player::_bigDamage)(modbase + 0x1000);

// 主循环,持续检测按键输入
while (true)
{
// 检测是否按下小键盘7键
if (GetAsyncKeyState(VK_NUMPAD7) & 1)
{
// 调用bigdamage函数,传入this指针和伤害值100
// 这将对游戏中的玩家造成100点伤害
player.bigdamage(player.ptr_this, 100);
}

// 检测是否按下End键,如果按下则退出循环
if (GetAsyncKeyState(VK_END) & 1)
{
break;
}
}

// 释放DLL并退出线程
FreeLibraryAndExitThread(hModule, 0);
return 0;
}

展示效果图:

S5

好了现在你已经掌握了此技术,可以随意调用自己所需要的程序函数了。这种技术最主要的还是以分析为主,找出函数的调用约定,参数,返回值就能任意调用了。文章当中有需要更改的部分请在评论区指出或者联系我个人邮箱进行反馈,谢谢观看!