资讯动态

CheatEngine Delphi源码拆解:内存扫描、Hook与逆向工具链构建

发布时间:2026/10/13 5:09:55 来源:尧图企业网站定制
简介Cheat Engine源码包基于Delphi编写完整还原经典游戏内存修改与调试工具的底层构造适合有编程基础并关注逆向工程、游戏安全或大型桌面应用架构的开发者。包内共有一千五百四十六个文件以Pascal源码、C头文件、C源文件为三大主体另有Lua脚本、汇编代码、工程配置、界面定义文件与动态库等Pascal源码部分可用来跟踪扫描与注入逻辑C/C文件体现原生交互脚本代码展示扩展方式压缩包整体仅8.52兆字节结构紧凑便于分模块研读。已有三百九十五人学习下载。通读源码可以深入理解精确值与模糊值扫描、动态内存地址追踪、进程注入、数据结构解析等关键机制也能学习Delphi下的多线程调度、WinAPI封装、错误处理与脚本系统构建技巧同时借鉴其插件模块划分和调试界面设计思路对于想自行编写调试器、修改器或希望提升反汇编与逆向实战能力的开发者是一份完整且贴近真实工程的参考资料。1. 拆一份 CheatEngine 源码Delphi 逆向工具链到底藏着什么很多人下载 CheatEngine 源码下称 CE是冲着“修改器”去的但真正把 Delphi 源码摊开之后才会发现它其实是一套完整的 Windows 内存操作教学系统从进程枚举、虚拟内存读写到断点注入、调试事件处理再到符号扫描和特征码定位每个模块都能独立抽出来学。我见过不少做安全测试的同行把 CE 当“黑匣子”用扫到地址就改改了也不知道 CE 内部做了多少次 API 调用和缓存。这篇笔记基于 CE 开源的 Delphi 工程把构建流程、内存扫描的底层逻辑、注入与 Hook 的代码路径、以及编译时最容易卡住的几个坑逐一过一遍。适合三类人想用 Delphi 做 Windows 工具开发的、搞逆向工程入门想找真实项目读源码的、以及需要自己维护一套内存调试工具的从业者。2. 工程结构与 Delphi 技术栈先搞清楚 CE 由哪些模块组成2.1 从源码根目录认识 CE 的模块划分拿到 CE 源码压缩包之后第一件事不是找 .exe而是打开根目录结构。CE 的工程文件用 Lazarus 管理主力语言是 Object PascalDelphi 兼容模式源码里会同时出现 .lpi、.lpr、.pas、.lfm 等文件。核心模块大致可以分成四块主窗体逻辑MainUnit、内存读写封装MemoryQuery、调试器核心Debugger以及驱动接口Driver。我用 IDE 打开主工程文件后能看到单元之间的引用关系是从 MainUnit 向各个子系统辐射的这和普通 Delphi 应用的单窗体结构完全不同。值得留意的是 CE 对 Delphi 版本的兼容性工程里用到了泛型、匿名方法、类静态方法这些较新的语法特性用老版本的 Delphi 7 直接打开会报语法错误。我这边使用的是 Lazarus 2.x 搭配 FPC 3.x编译目标选择 win64在 Windows 10/11 上都能正常构建。如果你机器上同时装了多个 Delphi 版本建议先检查每个 .lpi 文件开头的CompilerOptions里面写的 Optimization 和 Debug 开关会影响后续附加调试器时的表现。2.2 关键文件与编译入口第一次编译必须知道的事打开主工程以后默认的启动单元是 MainUnit.pas窗体文件 MainUnit.lfm 定义了主界面布局包括地址列表、十六进制视图、内存区域树这几个核心控件。编译前需要在 Project Options 里确认三件事目标平台x86_64 或 i386、LCL 控件集Windows 下选 Win32/Win64、以及调试信息类型建议选 dwarf with sets。直接用默认配置编译大概率会失败这是因为 CE 源码默认携带驱动编译规则普通用户模式下没有安装驱动开发环境需要在 Project Inspector 里把 Driver 相关的包排除掉。我一般会在 IDE 里执行 Clean Build而不是直接 Run。清理历史对象文件这一步很关键因为 CE 的源码包可能包含多个平台的历史编译产物不清理的话有可能链接到架构不匹配的 .o 文件。编译成功后在输出目录找到 ce.exe首次运行会提示缺少驱动文件这个不影响学习源码用软件模式就能完成大部分内存读写实验。2.3 用依赖图理解 CE 如何组织调试功能用 IDE 的依赖图功能查看 MainUnit 的 uses 列表能从全局把握 CE 的设计思路。MainUnit 引用了 MemoryQuery、MemoryView、Disassembler、SymbolScanner 等模块其中 MemoryQuery 负责与 Windows 内存管理 API 打交道Disassembler 封装了 Zydis 反汇编引擎SymbolScanner 则处理导出符号和特征码扫描。这种每个模块自包含的设计非常符合 Delphi 单元组织的最佳实践界面与逻辑分离底层 API 调用不外泄。读这个工程的另一个收获是学习 CE 如何抽象“内存区域”概念。CE 的自定义类 MEMRegion 封装了基址、大小、保护属性、区块信息后续所有扫描和分析都是围绕这个类展开的。如果你想把这个工程改造为自己的内存监控工具第一步就是沿用这个抽象而不是直接调用 VirtualQueryEx。3. 内存扫描的完整实现从地址枚举到特征码匹配3.1 进程内存区域枚举的 Delphi 实现CE 扫描内存的第一步是枚举目标进程的内存区域底层调用 VirtualQueryEx 这个 Windows API。源码里主扫描单元遍历进程的整个地址空间把类型为 MEM_PRIVATE 且保护属性可读的区块收集起来构成一个候选区域列表。这一步的 Delphi 实现通常会写出类似下面的循环结构function CollectReadableRegions(ProcessHandle: THandle): TList; var Addr: Pointer; MBI: TMemoryBasicInformation; RegionSize: NativeUInt; begin Result : TList.Create; Addr : nil; while VirtualQueryEx(ProcessHandle, Addr, MBI, SizeOf(MBI)) 0 do begin if (MBI.State MEM_COMMIT) and (MBI.Protect and PAGE_NOACCESS 0) then Result.Add(Pointer(MBI.BaseAddress)); Addr : Pointer(NativeUInt(MBI.BaseAddress) MBI.RegionSize); end; end;这段代码用循环遍历地址空间而不是一次性尝试读完整内存因为 Windows 的地址空间存在空洞VirtualQueryEx 逐块返回的方式最可靠。每次查询返回一个 MEMORY_BASIC_INFORMATION 结构包含区域基址、大小和保护属性然后移动指针到下一个区域。判断条件里同时检查 MEM_COMMIT 和 PAGE_NOACCESS是为了过滤掉未提交和不可读的区域避免后续 ReadProcessMemory 频繁失败。参数层面需要注意的一点VirtualQueryEx 的第三个参数 MBI 必须用 SizeOf(TMemoryBasicInformation) 显式传入不同 Delphi 版本的记录类型大小可能不同写死长度会在某些 Windows 版本上取不到完整信息。另外这个函数不能跨架构使用编译 64 位程序去枚举 32 位进程时类型定义要对应调整。3.2 初次扫描与下一次扫描数据结构决定性能CE 的扫描引擎在第一次扫描时会把整个候选区域读入内存按扫描方式建立初始结果集。建立结果集的数据结构选择很有讲究对每个对齐位置记录当前值同时保存位置信息。后续“下一次扫描”不再读全部内存而是基于上一次的结果做增量比较。增量比较的核心逻辑是把每个位置的新值与期望值做比较满足条件的保留不满足的删除。扫描流程 第一步 扫描类型 初始值值 输入数字 第二步 扫描类型 数值变化变化方式 增加 第三步 对保留结果执行再次扫描缩小候选集这个流程决定了 CE 扫描对话框的交互逻辑先“首次扫描”建全集再修改游戏或程序中的数值执行“下一次扫描”。每次下一次扫描都只遍历候选集对应的地址读取当前值和上次记录值比较。如果候选集很大CE 会使用哈希索引来加速查找这是源码里 MemScan 单元的核心设计之一。3.3 特征码AOB扫描原理与 CE 源码中的实现思路手工找地址的方式不可靠时CE 提供了 AOB 扫描Array of Bytes这是它的招牌功能。在源码中的实现是读入内存区块后用字节模式匹配算法查找目标序列。这个逻辑在符号扫描单元里允许使用通配符用问号表示任意字节例如匹配 48 8B 05 ? ? ? ? 这样的指令模式。实现上CE 对每个候选区域使用了一个改进的搜索算法比朴素的逐字节匹配快很多。核心优化在于通配符的处理当模式中无通配符时使用快速移位搜索有通配符时则建立跳过表。把 AOB 扫描和符号解析结合就能实现“在模块基址 偏移处搜索特征码”这种常见逆向操作。这里给新手一个建议用 CE 做 AOB 扫描时尽量选指令中稳定的字节段避免出现 SIB 字节或立即数中带模式的情况。我在实际操作中遇到过特征码太短导致上千个匹配结果的问题后来把模式长度加长到 12 字节以上再限定模块范围候选集就控制在了个位数以内。4. 代码注入与 Hook 机制CE 是怎么改程序流程的4.1 注入方式一用线程句柄执行远程代码CE 源码中的代码注入功能建立在 CreateRemoteThread 的基础上。对目标进程申请一块可执行内存把机器码写入后创建远程线程执行这是最传统的方式。在 Delphi 工程中的体现是代码注入单元封装了几个关键步骤VirtualAllocEx 申请内存、WriteProcessMemory 写入字节、CreateRemoteThread 启动执行。procedure InjectAndRun(ProcessHandle: THandle; Code: PByte; CodeSize: Integer); var RemoteBuf: Pointer; hThread: THandle; Written: NativeUInt; begin RemoteBuf : VirtualAllocEx(ProcessHandle, nil, CodeSize, MEM_COMMIT or MEM_RESERVE, PAGE_EXECUTE_READWRITE); try if not WriteProcessMemory(ProcessHandle, RemoteBuf, Code, CodeSize, Written) then RaiseLastOSError; hThread : CreateRemoteThread(ProcessHandle, nil, 0, RemoteBuf, nil, 0, nil); if hThread 0 then RaiseLastOSError else CloseHandle(hThread); finally VirtualFreeEx(ProcessHandle, RemoteBuf, 0, MEM_RELEASE); end; end;这个实现里有几个参数值得关注分配内存时保护属性用 PAGE_EXECUTE_READWRITE如果改成 EXECUTE_READ 可能导致部分动态生成的指令写入失败CreateRemoteThread 的第四个参数是远程函数地址传的是 VirtualAllocEx 分配的缓冲区首地址。代码执行结束后调用 CloseHandle 关闭线程句柄避免句柄泄漏。这种方法简单有效但会留下明显的线程痕迹容易被检测CE 源码里这样的注入属于基础功能实际使用中常配合线程伪装。4.2 注入方式二通过调试事件实现 HookCE 的调试器模块实现了一种更隐蔽的方式附加进程后利用 DEBUG_EVENT 循环控制目标程序的运行。当目标进程触发断点异常EXCEPTION_BREAKPOINT时CE 暂停目标并检查断点地址如果命中了预设的 Hook 点就改写寄存器和指令流实现无侵入注入。整个过程在 CE 的调试线程中完成不创建远程线程因此没有新增线程的痕迹。这种方式的实现核心是调试事件循环Delphi 源码中会看到处理 EXCEPTION_DEBUG_EVENT 的分支。Hook 的本质是把原指令替换为跳转指令跳转到 CE 存放的代码洞穴Code Cave执行完自定义逻辑后再跳回原位置。CE 的 Auto Assembler 模板就是围绕这个机制设计的DB 命令定义目标地址跳转指令由 CE 根据目标架构自动生成。实操中需要注意使用调试事件 Hook 时如果目标进程自己也在调试例如附加了另一个调试器Windows 会拒绝二次附加这个在 CE 源码的附加逻辑中有明确判断。4.3 Hook 的完整生命周期与常见翻车原因Hook 从安装到卸载的完整步骤是保存原始字节、写入跳转指令、等待触发、执行代码洞穴逻辑、恢复原始字节。CE 中通过 Auto Assembler 脚本完成这些步骤源码会解析脚本生成对应的修复数据。卸载时如果顺序不对先恢复再等线程同步会导致目标进程崩溃或死锁最常见的原因是钩子触发的代码里访问了非法地址。我在给一个旧版游戏程序做 Hook 实验时遇到过这类翻车代码洞穴里用了相对调用指令但洞穴距离目标地址超过跳转范围生成了错误的相对偏移。CE 的汇编器会用较长的跳转指令序列解决这个问题但手工注入时如果不考虑距离约束就会跳飞到错误位置。5. 避坑与调试CE 源码编译和运行中的常见问题5.1 现象Lazarus 编译报错找不到驱动相关包编译 CE 源码时最常见的现象是 IDE 报错找不到某个驱动单元错误信息会提示无法找到使用的单元文件。原因在于 CE 源码默认把驱动模块当作工程的一部分但公共发布包里没有包含完整的驱动代码。解决方式是在工程选项里移除驱动相关的包引用或者编译时定义条件编译符号 SOFTWARE_MODE将驱动部分排除这个符号在工程配置里可以找到。5.2 现象用 CE 附加 64 位进程时扫描结果为空有用户反映扫描 64 位进程时不显示任何内存区域原因通常是 CE 以 32 位模式运行无法枚举 64 位进程的完整地址空间。解决方法是确认编译的是 64 位版本的 ce.exe如果自己编译时选错了目标架构需要重新编译。附加上看进程列表里的类型标识x64 进程会标注 64 位标识。5.3 现象运行扫描时程序崩溃提示访问了无效地址触发场景是勾选了“可写”过滤条件扫描某类游戏进程时CE 扫描引擎在遍历候选区域时访问了已被释放的内存区域。原因是目标进程的内存布局在扫描过程中动态变化某个区域的保护属性被修改或区域被卸载。解决方式是让 CE 在每次扫描前重新枚举内存区域而不是复用上次的区域列表。CE 源码中的刷新机制默认是自动触发的但如果手动改过扫描参数就可能绕过这层保护。5.4 现象注入的 DLL 没有被 LoadLibrary 加载这个现象常见于手工注入调用了 LoadLibraryA 但目标进程无反应。可能原因有两个一是 DLL 依赖的运行时与目标进程不兼容DLL 初始化失败被静默跳过二是远程线程执行时传入的参数地址不可读LoadLibraryA 拿到非法路径。解决方式是在目标进程里把 DLL 路径字符串写入可读内存后再创建线程并确保 DLL 导出的 DllMain 能处理 ATTACH 事件。CE 的注入封装内部已经处理了路径字符串的写内存过程所以用 CE 很少踩这个坑但手写注入器时一定要检查。5.5 现象Auto Assembler 脚本执行后毫无效果如果脚本执行成功但没有产生预期的数据变更通常是因为脚本里使用的地址是静态地址或基址计算错误。原因是很多程序每次启动后模块基址会变化直接用硬编码地址做 Hook 不适用于 ASLR 开启的进程。解决方式是先用 CE 的“查看模块”获取当前基址然后在脚本中用模块名加偏移的方式引用地址。CE 的 Auto Assembler 支持使用模块名偏移的语法这一点在源码的脚本解析器里有对应实现。6. 进阶验证与使用技巧把 CE 源码变成你自己的调试工具如果你想更进一步可以给 CE 加一个自定义的符号解析插件。CE 源码保留了符号加载器的接口在符号扫描单元里有一个 LoadSymbols 的回调可以挂接自己的符号文件解析器。我曾经写过一个简单的符号加载器从 Map 文件中读取函数名和地址转换成 CE 可以识别的符号表结构这样在内存查看器里就能直接看到 API 名称而不仅是十六进制地址。验证自己编译的 CE 是否工作正常我有两个常用手段。第一个是启动两个 CE 实例让第二个 CE 附加第一个 CE 的进程扫描已知字符串这个能测试基本的跨进程内存读写。第二个是编译一个含固定数组的测试程序用 CE 的数值扫描找到数组首地址再对比数组内容的十六进制显示。这两个实验可以快速筛查编译配置是否有误比直接拿游戏做实验稳定得多。打过一次验证之后我每次编译完都会强制走一遍这两步直接避开后面调试时的各种奇怪现象。Hook 脚本方面有个实用技巧CE 的 Auto Assembler 脚本支持将代码块标记为“分配内存”脚本会在目标进程的可用地址空间里搜索一块足够大的代码洞穴。如果脚本里的标签定义失败通常是因为洞穴大小不足这时可以缩小脚本代码或增加分配区域大小。调试时如果脚本反复修改导致目标进程不稳定可以使用 CE 的“调试器自动操作”功能在脚本执行前后自动保存和恢复通用寄存器状态这个小开关在 CE 的调试设置里勾选。从安全测试的视角看把 CE 源码吃透之后你会对“读取内存、扫描特征、Hook 流程”这三板斧有自己的判断。比如自己写扫描器的时候会偏向用缓存减少 ReadProcessMemory 的调用次数写 Hook 的时候优先考虑断点方式而不是远程线程。这些经验判断很难从使用者角度获得只有读了源码里的实际流程才能总结出来。我自己的习惯是维护一份修改过的 CE 构建脚本自动化完成清理、排除驱动、编译和符号加载这几个步骤每次换环境都直接跑一遍。从那以后每一次构建都控制在十分钟内出错概率也明显下降。希望这篇关于 CheatEngine 源码的 Delphi 实现拆解能帮到你愿你的调试之路少踩几个坑。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑