游戏修改器 - 自己实现DLL注入
烟汐忆梦_YM
2024年09月19日 23:39

大家在玩游戏的时候有没有开过修改器呢?

在玩游戏打不过Boss/攒资源太麻烦/...的时候,开修改器直接达成目标真的很爽

不过,我们好奇一个问题, 游戏修改器是如何修改游戏的?

作为一名游戏开发者, 我也不希望玩家随意地修改数据, 可是游戏运行在玩家电脑上, 如果仅仅是单机游戏, 游戏数据必然保存在游戏玩家本机的电脑内

我们的游戏程序, 都是一个进程(Process), 游戏运行的数据都存储在用户的内存(Memory)中

修改器一般都是直接修改程序的内存, 达到锁血/修改金钱等目标

但是, 我们的操作系统一定不会轻易让我们能够篡改其他进程的内存, 谁都可以修改数据, 岂不是乱了套了

操作系统在启动一个程序时, 首先需要将程序的可执行文件加载到计算机内存中去

然后启动一个进程, 为这个进程分配一个唯一的进程ID, 随后为这个进程分配一个虚拟内存(Virtual Memory), 为了避免访问冲突这种问题, 虚拟内存一般被进程独自占用

任何程序操作的内存一般都是操作系统为其分配的虚拟内存, 因此其他程序无法直接操作进程的虚拟内存

既然只有自己才能操作自己的虚拟内存, 我们该怎么修改游戏程序中内存的数据呢?

DLL注入

DLL注入, 实际上就是将DLL文件(Windows动态链接库)注入到目标程序的远程内存地址空间, 我们编写自己的DLL程序, 随后我们将这个编写好的DLL用另一个程序(注入器)注入到其他程序

这样, 被注入的DLL文件就被视为了目标程序的一部分, 就理所应当的可以操作内存地址空间了

DLL(Dynamic Link Libraries)

动态链接库实际上是一个模块, 其中包含一些数据和代码函数, 应用程序可以在运行时加载动态链接库, 执行动态链接库里面的代码, 实现数据与代码的重用

例如, 我有10个程序, 这10个程序都需要实现一个计算点与点距离的函数, 一般情况下我们可能会直接在程序中编写特定的代码, 每个程序都编写一个, 这样我们的所有程序都包含了重复的代码

如果是简单的代码还好说, 在编写复杂程序时, 我们一定都不希望重复编写复杂的逻辑, 况且同一套代码(或数据资源)重复了很多次, 对于硬盘空间也是一种浪费

我们何不将重复的代码打包为一个可以外部加载的代码模块, 随用随加载, 所有的程序都可以加载同一个模块, 这样, 用户只需要拥有一个这样的模块文件, 所有依赖此模块的程序都不需要重复包含这个模块具有的代码了

所以DLL就是为了解决这种问题而出现的

我们将负责篡改数据的恶意代码写入DLL中, 再由注入器注入到目标程序, 既然DLL被视作目标程序进程的一部分, 就可以对进程的虚拟内存进行修改

CE修改器(Cheat Engine)

CE修改器实际上就是为了方便玩家修改游戏数据的一个软件, 实际上CE就是通过DLL注入实现修改数据的

我们在编写游戏修改器时, 首先就要知道我们到底要修改哪个地方的内存

计算机中的内存一般都有一个内存地址, 以字节为单位, 我们必须得知哪个地方的数据才是我们希望修改的数据

CE修改器通过扫描游戏进程的内存, 找到符合要求的数据, 随后修改内存的值, 也就是暴力地对目标程序的虚拟内存进行扫描

如果是符合要求的数据, CE就会将数据添加进一个列表, 当然, 由于内存中数据众多,程序中难免会有很多符合要求的数据, 在接下来, 我们就需要改变一下我们希望修改的目标参数值, CE再次根据目标参数值, 搜索这个列表, 经过一层层筛查, 就可以得知这个目标数据的内存地址了

下载好CE后, 打开会出现这样的界面, 点击右上角红色框, 可以选择要搜索的目标程序, 我们这里拿植物大战僵尸作为演示

我们指定目标进程为Plants vs. Zombies, 也就是植物大战僵尸的进程名称

我们看到我们目前游戏的阳光总量为50, 我们首先猜测植物大战僵尸的开发团队会使用int整型存储阳光值, 所以我们可以在CE右侧找到 数据类型 下拉菜单, 由于int类型一般占用4字节, 我们将其设置为 4字节

且我们知道阳光具体的数量, 所以扫描类型可以选择 精确数值

如果我们的目标数值不确定, 可以使用其他的扫描方式, 如大于.../小于.../等等

最后在数值框中填写阳光的数量值50, 点击 首次扫描

大家可以看到左侧列表出现了很多结果, 有1066个, 其中有些绿色的地址是 内存基址 是程序运行时不会改变的地址

由于我们要确定数值, 因此要对这一千多个结果进行筛选

我们点击老虎机消耗一些阳光, 让目标数值发生变化

我们点击老虎机抽奖, 消耗了25阳光并什么都没获得, 随后在CE修改器中填入现在的阳光新值, 随后点击 再次扫描 按钮

再次扫描会根据已经扫描后的地址列表进一步检查, 我们这次很成功, 直接筛选出了一个地址, 我们将其选中并在右键菜单中选择 将选中地址添加进地址列表, 也可以双击添加

随后我们点击数值栏, 对值进行修改, 看看游戏页面的阳光数量是否也发生改变

将阳光数量修改至9999

点击确定后, 我们发现有游戏的阳光数量确实发生了改变

这个地址, 就是游戏中存放阳光数据的地址, 不过, 要注意的是这个地址并不是固定的, 每次启动游戏, 这个地址都会发生改变

因为分配内存通常是不固定的, 我们可以在任何时候声明一个变量, 变量总要有一个内存地址

这个地址通常是由内存基址经过偏移得到的, 内存基址是程序不变的, 我们只需要找到内存基址, 然后得知地址运算方式就可以推断出这个阳光数据的地址是如何被计算出来的

这种操作叫内存基址寻址, 通过将一个基址与一个偏移量相加来确定内存中的特定位置

我们需要找到内存的基址, 随后我们还需要得知地址的偏移量

那我们该如何得知内存基址与偏移呢?

查找内存基址与地址偏移量

我们可以选中地址栏中的阳光数据地址, 随后右键打开菜单, 选择 找出是什么访问了这个地址 这个选项, 选择后, CE会监控这片内存, 每当这片内存被读取时, 就会被记录

我们可以看到访问这片内存的汇编指令, 其中的计数不断在累加, 说明这片地址正在被读取

程序在计算内存地址时需要将源数据与目标数据放入寄存器中, 其中MOV指令就是用来将内存中的某一地址数据放入某一寄存器中的

而ADD指令负责对某个寄存器中的数据进行相加

我们要得知内存的计算方式, 我们可以看到只有一条ADD命令负责计算相加操作, 计算机在对内存寻址时, 都要计算一遍内存的地址, 所以大家仔细观察可以发现这条ADD命令计数增长的很快

我们选中这条ADD命令, 打开详细信息页面

汇编ADD命令的格式是 add A B, 即计算A与B的相加

而eax与edx都是寄存器名称, 所以这条命令实际上是将 寄存器eax 中的数值与 [edx+00005578]这个值相加, 而[edx+00005578]实际上就类似一个指针, 指向内存的一处地址, [edx+00005578]实际上就是将寄存器中存储的值与00005578这个数字进行相加

分析这条命令的作用就是将edx这个寄存器中存储的地址以00005578进行偏移

最后将其赋值给eax寄存器, 我们可以发现CE有一段文字, 提示我们要查找的该地址的指针数值可能是 138BA918, 经过观察可以发现, 我们的edx寄存器的值就是138BA918, 这是一个4字节16进制数, 我们打开计算器的程序员模式计算16进制相加计算, 将edx的值138BA918加上偏移量00005578, 结果正好就是阳光数据的内存地址 138BFE90

所以我们得知, 阳光数据地址是由 138BA918这个地址加上偏移量00005578得来的, 不过还没完, 138BA918这个地址被存放于寄存器, 它的值并不是内存基址

这个地址应该是由其他地址经过进一步偏移得到的

我们记住偏移量与地址138BA918, 打开CE, 我们要计算138BA918这个16进制数据是如何被计算出来的

打开CE, 在数值框前打开 十六进制 选择框, 输入138BA918这串16进制数, 而由于138BA918是一个4字节16进制数(十六进制数每2位数为一个字节)因此使用的数值类型还是4字节

最后点击 新的扫描 进行一次新的内存扫描

大家可以看到由很多内存都包含了这个值,我们多次点击再次扫描过滤掉一些发生变化的值, 随后, 选择第一个地址

有时候我们会遇到这种指令, push 指令与 pop指令是对堆栈的操作, 负责将数据压入/弹出堆栈, 我们显然不需要这种指令, 因此这个地址不是正确的计算地址

我们要寻找的地址通常有指针偏移操作, 也就是[xxx + xxx]这种对寄存器或数据地址进行偏移的命令, 也要对其进行mov赋值操作, 最后要符合游戏每一帧都刷新(计数不断增加)

在找了几个地址后, 我们发现了一个符合条件的地址

我们找到mov指令, 点击查看详细信息

我们可以看到mov指令将 寄存器edi存储的值以0000868偏移后的地址指针赋值给了esi寄存器

这里由于UP重启了一下游戏,所以导致地址结果变了,现在阳光一段寻址的值是1390D920,源地址变为13912E98, 这也证明了内存地址不是固定的

esi寄存器的值就正好是我们一段寻址刚刚查到的值, 我们看esi寄存器中的值, CE提示我们要查找的地址可能是 03C6AC18 正好就是寄存器esi的值

我们记录偏移量与内存地址03C6AC18 , 再重新搜索

这次我们看到, 出现了4个绿色的名字的地址, 这就是我们的内存基址

我们将基址添加进底部的地址栏, 按F5观察访问地址的指令

有些基址是没有任何指令对其访问的, 这种我们可以排除掉

我们最后找到第一,第二个地址都符合要求

我们记下符合要求的地址(00731C50与00731CDC), 现在我们可以最后确认, 阳光地址是由内存基地址经过两次偏移所得到的, 第一次偏移的值是0000868,第二次偏移的值是00005578

在CE右下角点击手动添加地址, 选择指针, 因为我们计算的实际上是一个内存指针的地址, 并添加两个偏移, 即上几次我们检查到的偏移值

在底部地址添加基址的地址, 最后计算的结果为13912E98, 正好是我们阳光的内存地址

因此, 阳光数据地址的基地址的值就是00731C50

阳光地址计算的公式就是 [[00731C50] + 868] + 5578

编写DLL程序

我们接下来就可以编写DLL程序, 对地址进行修改

启动VS, 新建一个C++解决方案

随后我们点击解决方案,并新建一个C++空项目,名为DLLProgram

点击添加 -> 新建项目

新建一个项目, 并将其配置类型修改为DLL程序

右键项目, 选择属性, 在 配置属性 -> 常规 中设置项目的配置类型为 动态链接库(.dll)

随后新建一个源文件,名为DLLMain, 并编写一个DLL主函数

具体逻辑如果有C++基础知识的话应该很容易理解, 可以观看每一步的注释

DLL的主函数为 BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)

各位可以直接创建一个DLL项目, 默认会自带主函数

计算后的 data_ptr 就是阳光数据的指针

我们将DLLProgram项目设置为启动项目, 运行后看看是否生成dll文件运行结束后, 可以找到生成的DLL文件

在配置管理器中, 我们还要注意将DLL编译为x86,这是因为植物大战僵尸是一个32位程序, 32位程序是无法加载64位DLL的,64位程序也无法加载32位DLL

编写DLL注入器

接下来我们在原有创建的项目(DLL注入)创建一个源文件作为注入器项目

我们引用 windows.h 因为dll与windows进程的相关操作需要依赖windows.h库

首先, 我们需要找到目标程序, 植物大战僵尸的窗口名称为 "Plants vs. Zombies", Windows需要一个宽字符串

使用FondWindow函数查找窗口, 它要求两个参数, 第一个参数为窗口类名, 如果填写NULL, 则会查找匹配第二个参数名称的窗口

FondWindow 函数返回一个窗口句柄 HWND, 即查找的目标窗口

如果返回值为NULL则窗口不存在

不过窗口ID并不是我们的植物大战僵尸程序的进程ID, 我们通过GetWindowThreadProcessId函数通过一个窗口句柄返回一个进程句柄

这个函数的第一个参数要求一个窗口句柄, 第二个参数要求一个进程ID的引用, 其类型是一个无符号整型

调用函数后, processID就是我们窗口的进程ID, 如果ID为0, 则进程不存在

我们如果要对当前进程进行访问, 必须要得到一个进程IO句柄

进程IO句柄是当前进程对于IO句柄目标进程的一个偏移量, Windows维护了一个指针列表, 这些指针指向了Windows所管理的不同对象的地址, 而句柄就是Windows句柄表的编号

我们通过OpenProcess函数打开一个程序, 返回一个HANDLE句柄

OpenProcess函数要求的第一个参数是针对进程的安全描述符检查此访问权限

具体权限宏的名字可以在微软开发文档中看到

PROCESS_ALL_ACCESS 即所有访问权限

OpenProcess的第二个参数, 是一个布尔值参数, 如果此值为 TRUE,则此进程创建的进程将继承句柄, 否则, 进程不会继承此句柄

我们填入FALSE, 不需要继承句柄

第三个参数就是一个进程ID, 即我们要打开的进程的ID

如果无法获取进程IO句柄, 则返回NULL, HANDLE实际是一个void*

接下来我们就可以根据进程IO句柄创建一个远程虚拟内存了

我们要运行LoadLibraryW 这个函数, 也就是加载DLL, 我们让目标程序执行LoadLibraryW,让目标程序自己将DLL加载进自身内存

我们的目的是开辟一个远程线程, 和一个远程虚拟内存堆栈, 以LoadLibraryW作为远程线程运行, 其函数需要的参数就存放在这个远程内存堆栈中

所以我们要求一个DLL文件的相对路径, 以及这个路径字符串所占用的内存大小, 我们开辟内存不能小于这个大小

dllPath 是一个宽字符串, 存放DLL文件的相对于目标程序可执行文件的路径地址

dllPathSize 就是存储我们宽字符串 dllPath的大小, 通过wcslen函数计算宽字符串的字符数量, 然后加1, 这里的加1实际上就是把字符串后面的'\0'算进去

因为C++字符串char*通常以0结尾, 最后乘以wchar_t宽字符的大小, 得到整个字符串所占用的字节数

我们使用 VirtualAllocEx 函数开辟一个大小为dllPathSize的内存

VirtualAllocEx 函数要求5个参数, 第一个参数是进程的句柄, 这里填我们刚刚获取到的processHandle

第二个参数是要分配的页面区域指定所需起始地址的指针, 如果为NULL, 将自动确定分配区域的位置

第三个参数是开辟内存的大小, 以防万一我们填写 dllPathSize + 1

第四个参数是内存分配的类型, 它的值必须为下列值之一

我们填写MEM_COMMIT

第五个参数指要分配的页面区域的内存保护, 如果需要动态分配内存, 这个参数必须 PAGE_READWRITE 或 PAGE_EXECUTE_READWRITE

我们这填入PAGE_EXECUTE_READWRITE

如果函数成功开辟远程内存, 则返回值是页面分配区域的基址, 否则返回NULL

至此, 我们开辟了远程虚拟内存, 接下来就要将 dllPath 写入到这块内存中去

我们通过 WriteProcessMemory 函数将dllPath写入远程内存, 这个函数返回一个int整数,如果该函数成功,则返回值为非零值

WriteProcessMemory 函数需要5个参数

第一个参数表示进程的句柄, 第二个参数为远程虚拟内存区域的地址, 这里我们填写VirtualAllocEx返回的虚拟内存指针

第三个参数为写入数据的指针, 我们填入dllPath

第四个参数为写入数据的大小, 我们填入计算好的dllPathSize

第五个参数为指向变量的指针引用, 该变量接收传输到指定进程的字节数, 此参数是可选的, 我们并不需要得知实际写入了多少内存, 因此填入NULL

接下来, 我们就要创建一个远程线程, 并以这个远程虚拟内存作为参数栈

我们调用CreateRemoteThread函数创建一个远程线程, 函数需要7个参数

第一个参数为目标进程的句柄

第二个参数为一个指向 SECURITY_ATTRIBUTES 结构的指针,表示一个安全描述符ULL, 则线程将获取默认安全描述符

第三个参数为远程线程堆栈的初始大小, 如果此参数为 0, 则线程将使用可执行文件的默认大小, 我们保持默认

第四个参数是线程执行的应用程序定义函数的指针, 类型为PTHREAD_START_ROUTINE, 表示远程进程中线程的起始地址, 我们将LoadLibraryW函数地址转换为PTHREAD_START_ROUTINE并传递进去

第五个参数就是指向要传递给线程函数的变量的指针, 即远程内存地址

第六个参数是指控制线程创建的标志

我们需要让远程线程立即运行, 因此填入0

最后一个参数是输出一个指向接收线程标识符的变量的指针引用, 我们不需要获取, 因此填入NULL

在程序的最后, 我们调用WaitForSingleObject等待远程线程退出信号, 当远程线程退出, 就调用VirtualFreeEx释放远程虚拟内存, 并调用CloseHandle关闭获取到的进程句柄与我们创建的远程线程句柄

我们最后进入解决方案属性中, 将DLLProgram项目添加为DLL注入器程序的依赖项, 这样, DLL程序在启动时也会编译

我们运行项目, 将项目生成的dll文件拖动到植物大战僵尸的可执行文件根目录, 让LoadLibraryW函数能够找到DLL文件

最后, 先启动游戏, 打开游戏关卡, 再启动注入器程序, 可以看到阳光被成功修改了!