前言
Cheat Engine是一款功能强大的内存修改工具,广泛应用于游戏作弊、调试和逆向工程。它允许用户通过扫描和修改程序的内存,以实现改变游戏数据、调整游戏参数等目的。其友好的操作界面和强大的功能吸引了大量开发者和玩家的关注,是游戏内存修改领域最知名的工具。
可惜,树大招风,尽管Cheat Engine功能强大,但其因名声过大,使得用户在使用过程中常常面临被检测到的风险。许多反作弊系统都会监测Cheat Engine进程的存在,其中大部分都是针对其进程特征的检测,一旦发现当前电脑上有这个进程正在运行,则会立刻主动退出。
因为Cheat Engine是开源软件,所有大部分人都会选择自己魔改后编译一个新版本,期望能绕过特征检测,可惜你修改掉一个特征,人家可能就去抓另一个特征,这逐渐变成了一场猫抓老鼠的游戏。
为了彻底解决针对Cheat Engine程序的检测问题,我尝试将Cheat Engine的架构在Windows上修改为客户端/服务器(C/S)模式,就和它在Linux和Android平台上一样。在这一新架构中,用户在一台干净的机器上运行Cheat Engine客户端,通过其友好的用户界面进行内存搜索和修改。
与此同时,真正的内存读写操作则由另一台目标机器上运行的Cheat Engine服务器完成,其修改内存的方法可能是内核挂,或者更为隐蔽的物理挂。这种设计不仅使得用户操作更加灵活,还有效降低了Cheat Engine程序被检测的风险。
C/S架构
Cheat Engine在Linux/Android平台上,本身就支持C/S架构,它在目标机器上运行的是一个叫做 ceserver
的服务器,由它来读写目标进程的内存,而在另外一台机器上运行Cheat Engine客户端本身,这样设计类似于 gdb 的远程调试,也非常适合像Android这种小屏的移动平台。
而我们将直接利用其自带的这个C/S架构,只是将其引入到Windows平台,所做的工作也只需要在Windows上开发一个支持同样API接口的 ceserver
服务器即可,而对于Cheat Engine客户端本身将完全不用进行任何修改,一来是这样将完美的在目标机器上彻底隐藏该进程的特征,二来是Cheat Engine虽然是开源的,但是它是用 Pascal
这种上古语言编写的,而我实在不愿意去阅读这样的代码。
如上图的架构所示,用户在完全独立的机器上使用Cheat Engine客户端进行操作,不会被任何人检测到该进程的存在。而在另一台机器上,我们新建一个服务器来响应Cheat Engine客户端的API请求,并且用内核驱动去读写目标进程的内存。
需要特别说明的是:我们这里只解决针对Cheat Engine进程特征的检测,而针对内存读写的检测还有多种手段,绕过这些检测不在本文的讨论范围内。 你可以自行用各种手段去绕开读写内存的检测,这里只是希望能充分复用Cheat Engine非常好用的GUI而已。
ceserver
为了方便,我们这里直接用 libuv
在windows平台上搭建一个简单的 TCP 服务器,监听到 Cheat Engine 的默认端口 52736
即可,但是实际使用的时候,还是建议更换其他端口,以提高隐秘性:
loop_ = uv_default_loop();uv_tcp_init(loop_, &server_);server_.data = this;structsockaddr_in addr;uv_ip4_addr("0.0.0.0", port_, &addr);uv_tcp_bind(&server_, (conststruct sockaddr*)&addr, 0);uv_listen((uv_stream_t*)&server_, DEFAULT_BACKLOG, on_new_connection);DEBUG("ceserver listen at: %d\n", port_);
Cheat Engine客户端在启动后,通过选择【进程列表】,然后点击【Network】,则会弹出一个【连接服务器】的窗口,在其中添加我们服务器的IP和端口号即可建立连接。
通讯协议
Cheat Engine客户端和服务器的通讯协议比较简单,或者可以称得上是简陋,不太像一些正规的C/S的通讯协议,它的协议里面没有包括消息ID和消息长度的消息头,也没有版本前后兼容的机制,而只是简单的将内存中的结构体作为二进制发来发去,它假设了两端是预先约定好的,包括大小端一致,版本一致等。
1 byte n-bytes+--------+------------------+| CMD | Payload...+--------+------------------+
这个通讯协议最大的问题是,它的协议头里面只有一个字节的 CMD
字段,用来表示消息的类型,接下来的数据就是这个消息的 Payload。Payload 实际上是将 C 语言的结构体直接按照字段类型进行序列化和反序列化。
然而,由于在消息头中没有明确每条消息的长度,这导致两端通讯有很多非常严格的前提条件:
-
1. 客户端和服务器的版本必须完全一致。 -
2. 服务器必须支持客户端发送的所有消息类型。
一旦其中任何一条不满足,例如版本不一致、消息的结构体发生变化,或者客户端向服务器发送了一条不支持的消息,都会导致客户端卡死。这都是因为没有消息长度,两端完全不知道对方的某条消息是否发送完毕,因此一旦版本不一致,另一端就会完全无法正常工作。
我猜测这种设计的原因在于,这个 C/S 结构本来就不是 Cheat Engine 最开始的主要的设计目标。在实现这个 C/S 的通讯协议时,可能只是希望快速实现这个功能以兼容 Linux/Android 等平台,而并未将其视为重点进行深思熟虑,因此设计得过于简单粗暴。
还有另一个问题是,Cheat Engine 官方目前对于开源的态度变得暧昧。他们官网上已经在今年推出了最新的 7.6 版本,但在 GitHub 代码库中却迟迟未同步开源代码。其作者在 Issue 中的回复说是因为某些人在没有 License 授权的情况下使用了他的代码,使得他暂时并不愿意将新版本的代码提交到公共库中,可能将来会开源,也可能不会。
结合这两点,我们的 C/S 架构修改方案就存在一个很大的风险:要想让自己写的服务器与最新版的 Cheat Engine 通讯,就必须获得新版 Cheat Engine 客户端和服务器完整的通信协议。只要稍微存在版本差异导致协议变化,就可能无法正常工作。
针对这个问题我们可以暂时放在后面观察,也许作者将来想通了会将新版本的代码同步到 GitHub;当然,即便他不这样做,我们依然可以通过抓包和逆向分析,并结合之前旧版本的源码进行同步更新,问题不大。
下面的我们首先需要实现的一些重要的消息类型:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NOTE: 其他还有很多消息类型,这里我们就不一一列举了,实际测试只要实现了上表中的这些协议,基本上可以正常使用Cheat Engine客户端了。
获取版本号
获取服务器版本号,这个版本号会跨越Cheat Engine的客户端版本号,即多个不同的Cheat Engine版本,这个通讯协议的版本号可能是相同的,目前来看 7.4/7.5/7.6 都是一样的版本号(我甚至怀疑作者是不是真的会严格的按照协议的变化来自增这个版本号)。
客户端请求协议包:
1 byte 0-bytes+--------+------------------+| CMD | 无Payload...+--------+------------------+
服务器响应协议包:
4 bytes 1 byte n-bytes+---------+--------+---------------+| version | strlen | versionstring |+---------+--------+---------------+
其中 Cheat Engine 7.6 服务器对应的版本号为:
-
• version: 6
-
• versionstring: CHEATENGINE Network 2.3
-
• strlen: 为versionstring字符串的长度(不包括结尾的 \0
)
枚举进程列表
Cheat Engine是在选择进程窗口来选择连接远程服务器的,因此它在连接上服务器后,第一件事情就是来请求远程服务器上的进程列表,它在收到这个进程列表后,会立即刷新到界面,提供给用户选择其中的一个进程开启新会话。
这个协议比较奇怪的地方就是,当遇到列表型的数据类型时,客户端并不要求服务器一次性将列表的数据返回,而是将服务器的API接口视为和本地Windows API函数类似的风格,首先它会调用一个接口来获取到该列表数据的句柄,然后用遍历数组的形式,一条条的向我们索取下一条数据,直到数组的结束,类似的客户端代码如下:
// 打开列表查询句柄const HANDLE hProcess = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);// 获取列表的第一项元素Process32First(hProcess, &pe32);// 遍历列表中的每一项元素while (Process32Next(hProcess, &pe32)) {// ...}// 关闭句柄CloseHandle(hProcess);
而我们服务器需要去将上面客户端代码中的调用的每个函数都分别实现成一个独立的API接口,例如对应关系为:
|
|
|
|
|
|
|
|
|
|
我们服务器对于这些API接口的具体实现也比较简单,直接在用户态调用对应的Windows API,或者在内核驱动程序中调用对应的系统API都可以。
唯一需要注意的是,当客户端遍历到列表的结尾后,我们需要额外再发送一条空的元素来告诉它没有更多数据了。
枚举模块列表
当用户在Cheat Engine的进程列表窗口选择了其中一个进程以后,客户端就会向我们来索取该进程内的模块列表,和进程列表的接口类似,它也是要求先返回一个句柄,然后一个个的模块来进行遍历。
但是和进程列表不同的是,模块列表除了上面这种方式以外,它还做了另一个获取模块列表的版本,就是一次性要求我们返回所有的模块列表,而无需一条条的发送。
if ((flags & CE_TH32CS_SNAPMODULE) == CE_TH32CS_SNAPMODULE) { // 获取指定进程的模块列表 std::vector<ModuleInfo> modules = GetModules(procId); // 将模块列表一次性发送给客户端 ByteBuffer* buf = newByteBuffer(buf_size); size_t i = 0; for (auto& entry : entries) {// 模块地址 buf->Write((char*)&entry, sizeof(entry), offset, nullptr);// 模块名称if (modules[i].name.size()) { buf->Write(modules[i].name.data(), modules[i].name.size(), offset, nullptr); } ++i; } // 最后需要一个空元素标记结束 CeModuleEntry empty{ 0, 0, 0, 0, 0, 0 }; buf->Write((char*)&empty, sizeof(empty), offset, nullptr); Send(client, buf);}
NOTE:在 7.6 的客户端中暂未使用这种方式来请求数据,依然使用的是和进程列表一样的句柄模式。
虚拟内存映射表
在获取到模块列表后,Cheat Engine客户端就可以开始搜索内存里面的数据了,在搜索内存之前,客户端会先向我们请求该进程完整的虚拟内存映射表,它后面的内存搜索都会基于我们返回的这份映射表,并根据用户的输入不断在这些虚拟内存空间范围内进行搜索。
例如目标进程的虚拟布局如下:
首先我们的服务器需要把这份虚拟内存映射表完整的发给客户端,当开始搜索内存的时候,Cheat Engine默认情况下是快速扫描模式,并且只扫描内存访问权限为可读可写的内存区域,因为它的目的是在搜索后去修改内存。
特别要注意的是:我们返回的虚拟内存区域一定要都是可以被访问的,访问这些虚拟内存地址不会引起内存访问错误,因为一旦我们向客户端返回了错误的内存区域范围,那么在下一步搜索这些内存区域的时候就可能导致宕机。
搜索内存
Cheat Engine客户端在获得到完整的虚拟内存映射表后,用户就可以在其界面上搜索需要的值,当点击【New Scan】按钮的时候,客户端就会向我们服务器发送读取内存数据的API请求,它默认是按照 512 K
的缓冲区不断来向我们请求并遍历内存区域,读取内存的接口和Windows API类似,例如:
BOOL ReadProcessMemory( HANDLE hProcess, // 进程句柄uint64_t lpBaseAddress, // 内存地址size_t nSize, // 内存大小);
我们的服务器需要将读到的内存数据发还给客户端:
structMemoryData {size_t size; // 内存大小char* data; // 内存数据}
这里需要特别说明两点:
-
1. Cheat Engine 不知道什么原因,在向我们请求读取内存时,传过来的内存地址有时是 0
,我们必须对这种情况进行容错。 -
2. Cheat Engine 7.6版本可能存在某些 BUG ,当内存块的区域不是 512 K 的倍数时,其最后余下来一段内存大小总是会比实际的内存块多那么一点儿,造成内存访问越界,我们也需要对这种情况增加容错。
修改内存
用户使用Cheat Engine的一般工作流程是:先通过反复的搜索内存值,找到精准的内存修改点,然后用它对目标进程的这一小段内存进行篡改,此时客户端就会向我们的服务器发送修改这块内存的API请求,我们的服务器则需要对指定内存进行修改:
// 客户端请求值:// 进程句柄uint32_t handle = reader.ReadU32();// 需要修改的内存地址uint64_t address = reader.ReadU64();// 需要修改的内存大小uint32_t size = reader.ReadU32();// 需要修改的内存数据constchar* data = reader.GetPtr();// 我们使用内核驱动篡改目标进程的内存uint32_t bytes_written = memory->WriteBytes(address, (char*)data, size);// 服务端响应值:// 将写入了多少个字节返回给客户端Send((char*)&bytes_written, sizeof(bytes_written), 0, nullptr);
修改内存成功后,Cheat Engine会再次自动扫描内存,用户在其界面上可观察到该内存已经被成功修改:
结语
本文探讨了如何将Cheat Engine在Windows平台从传统的单机架构改为基于客户端/服务器(C/S)架构的实现方式,旨在绕过反作弊系统的监测,尤其是针对其开源软件进程特征的检测。
通过引入C/S架构,Cheat Engine客户端和服务器可以分别运行在不同的机器上,从而将内存修改操作与客户端的运行环境分开,降低被检测的风险。
而真正对内存修改的手段也有很多种,例如市面上常见的内核挂,或者物理挂等,八仙过海各显神通,在此并未对此领域进行深入探讨。
因为进程检测自身内存是否被篡改的手段,同样也是多种多样,不管是使用内核挂还是物理挂对目标进程的内存进行篡改,都依然存在被检测到的概率,正所谓魔高一尺道高一丈。
当然,不管怎么说,我们至少从零开始快速手写了一个简易服务器,实现了和 Cheat Engine 客户端进行通讯,并使其可以正常工作,借由此过程,也从另一个角度探索了其内部的大致实现原理。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论