ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cheat Engine 6.8.1源码编译:从内存扫描到DBK驱动的完全解析

Cheat Engine 6.8.1源码编译:从内存扫描到DBK驱动的完全解析

简介:Cheat Engine 6.8.1源码是一套面向逆向工程、游戏修改与内存调试学习者的完整工程资料,可帮助深入理解CE从扫描到修改的底层实现。压缩包共1523个文件,体积约8.45MB,主要文件类型包括pas、h、c、cpp、lfm、lua等,对应Delphi核心逻辑、C/C++头文件、界面窗体定义、脚本接口与工程配置,目录结构清晰,适合按模块研读。源码重点展现了精确扫描、模糊扫描、宽范围扫描等不同查找策略的算法实现,也覆盖了内存读写、指针链追踪、Lua自定义脚本、反调试对抗等关键主题,同时附带了GUI设计代码与多工程文件,方便还原构建。对于想从零搭建调试器、分析游戏保护机制或基于CE二次开发的读者,这份源码提供了直接可用的参考案例。目前已有501人学习下载,是游戏安全研究和工具开发领域值得反复查阅的原始素材。

1. 拿到 cheat-engine 6.8.1 源码之后,先别急着编译

如果你是因为某个游戏内存数据搞不定、或者想找一个能反复修改内存布局的“调试杠杆”而盯上 cheat-engine-master 这套 6.8.1 源码,那恭喜你踩对了一半。Cheat Engine 6.8.1 是最后一个在 master 分支里还能整套编译、带 DBK 驱动源码、并且社区资料最全的版本;后面 7.0 以上更多是面向 64 位和反作弊对抗的重构,源码复杂度高不少。这套源码适合三类人:想搞懂 Windows 内存扫描算法怎么设计的、想给 CE 做二次开发加定制功能的、以及需要用驱动级读写绕过游戏保护做内存分析的。它不是一个开箱即用的“成品”,而是你往下游走的起点。我这篇文章就从目录结构、编译链路、核心模块和一堆血泪坑讲起,争取让你拿到手之后 24 小时内看到主界面。

需要先说清楚:CE 是正经的调试辅助工具,在逆向工程、软件调试、内存取证里都有合法用途。如果你是冲着破坏公平竞技环境去的,那这篇文章帮不了你,也请你别这么干。

2. 先读懂 cheat-engine-master 的目录:6.8.1 的模块划分与选型理由

2.1 顶层目录到底放了什么

把压缩包解压之后,你会看到十几个顶层文件夹。我第一次打开这堆目录时也有点懵,但按功能归一下类就很清晰了。核心的源码在Cheat Engine目录下,这是 Lazarus 工程的主目录;DBK目录是内核驱动工程,负责 Ring0 读写;Cheat Engine\C\下面是一些 C 写的辅助模块,比如lua5.1嵌入引擎的 C 源码、winsock相关组件;Cheat Engine\extrafiles放的是发布时打 zip 包的脚本和说明;Cheat Engine\Languages是多语言翻译文件。注意没有 bin 目录,也就是说你拿到的确实只是源码,需要自己编。

主程序六个最关键的目录我列一下:Cheat Engine\FindUnit是地址扫描的 Pascal 实现,Cheat Engine\CFindwhat是核心扫描对话框逻辑,Cheat Engine\KernelUtils封装了对 DBK 驱动的事件调用,Cheat Engine\ProcessList负责枚举进程、附加进程,Cheat Engine\MonoLib和MonoDisAsm负责解析 Mono/.NET 模块和反汇编显示,Cheat Engine\ImageLoader管理 PE 镜像加载。如果你只打算改按键脚本或者 UI,碰前两个目录就够;想理解内存扫描原理,CFindwhat和FindUnit是主线;想做驱动级读写,那得去DBK目录。

目录职责修改频率
Cheat EngineLazarus 主程序源码、窗体、脚本逻辑高
DBK内核驱动(DKMain.c)、IOCTL 处理低,但一改就翻车
Cheat Engine\CFindwhat扫描类型(精确值/未知初始值)、过滤流程中
Cheat Engine\KernelUtils用户态到 Ring0 的调用封装中
Cheat Engine\ProcessList进程枚举与附加逻辑低
Cheat Engine\MonoDisAsmMono 方法反汇编、IL 解析低

2.2 为什么偏偏是 6.8.1 这个版本

你可以用 git 直接切 tag,git tag能看到ce680、ce681这种标记;master 分支在 6.8.1 时代还算稳定,不像 7.x 主干开发期那样每天变 API。我实际对比过 6.8.1 和 6.8.2 的差异,两者的扫描核心几乎一样,但 6.8.1 的 Lua 脚本接口更老,很多网上流传的脚本是基于这个版本写的,兼容性好。另外 6.8.1 自带一个可用的 DBK 驱动,在 Windows 7 到 Windows 10 的早期版本上可以直接加载(需要关闭驱动签名强制或者进测试模式),而 6.8.2 的驱动没有太大变化,所以没必要追新。这里的选型理由很简单:你以后想搜问题、抄脚本、问别人踩坑经验,搜“CE 6.8.1 源码”得到的结果比搜“CE 7.0 源码”多一个量级。

2.3 源码工程的构建链先说清楚

6.8.1 用 Lazarus 做 IDE,编译器是 Free Pascal(FPC),不是 Delphi。很多人第一步就卡在安装版本上:Lazarus 2.0 以上打开这个工程会报一堆控件不兼容,建议用 Lazarus 1.6.4 + FPC 3.0.0,老版本里最匹配这个工程的就是这个组合。主工程文件在Cheat Engine\CheatEngine.lpi,不是.dproj也不是.sln。驱动工程在DBK\DBK.dproj?不对,它不是 Delphi 工程,你打开DBK\DBKD.dpr会看到一部分 Borland 风格的 Pascal,但实际编译它建议走批处理脚本而不是 IDE,因为要处理驱动签名和ntoskrnl.lib导入。这里我直接说结论:用户态主程序用 Lazarus 打开 lpi 编译,驱动用DBK\build.bat配合 WDK 编。

编译之前你还需要确认系统里有没有 Lua 的开发库,CE 把 Lua 5.1 的 C 源码直接放在Cheat Engine\C\lua5.1里,编译时 Lazarus 会自动把lua51.dll编出来,但前提是你的 FPC 配置里包含了 C 源码路径。如果缺这一步,链接阶段会报can't find lua51.dll,我后面在避坑章再展开。

3. 从零编译 cheat-engine 6.8.1:最小命令集与四步跑通

3.1 准备环境时最容易忽视的三个细节

不要直接在 Windows 命令行里git clone完就双击 lpi,你要先做三件事:第一,把 Lazarus 1.6.4 装到默认路径,装的时候勾选“FPC source directory”,这样编译器自带的 RTL 源码路径才有效;第二,确保项目路径没有中文和空格,C:\ce-src可以,D:\我的源码\CE大概率连 IDE 都打不开;第三,如果打算编驱动,提前安装 WDK 7,或者用 VitualKD/Driver Loader 这类工具,而不是事后补。

常见做法是先在纯用户态编译一次主程序,暂时忽略驱动。也就是说,你先用 Lazarus 打开 lpi,按 Ctrl+F9 编译,看到CheatEngine.exe生成,再去折腾 DBK。很多新手一上来想全编,结果驱动部分报错就以为整个工程编不了,其实是两个互相独立的工程。

3.2 克隆源码并切换到 6.8.1 标记

如果你拿到的是一个名为cheat-engine-master的文件夹,那就已经省了 clone 这一步。如果是从 GitHub 拉的,我用的是这条命令:

git clone --depth 1 --branch master https://github.com/cheat-engine/cheat-engine.git cd cheat-engine git tag -l git checkout ce681

参数说明:

  • --depth 1只拉取最新快照,不加历史,体积小很多;CE 仓库里有大量二进制依赖,全量克隆可能到 1GB 以上
  • --branch master明确拉 master,因为有些镜像默认分支是main
  • git checkout ce681切到 6.8.1 对应的 tag;有的仓库 tag 命名是CE681全大写,先git tag -l看一眼再切

如果你拿到的压缩包本身就是 master,直接解压到纯英文路径就往下走。

3.3 用 Lazarus 编译主程序的最小操作

打开 Lazarus,点File -> Open,选中Cheat Engine\CheatEngine.lpi。首次打开会弹出一个Packages对话框,提示缺少依赖,不要慌,等它自动加载完。然后做三个配置:到Project Options -> Compiler Options -> Paths,把Other unit files里补上..\C\lua5.1;..\..\DBK;(相对路径以 lpi 所在目录为基准);到Config -> Global Options -> Files,确认 FPC source directory 指向你安装的 FPC 3.0.0 的源码目录;最后点Project -> Project Inspector,检查有没有叫CheatEngine的主程序目标。

配置完之后直接按 Ctrl+F9 编译。正常情况要等两三分钟,期间能看到控制台刷一堆Compiling ...。第一次编最容易翻车的地方是模式选择:Lazarus 默认是Debug模式,Debug模式会产生大量断言代码,某些窗口初始化顺序和 Release 不同,会出现编译通过但启动即崩的情况。我一般直接切到Release:在Project Options -> Compiler Options -> Debugging里取消Debug info两个勾选,再把优化级别设为-O2。

编译成功后,Cheat Engine\cheatengine.exe或者Cheat Engine\bin\i386-win32\cheatengine.exe就在那里了。我实际在 Windows 10 1709 上跑过,双击第一次会弹 Windows Defender 提示,选择“仍要运行”就行,因为 CE 自己会用调试权限附加进程,杀软很容易拦。

3.4 编译 DBK 驱动的最小流程

驱动不编,你只能做普通用户态读写;要跨进程读内存、受保护区域读写,必须把DBK编出来。DBK目录下有一个build.bat,但你直接双击大概率报错,原因在于批处理里写死了 WDK 路径。我一般这样改:

@echo off set WDK_ROOT=C:\WinDDK\7600.16385.1 set BIN_DIR=%WDK_ROOT%\bin\x86 set INCLUDE=%WDK_ROOT%\inc\ddk;%WDK_ROOT%\inc\api set LIB=%WDK_ROOT%\lib\wxp\i386 cl /DDBK /DKERNEL_MODE /GS- /W3 /O1 -I"%INCLUDE%" -c DKMain.c link /DRIVER /BASE:0x10000 /SUBSYSTEM:NATIVE -LIBPATH:"%LIB%" DKMain.obj ntoskrnl.lib

参数说明:

  • WDK_ROOT要改成你实际安装的 WDK 路径,版本 7600 对应 Windows 7 SDK 那代,这是和 CE 6.8.1 驱动配合最稳的
  • /DDBK是编译宏,决定DKMain.c走NTSTATUS还是用户态兼容路径,不定义会编出一堆 Win32 代码,link 时必错
  • /GS-关闭缓冲区安全检查,内核驱动里栈布局更可控,CE 源码就是这么写的
  • /O1体积优先,驱动小一点加载更容易过

link 成功后得到DKMain.sys,这就是你要的驱动文件。加载它需要签名或者测试模式,Win7 下直接bcdedit /set testsigning on,重启后就可以用sc create和sc start加载。Win10 及以上建议用 VirtualKD 或者双机调试来跑,否则驱动签名够你折腾一天。

4. CE 6.8.1 核心机制拆解:地址扫描、指针追踪、DBK 驱动的设计思路

4.1 精确值扫描:为什么“从上一次结果中过滤”是灵魂

CE 最经典的用法是:先搜一个已知血量初值,比如 100,再让游戏里血量变了,搜 80,反复几次定位到那个变量地址。这个流程在源码里由FindUnit的FindDialog控制,核心数据结构是ScanState,它保存了所有候选地址的列表。第一次扫描时,CE 会把整个进程的 Virtual Memory 按页读回来,用ReadProcessMemory逐页比较,匹配的值存入数组;后续过滤不是重扫全进程,而是只遍历上一次的结果数组,让IsAddressMatch再判断一次当前值是否等于你输入的新值。

procedure TFindDialog.ScanFilterAgain(GuessValue: Int64; ScanType: Integer); var i, matchCount: Integer; currentValue: QWord; begin matchCount := 0; for i := 0 to LastScanResult.Count - 1 do begin currentValue := ReadProcessMemory(LastScanResult.Addresses[i], ValueSize); if CompareValue(currentValue, GuessValue, ScanType, ValueSize) then begin NewScanResult.Addresses[matchCount] := LastScanResult.Addresses[i]; Inc(matchCount); end; end; ShowResultCount(matchCount); end;

逻辑说明:LastScanResult是上一次的候选地址数组,每次过滤都不重新枚举全进程,只精确地读取当前候选地址的值做比较;CompareValue根据ScanType决定是“等于”“大于”“小于”还是“变化值”。这里的ValueSize很重要,扫描类型选 Byte(1字节)、2字节、4字节、8字节对应不同的内存读取宽度,选错就搜不到。

参数说明:

  • GuessValue是你输入的目标值,必须是十进制的整数;浮点扫描另有Single类型
  • ScanType在 6.8.1 里是0=精确 1=大于 2=小于 3=变化,其中“变化”类型配合未知初始值扫描时,不需要输入值,直接比较当前值和上次是否不同
  • ValueSize由扫描选项里的“Byte”“Word”“DWord”“QWord”决定,注意 CE 的 Word 是 2 字节,DWord 是 4 字节,和 Windows 的类型定义一致

这个设计最值得学习的一点是:它把“扫描全进程”和“过滤候选集”分开,前者慢但只做一次,后者快所以可以反复操作。你以后写自己的内存分析工具,照这个思路来,性能和体验都会有质的提升。

4.2 指针扫描与指针树:为什么找到地址还不够

很多人扫描拿到一个静态地址后,重开游戏又变了,这是因为游戏对象都是堆上动态分配的,地址每次运行都不同。CE 6.8.1 的指针扫描是解决这个问题的常用手段。源码里PointerScan模块会先把当前地址的“潜在指针链”抓出来:从目标地址出发,向上层遍历所有被指向该地址的指针,记录偏移量和指针的指针,生成一棵树。

这份源码里PointerScan.pas的TLevelInfo结构保存了每一级节点的偏移范围、过滤条件、最大深度。默认最大深度 5 级、偏移范围从 0 到 0x1000,意思是你允许最多 5 层指针跳转,每层指针加上任意偏移落在目标地址上。搜完之后你拿到的是类似"[[[base+0x30]+0x50]]"的表达式,这就是可在重开游戏后重新定位的指针链。

注意一个原理性边界:指针扫描不是万能的。如果游戏对象是被 array 索引管理的,指针跳转深度会非常深,默认 5 级就不够用,你需要调大MaxDepth,但结果数量会指数上涨,一秒算出几百万条都有可能,扫描会卡死。我一般先 3 级浅扫,看能不能锁定到全局句柄,再逐步加深,而不是一次性 5 级。

4.3 DBK 驱动:CE 如何在用户态和 Ring0 之间穿梭

DBK 是 CE 6.8.1 源码里最“硬”的部分,它不是一个完整驱动框架,而是一个极简内核扩展:注册一个设备\Device\DBK,暴露一组 IOCTL 给用户态调用。核心文件DKMain.c里最关键的函数是DKReadProcessMemory和DKWriteProcessMemory,它们用KeStackAttachProcess切换到目标进程的上下文,然后直接MmCopyVirtualMemory读写。

NTSTATUS DKReadProcessMemory(HANDLE pid, PVOID address, PVOID buffer, SIZE_T size) { PEPROCESS targetProcess; PVOID oldProcess; NTSTATUS status; status = PsLookupProcessByProcessId((ULONG)pid, &targetProcess); if (NT_SUCCESS(status)) { oldProcess = KeStackAttachProcess(targetProcess); status = MmCopyVirtualMemory(targetProcess, address, CurrentProcess, buffer, size, KernelMode, &size); KeStackDetachProcess(oldProcess); ObDereferenceObject(targetProcess); } return status; }

逻辑说明:PsLookupProcessByProcessId从 PID 找 EPROCESS,KeStackAttachProcess让当前线程挂到目标进程的地址空间,MmCopyVirtualMemory是微软未公开但稳定的内核 API,能够跨进程复制内存,最后KeStackDetachProcess切回原进程。这套操作是驱动读写的标准姿势,比起暴力改 CR3 寄存器要安全得多。注意KernelMode参数表示这是在内核态发起的复制,不经过用户态缓冲区检查,所以速度很快也有一定风险。

用户态那边,KernelUtils.pas里CDBKCommunicator负责打开\\.\DBK设备句柄并通过DeviceIoControl发IOCTL_DBK_READ_MEMORY之类的控制码。整个设计很简洁,没有用 WDF,纯粹的传统 WDM 思路,这也是为什么它在 Win7 上最稳定,到 Win10 上容易被 PatchGuard 盯上。

4.4 Mono 模块:CE 6.8.1 如何对付 .NET 游戏

Unity 游戏很多是 Mono 架构,CE 6.8.1 专门做了MonoDisAsm来解析 Mono 运行时里的类型和方法表。它扫描进程内存里 Mono 模块的MiniModule结构,解析出所有加载的类、字段、方法,在Mono Inspector窗口呈现。这个模块的价值在于:你不需要像搜普通内存那样盲搜,可以直接按类名找字段。

源码实现上,MonoLib\mono.pas里定义了大量与 Mono 内部结构对齐的 Pascal 记录,比如MonoImage、MonoClass、MonoMethod。注意这些结构在不同 Mono 版本里有偏移差异,CE 6.8.1 只保证它发布时那几版 Mono 能解析,遇到新版 Unity 2019+ 很容易字段错位。我踩过这个坑:在找 Unity 游戏的角色坐标时,Mono Inspector 显示类型名乱码,字段偏移全错,后来直接用步进扫描定位浮点数组解决了,所以别神话 Mono 模块。

5. 源码使用者的常见翻车点:编译、加载、扫描三个阶段的排查记录

5.1 编译阶段:Lazarus 版本过新导致 LCL 类型定义冲突

现象:用 Lazarus 2.0 打开 6.8.1 工程,编译到一半报TBitmap 未声明或Identifier not found: HTREEITEM。 原因:FPC 3.2 的 Win32 头文件里TreeView常量名和 CE 源码里的自定义类型重名,新版 LCL 又改了消息处理参数,老代码直接炸。 解决:装 Lazarus 1.6.4,不要用新版强行编译;如果一定要用新版,可以在Cheat Engine\extrafiles里找官方为新版准备的补丁文件夹(里面有Lazarus_2_fixes),把那些*.patch打到源码上再编,但我试过打补丁后仍有小概率启动崩溃,不如直接老版本省心。

5.2 编译阶段:提示找不到 lua51.dll

现象:编译通过,但运行时弹窗lua51.dll 缺失,或者链接阶段报can't find -llua51。 原因:Lazarus 工程的库搜索路径没包含C\lua5.1目录,FPC 链接器找不到导入库。 解决:在Project Options -> Compiler Options -> Paths -> Libraries里加一行..\C\lua5.1,然后在Other unit files里也要加同一个路径;注意分号分隔,不要替换原有内容。改完重新编译,Lazarus 会自动编译C\lua5.1下的 C 文件生成lua51.dll,如果没自动编,就手动打开C\lua5.1\lua5.1.dpr单独编译一次。失败时看编译窗口的Hint,如果提示LUA_API没定义,说明缺少LUAI_FUNC等宏,需要把C\lua5.1\src也加入 include 路径。

5.3 运行阶段:主程序能起来,但附加进程后所有值显示 ??

现象:打开 CE,选中一个进程附加,地址列表里的数值全部是问号,无法读取。 原因:用户态ReadProcessMemory被目标进程的权限限制挡了。CE 的openProcess用的是OpenProcess(PROCESS_ALL_ACCESS),如果你的进程以管理员运行,目标进程是更高权限的系统服务或者反作弊驱动保护的进程,就会失败。 解决:用右键“以管理员身份运行”CE;如果还不行,加载 DBK 驱动切到内核读写模式。驱动加载后 CE 会自动使用CDBKCommunicator走 Ring0 读写,不再依赖用户态的权限检查。我在 Win7 上碰过一次:附加一个 32 位游戏什么都没显示,加载驱动之后马上能扫了。所以如果只是辅助学习,建议一开始就把驱动编好装上。

5.4 运行阶段:驱动加载失败,sc start DBK返回 577

现象:手动创建驱动服务后启动,提示错误码 577 或 1275。 原因:577 表示驱动签名验证失败,1275 是驱动被阻止加载。CE 6.8.1 的驱动没有签名,Win10 x64 默认禁止未签名驱动。 解决:正式环境用有签名的版本,开发环境进测试模式:管理员命令行执行bcdedit /set testsigning on,重启后加载即可。如果不想重启,用 VirtualKD 的vmsys方式双机调试也能绕过签名。还有一个小坑:很多教程让你关Secure Boot,但即使关了测试模式也可能被安全启动策略拦,建议在 BIOS 里同时关闭 Secure Boot。

5.5 扫描阶段:结果越来越少到最后只剩 0 条

现象:用未知初始值扫描一个变量,第一次全内存扫描有几十万条结果,改了几次值后结果变 0,重新搜也搜不到。 原因:最常见是扫描类型选错。比如你实际想搜 4 字节浮点,却用了默认的 Byte;再比如输入新值时,值大小不匹配(游戏里血量是float,你按4 bytes搜能出来,但按Byte搜就废了)。另一种情况是开启了“写时拷贝”或者内存页被反作弊打了补丁,扫描被误导。 解决:先确认Scan Type是Exact Value还是Changed Value,Value Type选Float或Double,再重扫。对于 0 条,切换到 CE 的内存查看器,手动跳到目标地址附近看看实际字节长什么样,用真实数据反推类型。还有一个取巧办法:把扫描结果窗口最下方的Hexadecimal复选框勾上,搜十六进制更直观。

6. 进阶:给 CE 6.8.1 扫描器加一个“连续变化值”过滤,并验证自己的改动

现在你已经能编译、运行、基本扫描了,最后的进阶是把你的定制想法落到源码里。我以“过滤连续值变化”为例,教你改一条逻辑并重新编译验证,这是能否二次开发 CE 的分水岭。

在CFindwhat\FindUnit.pas里找到TFindDialog.ScanFilterAgain,它只负责“当前值和目标值比较”。我要加一个 数组值连续自增的过滤规律:当目标值是后一次比前一次大 +1 时,自动匹配。思路不复杂,在CompareValue调用前加一个分支判断:

if ScanType = ctIncreasedByOne then begin currentValue := ReadProcessMemory(LastScanResult.Addresses[i], ValueSize); if currentValue = LastScanResult.OldValues[i] + 1 then begin NewScanResult.Addresses[matchCount] := LastScanResult.Addresses[i]; NewScanResult.OldValues[matchCount] := currentValue; Inc(matchCount); end; Continue; end;

逻辑说明:LastScanResult.OldValues保存了上一次扫描时每个候选地址的旧值,你只需要把当前值拿出来和“旧值 +1”比较,精确匹配。这种“递增型”过滤在找怪物 ID 递增、导弹编号连续变化这类常见场景里非常实用。

参数说明:

  • ctIncreasedByOne是你自己新增的枚举类型,要在FindUnit.pas顶部的TScanType枚举里加上一项,不然编译不过
  • OldValues[i]数组长度必须和Addresses[i]一致,这要求每次扫描写入NewScanResult.OldValues时同步记录值,CE 原版里只有少数扫描类型会记录旧值,要自己补
  • Continue跳过后面的原有比较逻辑,减少一次无意义调用

改完编译,Android 模拟器里跑一个内存值每次 +1 的小测试程序,确认新过滤规则生效。如果你发现OldValues全为 0,那说明上一次扫描时没存旧值,要去FindDialog的常规扫描分支里补一句OldValues[i] := currentValue,这里是最容易改崩的地方,改完记得做回归,普通精确扫描还要能正常工作。

最后一句话是我最想提醒你的:CE 6.8.1 源码的二次开发,断点永远比脑补好用。在ScanFilterAgain里打一个断点,看LastScanResult.OldValues的填充过程,比反复读代码快得多。每次改完,不要只验证新功能,老功能全扫一遍,否则就是给自己埋定时炸弹。这个习惯救过我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进