内存修改这件事,很多人第一次接触都是被某个单机游戏卡住——血条见底、金币不够、材料刷到吐。Cheat Engine(下称CE)就是在这个场景里被反复提起的工具。它本质上是一个内存扫描与调试工具,能读取进程的虚拟内存、定位关键数值、追踪访问路径,最终让你理解一个程序在运行时到底把数据放在了哪里。这篇内容面向的是想系统搞懂CE工作流程的人:从最基础的数值扫描,到多级指针的追踪,再到把一段自定义逻辑写回目标进程。全程以单机环境下的自我研究与逆向学习为前提,不涉及任何线上服务或多人环境。
我接触CE断断续续有几年,最开始也是照着教程改个金币数字就完事,后来发现真正有价值的部分根本不是"改数值",而是理解程序如何组织内存。数值会变、地址会失效,但指针链和数据结构是稳定的。把这条链路吃透,你才算真正会用CE,而不是只会按"首次扫描"。
1. 先搞清楚CE到底在扫描什么
很多人打开CE第一件事就是输入数值点扫描,但如果你不知道它在扫什么,后面所有操作都是碰运气。这一节把底层概念讲清楚,后面才不会迷路。
1.1 虚拟内存与进程地址空间
一个32位进程启动后,操作系统会给它分配一块独立的虚拟地址空间,范围通常是0x00000000到0x7FFFFFFF,也就是4GB。64位进程则是更大的空间。这块空间不是真实物理内存,而是操作系统通过页表映射出来的"假地址",程序里看到的每一个地址都是虚拟地址。
CE扫描的就是这块虚拟地址空间。它通过操作系统提供的调试接口(Windows下主要是OpenProcess、ReadProcessMemory、WriteProcessMemory这一组API)来读取和写入目标进程的内存。理解这一点很关键:CE本身不"侵入"程序,它只是借助系统提供的合法调试通道去读写。所以如果目标进程有反调试保护,CE可能连进程都附加不上,这是第一道门槛。
虚拟内存被划分成一个个"页",常见页大小是4KB。页有权限属性:可读、可写、可执行。CE扫描时默认只扫可读的页,因为不可读的页读出来会报错。你在扫描设置里看到的"可写""可执行"过滤选项,就是在按页权限筛选。
1.2 数值类型决定了扫描方式
CE支持非常多的数据类型,选错类型是新手最常见的失败原因。下面这张表是我实际用下来最需要记住的几类:
| 类型 | 字节数 | 典型场景 | 扫描注意点 |
|---|---|---|---|
| Byte | 1 | 等级、状态标志 | 范围0-255,超过就溢出 |
| 2 Bytes | 2 | 小数值属性 | 范围-32768到32767 |
| 4 Bytes | 4 | 金币、血量、经验 | 最常用,整数默认选它 |
| 8 Bytes | 8 | 大数值、时间戳 | 数值很大时优先试 |
| Float | 4 | 带小数的属性 | 精度问题,需用范围扫描 |
| Double | 8 | 高精度小数 | 同上 |
| String | 变长 | 名字、文本 | 注意编码(ASCII/UTF-16) |
举个实际例子:游戏里显示金币是100,你选4 Bytes扫100,可能扫出几十万个结果。但如果这个数值实际是Float存储的,你扫4 Bytes整数就永远找不到。判断方法很简单——看这个数值会不会出现小数。会,就优先试Float/Double;不会,先试4 Bytes。
提示:Float和Double不要用精确值扫描,因为浮点存储有精度误差。100.0在内存里可能是99.99999或100.00001。正确做法是勾选"值介于...之间",范围设成99.9到100.1。
1.3 扫描策略:精确值、未知初始值、变动/未变动
CE的扫描类型不止"精确数值"一种,这是很多人忽略的强大功能:
- 精确数值:知道当前值,直接扫。适合金币、血量这种明确数字。
- 未知初始值:不知道具体值,但知道它在变化。先扫一次全内存,然后让数值变化,再用"变动了/未变动"逐步缩小范围。血条这种没有数字显示的进度条就靠这个。
- 数值增加了/减少了:比"变动了"更精确,适合你知道数值在涨还是在跌的场景。
我个人的经验是:能拿到精确值就绝不用未知扫描。未知扫描第一次会扫出几百万甚至上千万个地址,后续要反复操作游戏让数值变化,再一轮轮过滤,非常耗时。只有在数值完全不可见时才用它。
2. 从一次成功的扫描到定位真实地址
扫描出结果只是开始,真正的难点在于:几万个结果里,哪个才是你要的?这一节讲怎么一步步收敛。
2.1 首次扫描与结果收敛
假设目标是一个单机游戏的金币,当前显示500。操作流程:
- 在CE里点左上角"打开进程",选中目标进程。
- 数值类型选4 Bytes,扫描类型选"精确数值",输入500,点"首次扫描"。
- 结果可能是几万到几十万个地址。
接下来是关键动作:回到游戏里让金币发生变化,比如花掉一部分变成320。回到CE,输入320,点"再次扫描"。结果会大幅缩小,可能只剩几个到几十个。重复这个过程两三次,通常就能锁定到1-3个地址。
这里有个细节很多人不知道:再次扫描时不要改数值类型,否则CE会重新全量扫描。另外,如果数值变化后一个结果都不剩,说明你第一次的数值类型选错了,或者这个数值根本不是直接存储的(可能是加密的、或者是通过计算得出的显示值)。
2.2 验证地址:改一下看反应
锁定候选地址后,双击把它加到下面的地址列表。然后右键该地址,选"更改记录"→"数值",改成一个大数比如99999。切回游戏看金币有没有变。
- 变了:恭喜,这就是真实地址。
- 没变:说明这是个"显示副本"或者无关地址,删掉继续试下一个。
我踩过的一个坑:有些游戏会把数值存两份,一份是逻辑值,一份是渲染值。你改渲染值,画面变了但实际逻辑没变,一操作就还原。判断方法是改完之后触发一次游戏内的数值计算(比如再花一次钱),如果数值立刻跳回原值,说明你改的是副本。
2.3 找出"谁在写这个地址"
找到地址只是第一步,因为这个地址下次启动游戏大概率会变。原因是现代程序普遍使用动态内存分配,每次运行分配的地址都不同。要让修改持久化,必须找到稳定的访问路径。
CE提供了一个核心功能:"找出是什么改写了这个地址"(Find out what writes to this address)。操作:
- 右键地址,选"找出是什么改写了这个地址"。
- CE会附加一个调试器到目标进程,并把这个地址设为写入断点。
- 回到游戏,触发一次数值变化(比如再花一次钱)。
- CE会捕获到改写指令,显示类似
mov [eax+0x1C], ecx这样的汇编。
这条指令信息量极大:eax是基址寄存器,0x1C是偏移,ecx是写入的值。它告诉你程序是通过"基址+偏移"的方式访问这个数值的。这就是指针链的起点。
注意:附加调试器可能触发目标程序的反调试机制,导致程序崩溃或退出。如果出现这种情况,说明该程序有保护,需要换思路(比如用CE的"Ultimap"或者VEH调试器模式)。
3. 指针扫描:让地址在重启后依然有效
这是CE里技术含量最高、也最容易劝退的一环。但只要你理解了原理,它其实是有章可循的。
3.1 为什么地址会变,指针为什么稳定
程序里的局部变量存在栈上,栈地址每次运行都可能不同。但全局变量和堆上的对象,通常通过一个固定的模块基址+固定偏移来访问。模块基址是exe或dll加载到内存的起始地址,虽然每次运行也可能变(ASLR),但CE能自动处理这个偏移。
指针链的本质是一条多级索引路径:[[[基址+偏移1]+偏移2]+偏移3] = 目标值。每一级方括号代表一次解引用。只要这条路径上的偏移是固定的,无论程序重启多少次,你都能顺着它找到目标值。
3.2 指针扫描的完整操作
假设你已经通过"找出是什么改写了这个地址"拿到了一条指令,比如mov [eax+0x1C], ecx,并且知道eax的值。接下来:
- 在CE地址列表里,右键目标地址,选"生成指针映射"(Pointer scan for this address)。
- 弹出的窗口里,最大偏移层级(Max level)先设3,最大偏移值(Max offset)设2048左右。层级越高、偏移越大,扫描越慢但覆盖越全。
- 点确定,CE会扫描整个内存,找出所有能指向目标地址的指针路径。这个过程可能几分钟到十几分钟。
- 扫完后会得到成千上万条候选路径。
关键来了:重启目标程序,重新找到那个数值的地址(这次地址肯定变了),然后用"指针扫描器"里的"重新扫描内存"功能,把新的地址填进去。CE会用新地址去验证之前那批候选路径,只保留仍然有效的。
重复"重启-重扫"两到三次,候选路径会收敛到几条甚至一条。那条就是稳定的多级指针。
3.3 手动验证指针链
拿到候选指针后,不要直接信。手动验证一遍:
- 记下路径,比如
"game.exe"+0x0012A3B0 → +0x10 → +0x1C。 - 在CE里手动添加地址,勾选"指针",填入基址和偏移。
- 看它指向的值是不是你要的数值。
- 重启游戏,再看一次。如果还是对的,这条链就成立了。
我遇到过一个典型问题:指针扫描扫不到东西。原因通常有三个——目标地址本身是动态分配的临时地址(比如栈上的),最大偏移层级设得太低,或者扫描时没有勾选正确的模块。解决办法是提高层级到4-5,扩大偏移范围,并确保只扫描主模块和必要的dll。
4. 代码注入:从改数值到改逻辑
改数值只是入门,CE真正强大的地方是能让你往目标进程里注入自己的代码。这一节讲清楚注入的原理和实操。
4.1 代码注入解决什么问题
有些数值不是简单存储的,而是每帧计算出来的。你改一次,下一帧就被覆盖。这时候改数值没用,得改产生数值的那段逻辑。
比如一个游戏每帧执行health = maxHealth - damage,你想让health永远满,直接改health会被覆盖,但如果你把这条指令改成health = maxHealth,就一劳永逸。
CE的"自动汇编"(Auto Assemble)功能就是干这个的。它能让你在目标进程里分配一块内存,写入自定义汇编代码,然后把原来的指令跳转到你的代码,执行完再跳回去。
4.2 用代码注入实现一个"锁血"
操作流程:
- 用"找出是什么改写了这个地址"定位到写血量的指令,比如
mov [ebx+0x20], eax。 - 右键这条指令,选"显示反汇编程序",会打开反汇编窗口。
- 在反汇编窗口里,选中这条指令,点"工具"→"自动汇编"。
- CE会自动生成一个模板,包含
alloc、label、code等段。
一个典型的注入脚本长这样:
[ENABLE] alloc(newmem, 2048, "game.exe"+0x0012A3B0) label(returnhere) label(originalcode) label(exit) newmem: // 这里写你的逻辑 mov eax, [ebx+0x24] // 把maxHealth读进eax mov [ebx+0x20], eax // 写回health jmp exit originalcode: mov [ebx+0x20], eax exit: jmp returnhere "game.exe"+0x0012A3B0: jmp newmem nop returnhere: [DISABLE] "game.exe"+0x0012A3B0: db 89 43 20 // 恢复原始指令字节这段脚本的逻辑是:在目标进程里分配一块内存newmem,把原本写血量的指令替换成跳转到newmem。在newmem里,我们改成从maxHealth读取值写进health,实现锁血。执行完跳回原指令的下一条。
4.3 注入时的几个致命细节
代码注入看着简单,但有几个坑必须注意:
第一,指令长度必须对齐。你替换的原始指令如果是5字节(比如mov [ebx+0x20], eax是3字节,但jmp是5字节),那替换后必须用nop填充剩余字节,否则会破坏后面的指令。CE的自动汇编模板会自动处理,但手动写的时候要小心。
第二,寄存器必须保护。你的注入代码如果修改了原逻辑依赖的寄存器,会导致程序崩溃。比如原指令后面还要用eax,你在注入代码里把eax改了却没恢复,就会出问题。稳妥做法是在注入代码开头pushad,结尾popad,保存和恢复所有通用寄存器。
第三,alloc的地址要选对。分配的内存最好靠近目标模块,这样jmp能用短跳转(2字节),减少指令长度问题。CE的alloc支持指定near参数。
第四,禁用时要能干净还原。[DISABLE]段必须把原始字节写回去,否则你关掉脚本后程序会执行到残留的jmp,直接崩溃。
提示:写注入脚本前,先在反汇编窗口看清楚原始指令的完整字节。CE的反汇编窗口会显示每条指令对应的机器码,这是你还原时的依据。
5. 汇编与指针的进阶配合
到这一步,你已经能改数值、能锁数值、能注入代码了。但真正让修改稳定的,是汇编层面的理解和指针链的灵活运用结合。
5.1 读懂常见的访问指令
CE捕获到的写入指令,常见的有这么几类:
mov [地址], 寄存器:直接写入。地址可能是绝对地址,也可能是[基址+偏移]。mov [寄存器+偏移], 寄存器:通过寄存器间接写入,这是指针链的典型形式。add/sub [地址], 值:增量修改,常见于扣血、扣钱。movss [地址], xmm0:浮点数写入,xmm寄存器是SSE指令集用的。
看懂这些指令,你就能判断出这个数值是怎么被访问的。如果是[寄存器+偏移]形式,那这个寄存器里存的就是某个对象的基址,顺着它就能找到对象结构。
5.2 从单条指令推导数据结构
假设你捕获到mov [esi+0x14], eax,并且知道esi指向某个对象。那么+0x14就是这个对象里血量字段的偏移。如果你再捕获到mov [esi+0x18], ecx,那+0x18可能是魔法值。慢慢地,你就能还原出整个对象的内存布局。
这个能力非常有用。比如你想一次性修改多个属性,只要找到对象基址,就能通过不同偏移访问所有字段。CE的" dissect data structures"(分析数据/结构)功能就是干这个的,它能让你定义一个结构体,把各个偏移命名,方便管理。
5.3 多级指针的实际构建
回到指针扫描。假设你扫出来的路径是"game.exe"+0x0012A3B0 → 0x10 → 0x1C → 0x20。这条链的意思是:
- 从
game.exe基址加0x0012A3B0,读出一个地址A。 - A加
0x10,读出地址B。 - B加
0x1C,读出地址C。 - C加
0x20,就是目标数值。
在CE里添加这个指针时,基址填"game.exe"+0x0012A3B0,偏移列表依次填0x10、0x1C、0x20。CE会自动逐级解引用。
我个人的经验是:指针层级不要盲目追求多。层级越多越脆弱,中间任何一级对象被释放都会导致链断掉。通常3级以内就能覆盖绝大多数场景。如果扫出来5级以上的链,先怀疑是不是扫错了。
6. 实战中那些教程不会告诉你的坑
前面讲的都是"正确流程",但实际操作中你会遇到各种意外。这一节专门讲踩坑。
6.1 扫描不到数值的几种真实原因
原因一:数值被加密存储。有些游戏会把金币乘以2再加1存储,或者做异或运算。你扫显示值永远扫不到。解决办法是扫"未知初始值",然后通过变动/未变动逐步逼近,最后看捕获到的指令里有没有解密逻辑。
原因二:数值是浮点但显示为整数。比如显示100,实际存的是100.0的Float。这时候用4 Bytes扫不到,得用Float扫,并且用范围。
原因三:数值是计算出来的。显示的金币 = 基础金币 + 加成金币。你扫显示值,内存里根本没有这个数。得分别扫基础值和加成值。
原因四:进程有保护。某些程序会检测调试器,CE附加后直接拒绝访问。这时候可以试试CE的VEH调试器模式,或者用内核模式(需要额外驱动,操作复杂)。
6.2 指针扫描失败的排查链路
指针扫描扫不到东西,按这个顺序排查:
- 确认目标地址是否稳定。重启程序,重新找一次地址,如果新旧地址差异巨大且无规律,说明这个地址是动态分配的,指针扫描可能无效。
- 检查最大偏移层级。默认3级可能不够,试4-5级。
- 检查最大偏移值。默认4096可能太小,试8192或更大。
- 确认扫描模块。只勾选主exe和必要的dll,不要全选。
- 确认目标地址在扫描时是有效的。如果扫描时数值已经变了,扫出来的路径是错的。
我遇到最坑的一次是:目标地址在一个临时对象里,每次操作都会重新分配。这种情况下指针扫描根本无解,只能通过注入代码在对象创建时hook,记录下稳定的访问点。
6.3 注入代码导致崩溃的常见原因
崩溃基本逃不出这几个:
- 指令长度不对齐:替换的
jmp是5字节,原指令只有3字节,剩下2字节没处理,后面的指令错位。 - 寄存器没保护:注入代码改了原逻辑依赖的寄存器。
- 栈不平衡:注入代码里
push了但没pop。 - 跳转地址错误:
returnhere标签指向的地址不对,跳回去执行了错误的指令。 - 分配的内存被回收:
alloc的内存如果没被正确引用,可能被程序的其他分配覆盖。
排查方法:CE的反汇编窗口能单步执行,你可以一步步看注入代码的执行流程,找到崩溃点。
7. 把整套流程串起来:一个完整的修改案例
前面分模块讲了,这里用一个完整案例把流程串一遍。目标:让某个单机游戏的金币在重启后依然保持修改状态。
第一步,定位数值。打开CE附加进程,4 Bytes精确扫描当前金币值,通过消费金币触发变化,再次扫描收敛到1个地址。
第二步,验证地址。改数值看游戏反应,确认是真实地址。
第三步,找写入指令。右键"找出是什么改写了这个地址",触发一次金币变化,捕获到mov [eax+0x1C], edx。
第四步,指针扫描。对目标地址做指针扫描,层级设3,偏移设4096。扫完后重启游戏,重新定位金币地址,用"重新扫描内存"过滤候选路径。重复两次,得到稳定路径"game.exe"+0x0012A3B0 → 0x10 → 0x1C。
第五步,验证指针。手动添加指针地址,重启游戏确认有效。
第六步,持久化。在CE里保存这个指针地址到CT表(Cheat Table),下次直接加载即可。如果想让修改自动生效,可以写一个自动汇编脚本,在游戏启动时自动应用。
第七步,可选注入。如果金币会被游戏逻辑覆盖,就注入代码锁定。用自动汇编把写金币的指令改成写入固定值。
整个流程走下来,你对这个游戏的金币系统就有了完整的理解:它存在哪、怎么被访问、怎么被修改、怎么持久化。这套方法论可以迁移到任何单机程序的数值研究上。
8. 关于工具边界与学习路径的几点体会
CE是个工具,工具本身没有对错,关键看用在什么场景。我的原则很简单:只在自己有权限的环境里做研究,比如单机游戏、自己写的测试程序、CTF逆向题。任何涉及线上服务、多人环境、他人系统的操作,都不在讨论范围内。
从学习路径来说,我建议的顺序是:
- 先用CE改几个单机游戏的简单数值,熟悉扫描流程。
- 然后学"找出是什么改写了这个地址",理解汇编层面的访问。
- 接着练指针扫描,这是分水岭,过了这关才算入门。
- 最后学代码注入,理解程序逻辑的修改。
每一步都不要跳。我见过太多人直接跳到代码注入,结果连寄存器是什么都没搞明白,写出来的脚本全是崩溃。
另外,CE自带一个教程程序(Tutorial),里面有从基础扫描到指针到注入的完整练习。把这个教程从头到尾做一遍,比看十篇教程都管用。它每一步都有验证机制,你做对了它才让你进下一步,非常适合打基础。
最后说一个心态问题:内存修改这件事,失败是常态,成功是偶然。一个数值扫不到,可能是类型错了、可能是加密了、可能是计算出来的。你需要有耐心去一个个排除。我改一个复杂数值花过整整一个下午,但当你最终找到那条稳定的指针链时,那种"看透程序"的感觉是值得的。