简单的游戏修改器实现

Catalpa 网络安全爱好者

如何实现一个最简单的游戏修改器?

基础知识

闲来无事简单学习一下游戏修改器的原理。

修改器大致分为两种,一种是外部的,即修改器进程独立于游戏进程之外运行,通过某些系统 API 实现对游戏的修改。另一种是内部的,修改器通过特殊手段把自己注入游戏进程中,直接访问和操作游戏数据。

两种方式各有优缺点,外部修改器比较稳定,无需对游戏的内存数据进行大范围变动,但是其使用 API 和游戏进行通信,效率较低,响应速度较慢。

内部修改器需要将代码注入到游戏进程中,注入后以新的线程方式直接存取内存数据,在效率上更好,响应速度更快,但是对游戏内存有较大的改动,不是很稳定。不过大部分情况下两者差别不大,无论哪种技术,只要能够达成修改的目的即可。

修改器的基本思路:

  1. 找到想要修改的数据
  2. 修改
  3. 查看结果

如何寻找想要修改的数据?

游戏也是软件,我们想要修改的数据一定位于内存的某个地址中。

工具:Cheat Engine 或其他内存搜索 & 修改工具

游戏中所有参数都有其对应的数据结构,例如血量可能用 int,移动速度可能用 double 等。并且这些数据不一定要准确的显示在屏幕上,很多游戏中使用了进度条和其他形式来表示有关数据,血量有血条,移动速度只能感知出来等等。要想找到这些数据,需要用到很多不同的方法。其中最为常用的就是未知初始值 & 增加/减小值搜索。

CE 自带很多搜索数据的方法,例如具有血条的 BOSS,就可以先搜索未知的初始值,然后攻击减少血量,再搜索减小的值,一步步找到真正的数据。对于体量较大的游戏,采用了大型引擎开发,那么相关数据结构会比较混乱,单纯使用 CE 很难定位实际数据的地址。此时需要结合逆向分析,找到相关程序代码才能顺利定位数据位置。

举个例子

比较简单的游戏定位数据。这里用到的样本是 AssaultCube,是基于 Cube 引擎开发的开源第一人称射击游戏,此游戏内部数据结构比较简单,我们先拿它开刀。

打开游戏,默认会来到训练模式,此时需要在选项菜单中选择单人模式。(原因是训练模式和正常游戏所用到的数据很可能不是同一套)

进入游戏我们可以看到人物的基本信息。

基本信息包括血量、枪械子弹数量。简单游玩发现角色还可以装备防弹衣,也就是存在护甲值,另外还有手榴弹等投掷武器的存在。

CE 搜索数据非常简单,只需要附加到进程,然后搜索确切的数值,接着在游戏中修改数值,反复搜索,直到剩下的地址条目很少,基本就定位到了正确的数据。

接着可以直接进行修改查看效果,关于 CE 查找游戏数据有很多详细的教程,推荐先完成 CE 自带的教程程序,这样能对它有一个全面的了解。

我们可以做一个小实验,记录下第一次找到的数据所在地址,然后退出游戏,重新打开游戏并找到相同的数据所在地址,你会发现两次得到的数据地址是不同的,原因是现代操作系统都存在 ASLR,即地址布局随机化技术,程序每次运行的时候都会被加载到不同的地址空间,这样能够缓解一些漏洞对于系统的损害。

但是这给我们修改游戏数据带来了一些困难,虽然使用 CE 能够定位到游戏数据,但是每次重新启动游戏都需要按照相同的步骤重新找到数据所在地址,有没有什么好方法能够一劳永逸呢?

答案就是静态指针,现代游戏一般体量都很大,内部逻辑也非常复杂,游戏可能被分为几十个模块,为了方便模块之间相互访问数据,一般都会存在全局静态指针指向某些关键数据,例如游戏主角。

静态数据虽然也会受到 ASLR 的影响,但它有一个很重要的特点,相对模块基址偏移固定,程序每次加载之后我们可以通过技术手段确定每个模块的地址,因此可以根据固定的偏移量找到对应的静态指针。

一般来说,我们能够找到的游戏数据都会有一个静态指针与之对应,这是面向对象开发模式的特性,开发者通常会将游戏中的角色、道具编写成类,类中包含和此对象有关的数据,例如主角有血量和护甲值,开发者很可能会将它们写在同一个类中,因此我们找到血量,那么剩余数据基本就分布在血量内存附近。

修改器开发实例

我们以挺进地牢这款游戏为例,简单演示一下修改器的基本开发技巧。

首先,游戏修改器通常使用 C++ 来开发,这是由于 C++ 可以像 C 语言一样直接使用 Windows 提供的 API,但在 C 语言基础上又多出了面向对象的概念。C# 通常也可以用于修改器开发,但是 C# 想要调用底层 API 需要使用委托等较复杂的技术,另外 C# 中没有了指针的概念,某些操作上可能没有 C++ 灵活。

修改器需要实现的基本功能:

  1. 能够附加到目标进程
  2. 能够读取目标进程指定内存地址的数据
  3. 能够写入目标进程指定内存地址的数据
  4. 如果是内部修改器,需要将自身代码注入到目标进程中
  5. 绕过反作弊?

选择人物,进入地牢之后可以看到角色拥有几个基本属性:

分别是血量、空响弹(游戏中的一种特殊道具)、钥匙以及金钱。首先查找血量所在的地址,简单游玩之后可以发现每次受到伤害,血量会减少一半:

我们可以猜测此游戏的血量有两种保存方式,一是使用整数,即初始血量为 6,每次受伤减一。另外还可以使用浮点数形式保存,初始值为 3.0,每次受伤减少 0.5。

经过搜索,游戏的血量是 float 型保存的,即初始 3.0。

那么在 CE 中查找确切的值,float 类型,重复搜索,最后能够找到一个地址:

双击此地址可将它保存到下方表格中,双击 value 可以修改它的值,例如我们修改成 3 ,回到游戏会发现血量回满。因此这个地址保存的数值就是主角的生命值。

根据前面的介绍,这个地址很有可能只是一个动态地址,每次重新启动游戏都不同。你可以自己尝试重启游戏试验一下,实际上 CE 中自带了检测地址是否为动态地址的功能,在地址列表中所有黑色的地址都是动态的,而静态地址会以绿色表示(所谓静态地址就是相对模块偏移量固定的地址)。

现在我们找到了生命值所在的动态地址,那么如何才能找到它的静态地址呢?

最原始的方法

右键点击这个地址,选择 find what access this address 选项,CE 会自动附加一个调试器到游戏上,并且在目标地址添加一个硬件访问断点,之后所有访问了这个数据的指令都会被记录下来。

附加之后我们在游戏中故意受到伤害,会发现有几条指令访问了血量这个地址:

这些指令的访问次数各不相同,第一条指令访问非常频繁,而后面的指令只有在角色受伤之后才被加载出来。

考虑到血量在内存中只是一串数字,只有绘制到屏幕上才能给玩家直观的感受。游戏的画面每秒钟会绘制上百次,以此来形成流畅的显示效果。所以一定有某个函数负责重绘画面,在绘制过程中就要访问到相关数据。

而后面的指令很可能就和游戏逻辑有关了。例如有判断子弹和角色碰撞的代码,或是专门负责控制血量的代码等等,具体要看开发者怎么定义了。

不过所有指令都有一个规律,它们访问的地址都是 [xxx + 118] 这种形式,实际上这是指针解引用的汇编代码,xxx 寄存器中保存的是某个游戏对象的指针,在指针偏移 0x118 位置处保存着玩家的血量数据。翻译成代码应该如下

1
2
3
4
5
6
7
8
9
class Player
{
private:
// xxx 数据
float health; // 偏移 0x118 位置
// xxx 数据
public:
// xxxx
}

那么是不是 xxx 寄存器中保存的数值就是静态地址呢?我们可以选中某条指令找到寄存器中的数据,在 CE 中以 hex 形式搜索,能够找到很多结果:

这些结果仍然是动态地址,实际上这就是多级指针。

面向对象开发中通常会编写各种类,例如本游戏主角有基本的数据,主角手里拿着枪,不同的枪有不同的属性,另外还可以拾取不同的道具等,表示玩家的类中很可能包含着其他对象的实例,用伪代码表示如下

1
2
3
4
5
6
7
class Player
{
private:
HealthController healthPtr;
GunController gun;
// xxx
}

这就导致玩家对象中又存在着指向其他对象的指针,而其他对象中又会存在指向另外一些数据的指针

源代码经过编译器编译之后就会出现很多 [xxx + xxx] 形式的指针解引用,通常寄存器中保存的是某个对象的起始地址,加上偏移量就得到了确切的数据。

所谓最原始的方法就是一级一级的向上寻找指针,例如我们通过血量得到了某个对象的基址,那么可以确定肯定有另一个对象保存了这个基址指针,所以再次搜索这个基址,找到了一大堆地址,这些地址很可能属于其他对象,还需要重复执行 find what access this address 找到什么指令访问了这个地址,从而找到另外一个对象的基址…(CE 教程中有多级指针的章节,可自行了解)

体积比较小的游戏最多可能会存在 6、7 级指针,体积很大的游戏可能存在十几层指针,这种手动寻找方式效率太低,而且找到的地址很可能没办法稳定使用。

聪明的方法

寻找多级指针的原理基本可概括为:

  1. 找到目标数据所在的对象基址(对象1)
  2. 找到对象1所在的对象的基址(对象2)
  3. 如此循环直到找到一个静态指针指向的对象地址

实际上这一过程就是查找基址和偏移量的过程,由于每次搜索对象的基址都会出现很多地址,那么可以通过记录这些地址和偏移量让程序自动寻找到所有可能的指针路线。

例如上面我们找到了 17 条地址,将他们添加到一个数组中,并记录下对应的偏移量,每次从数组中选出一个地址,找到这个地址所在对象的基址,再次搜索,并将结果添加到数组中,如此循环直到找到一个静态地址。这样就能自动找到所有和目标数据相关的静态指针,而且偏移量也被自动记录下来。

这种查找方式在 CE 中被称为指针表。右键血量地址即可看到 pointer map 选项,我们可以对血量这个地址生成一个指针表,然后选择指针扫描选项,在弹出的窗口中选择使用指针表,并设置结尾偏移量必须是 0x118:

这样 CE 就会自动列出所有符合条件的指针:

结果一共有 60 万多个,想象一下如果手动寻找需要多少精力。

不过自动查找出来的指针也不是全部有效的,我们可以看到第一列 base address 中涉及到了很多不同的模块,这 60 万个指针有很大一部分只是凑巧指向了目标地址,为了缩减指针数量,我们可以重新启动游戏,再次找到生命值所在地址,然后重复上面的操作再次生成一张指针表,结合两张表搜索指针:

这样 CE 会比对两张表的指针结果,从而去除所有不符合规则的指针。

这次搜索到 317 个指针,细心观察一下发现还有很多指针最后的数据在变动,这些指针也是无效的。我们可以选择继续重启游戏,进一步缩减指针数量,或者直接在游戏中受伤,然后在选项卡中选择 pointer scanner -> rescan memory 选项,选择 value to find 填写当前生命值,即可直接进行一次指针搜索。

无论采用哪种方法,最终应该会剩下几个指针,此时我们就可以逐个测试了。

例如我找到了一个地址 UnityPlayer.dll + 0x144EBB8,经过测试发现这个地址确实是静态的,并且每次重新启动游戏都不会变动。现在可以在 CE 中选择 Add Address Manually ,添加一个指针

此时就添加了一个指针到 CE 中,重新启动游戏也不会发生改变。

我们得到了静态指针,现在可以着手实现修改器主体了。

由于 CE 只是一个内存修改工具,虽然能够以 CT 表的形式作为修改器使用,但是多数情况下还是希望有一个可执行文件,打开之后按下对应的热键即可实现修改功能。所以利用 C++ 编写修改器是必须的。

修改器基本功能前面提到过,首先需要能够附加到进程,可以使用 OpenProcess 来实现,它返回一个进程的句柄。

读取游戏指定地址的内存可使用 ReadProcessMemory 实现,写入内存就是 WriteProcessMemory。

这里暂且不讨论内部修改器的实现方式。

剩下的都是编码问题了,我把自己写的修改器 demo 上传到 github,项目中代码都很简单,基本思路如上所诉,感兴趣的朋友可以去看看。https://github.com/rrrrrrri/MyEtGTrainer

  • Title: 简单的游戏修改器实现
  • Author: Catalpa
  • Created at : 2020-02-23 00:00:00
  • Updated at : 2024-10-17 08:48:56
  • Link: https://wzt.ac.cn/2020/02/23/game_trainer1/
  • License: This work is licensed under CC BY-NC-SA 4.0.