Cheat Engine多级指针逆向实战:从基址寻址到C++模拟读取的完整流程
2026/9/19 7:37:48 网站建设 项目流程

很多朋友玩Cheat Engine,最开始都是图个乐,改个金币、锁个血量,感觉挺爽。但真到游戏重启、所有修改全部失效的那一刻,才会意识到自己其实只摸到了皮毛。CE最核心、也最值钱的技能,不是找到那个会变的数值,而是找到那个永远不变的东西——基址,再把基址到具体数值之间那条弯弯绕绕的指针链彻底打通。这篇文章就围绕多级指针这个硬骨头,把从附加进程到C++模拟读取的完整流程拆成五个关键步骤,每一步该做什么、为什么这么做、踩过什么坑,都写清楚。适合刚学完CE基础扫描、想往逆向深水区走的朋友。

1. 动手扫数之前,先把"基址与指针链"这层窗户纸捅破

先说一个最常见的误区:很多人以为CE的"精确数值扫描"是在内存里搜一个数字,搜到了就能改。其实游戏进程内存里有成千上万个"100"(假设你在改血量),CE只是把这成千上万个可能的地址都列出来,然后靠你反复改变游戏数值来筛掉"没反应的地址",最后留下那个真正存放血量的地方。

这一步能成功,本质上是利用了程序在运行时的数据结构。问题在于:游戏里你这个角色的血量,几乎不可能是一个全局变量。它是一个对象(比如角色类的一个实例)的成员变量。这个对象本身又被另一个管理器对象用指针挂着,管理器又被游戏主逻辑对象用指针挂着……这一串"对象A的地址里存着对象B的地址,对象B的地址+C偏移才是血量"的关系,就是传说中的指针链。

很多讲CE的教程会说"多级指针就是套娃",这话没错,但也容易让人忽略一个重点:为什么游戏不直接把血量放全局变量里?因为现代游戏引擎几乎都是面向对象的。主城里有100个NPC,每个NPC有血量、坐标、AI状态,引擎不可能给每个NPC的血量都写一个全局变量,而是创建100个NPC对象,每个对象内部按照同一个结构体模板去排布内存。你要找的"某个NPC的血量",实际上是"NPC对象数组/链表 + 对象内偏移"的组合。这种设计天然就形成了多级指针。

理解这一点之后,再看所谓"破解游戏内存"这件事,就变得很朴素了:你用CE做的一切,都只是在还原程序作者写代码时定义的数据结构访问路径。基址是这个路径的起点,偏移是沿路的门牌号,最终地址指向的才是那个会变化、可以被你修改的数值。

2. 第一步:附加进程不是"打开就行",权限和反调试的坑得先排掉

CE附加游戏进程,表面上是"选择一个进程双击",实际上内部调用了一堆Windows调试API,比如OpenProcess、ReadProcessMemory、WriteProcessMemory。核心就一句话:你的CE要以足够高的权限去读取另一个进程的内存空间。

这也解释了两个非常常见的问题。

第一个问题是"为什么CE一附加,游戏就崩溃或消失了"。很多现代单机游戏、尤其是带反作弊的联机游戏,程序启动时会检查自己是否被调试器附加。CE作为调试器级别的工具,附加动作本身就会触发检测。如果是单机学习环境,我一般建议把CE的附加方式从默认的"Windows调试API"换成"通过内核驱动",有的情况能绕过一部分检测。另一个技巧是:先打开CE,再启动游戏,让CE以管理员权限运行(右键→以管理员身份运行),然后在游戏主菜单界面附加,而不是在激烈战斗时附加——后者在暂停游戏逻辑的瞬间,更容易触发超时或者显眼的卡顿。

第二个问题是"附加之后CE下方显示一片空白,读不到数值"。这通常是权限不够。Win10/Win11上,如果游戏是以管理员权限启动的,你的CE也必须是管理员权限,否则OpenProcess直接返回拒绝访问。还有就是游戏是64位进程,你的CE也必须是64位版本,这个现在一般不会搞错,但偶尔有人下了老版本32位CE去附加64位进程,结果进程列表里根本看不到。

附加成功后的自我检查方法很简单:在CE的地址列表里手动添加一个地址,比如"0x0000000000400000",然后看"内存查看"窗口能不能显示数据。如果全是问号或空白,说明没读进来,别急着扫数值,先把权限搞定。

3. 第二步:从"会变的数值"到"稳定的地址",这一步决定了后面能不能成

附加成功后,就进入CE教程里最经典也最容易被忽视的环节:数值扫描。

假设游戏里你的血量是100/100,你在CE的Value里输入100,Value Type选Exact Value,Scan Type选Exact Value,点First Scan。此时CE会给你几千甚至几万个匹配地址。接下来你让角色被打一下,血量变成75,回CE输入75,点Next Scan,地址列表会被大幅过滤。重复几次,最后剩下一两个地址,这就是血量的动态地址

到这一步为止,大部分教学视频就结束了,然后教你进内存查看器找"绿色地址",说绿色的才是基址。但我想多说几句,因为这个绿色地址的细节直接关系到你后面学多级指针能不能成才

在CE的地址列表里,如果你看到地址前面有个绿色的小箭头图标,意思是"这个地址是通过某个模块基址+偏移计算出来的静态地址",比如"gc.exe+0x1234567"。没有小箭头、纯黑色十六进制地址,那就是纯粹的动态堆地址,是malloc或new出来的内存块。

这二者的区别是什么呢?用大白话说,模块基址(比如gc.exe的基址)在程序每次启动时通常是同一个值(在没有ASLR的系统上完全一样,开了ASLR会变但偏移不变),动态地址则是每次程序启动都随机变。

所以想要"游戏重启后修改仍然生效",你必须锁定那个绿色静态地址。但问题来了:绿色地址不一定是最终血量地址,它可能只是指针链中的一个节点。我在做CE实战时就遇到过:血量地址是黑色动态地址,但顺着它向上找,一层、两层、三层,终于看到一个绿色模块地址。这种情况下,血量地址本身是动态的,但那个绿色模块地址是稳定的,通过它加上一串偏移才算最终的血量。

这里就引出了一个关键操作——不要满足于"找到能改的地址",要养成"找到它为什么是这个地址"的习惯。CE地址列表里右键那个最终地址,选"Find out what writes to this address"(找到什么改写了这个地址),这个功能才是通往多级指针的核心钥匙,下一步就靠它。

还有个小技巧:扫描数值的时候,如果游戏内数值是浮点数(比如血量是100.0),Value Type一定要选Float或Double,我见过太多人用4 Bytes扫了一下午扫不出结果,最后发现游戏用的是Float。另外,有的游戏血量显示虽然只有几百,但内存里其实存的是百分比、或者放大了10倍的数值,这需要你扫内存的时候多留个心眼,用"Unknown initial value"配合"Changed value / Unchanged value"来扫,而不是死磕精确值。

4. 第三步:用"是谁改写了这个地址"反向追踪,拿到第一级指针偏移

现在我们已经锁定了那个动态血量地址,假设它是0x12345678。接下来,右键这个地址,选"Find out what writes to this address"。CE会提示你"将在这个地址上下一个调试断点,当游戏尝试写入这个地址时,CE会弹出汇编指令"。你需要确认游戏处于运行状态(不是暂停状态),然后回游戏里让角色挨打一次,或者吃一个回复药,让血量产生变化。

回到CE,你会看到类似这样的汇编代码:

mov [rax+0x1C], edx

或者

add [rax+0x18], eax

这行汇编就是"元凶"——它是游戏引擎更新血量的指令。括号里[rax+0x1C]表示"以rax寄存器存的值为基地址,加上0x1C偏移,这个位置才是血量"。换句话说,血量地址0x12345678 = rax的值 + 0x1C。所以rax的值应该等于0x12345678 - 0x1C = 0x1234565C。

这时候CE界面下方通常会显示执行指令时各个寄存器的值,其中就有rax的具体值。如果你把这个rax的值当成一个地址,去内存查看器里看,会发现它里面存放着一个值——这个值就是"指向角色对象的指针"。再去CE里搜索这个rax值(这个值很可能不是一个常见的整数,而是一个像0x1E4F2A60这样的大数),你就能找到"谁保存了这个指针"。

这一步非常关键,很多人就是卡在这里:找到了一个偏移(0x1C),却不知道这个偏移是属于"哪个对象基址的偏移"。其实逻辑很简单:指令是mov [rax+内容], 内容,那么rax的值就是当前对象的首地址,偏移0x1C是这个对象里血量成员的位置。你现在要做的是,找到"存放rax这个值的那块内存"——也就是上一级指针。

操作上,记得在CE里勾选"Hex"复选框,用十六进制填写寄存器里的值来扫描。这里有个常见坑:寄存器显示的值可能是0000000001234565C这样的完整64位地址,但你扫的时候如果把高位的0也带上,CE有时会匹配不到,可以先试试用"8 Bytes"类型去扫,配合勾选Hex,一般都能扫到。

扫到的那一串新地址,就是"指向角色对象的指针的存放地址"。这个地址往往是动态的,继续对它重复"Find out what writes to this address",就能继续往上追到第二级、第三级。理论上你可以一路追到程序入口,但实际只需要追到某一级出现绿色模块地址为止。比如追到第三级,看到地址是gc.exe+0x2A3F50,说明指针链的起点就是主模块的一个全局变量,稳定可靠。

到这一步,我们已经有了一个完整的指针链雏形:

gc.exe + 0x2A3F50 → 存放第一级指针 +0x1C偏移 → 存放第二级指针 +0x30偏移 → 存放第三级指针 +0x18偏移 → 最后血量地址

但注意,这是你在"理论推导"层面得到的链条,手动验证的时候很容易因为一次寄存器值看错、一次偏移算错,导致整个链条断裂。所以在拿到这个链条后,我强烈建议用CE的"指针扫描"功能做一次自动化和交叉验证,这就是第四步的内容。

5. 第四步:指针扫描不是玄学,但筛选候选列表是有方法论的

CE自带一个非常强大的功能,叫Pointer scan(指针扫描),快捷键是Ctrl+Alt+P,在找到最终地址后,可以用它来扫描"最终地址可能的指针链"。这个功能特别适合多级指针场景。

操作流程是:在地址列表里选中那个最终血量地址,右键→"Pointer scan for this address"。CE会弹出一个配置窗口,让你设置扫描参数。这里的参数要理解清楚,否则扫出来的不是一堆垃圾就是什么都扫不到。

关键的几个参数:

  • Max level:最大指针层级。新手可以先设6到8级。设太大会让扫描时间指数级增长,设太小可能漏掉真正的链条。
  • Max offset:每级指针允许的最大偏移量。游戏对象数组/结构体一般在0x2000以内,设0x1000或0x2000够用。太大也会让候选列表膨胀。
  • Max pointer:最多扫描多少个指针地址。默认看起来很大,但实际扫描数量受限于进程内存大小。

点OK之后,CE要花十几秒到几分钟扫描。扫完会弹出一个巨大的候选列表,里面每一行都是一个"可能的指针链",格式大致是:

[ gc.exe + 0x2A3F50 ] + 0x1C + 0x30 + 0x18

这就是一条完整的多级指针链。列表里成千上万条,怎么筛选?

我的筛选顺序是:

  1. 优先看包含模块名的候选,比如[ gc.exe + xxx]或者[ nt.dll + xxx],这些是静态基址,稳定性最好。纯黑色地址开头的候选,重启游戏后极大概率失效。
  2. 看层级数是否和你手动追踪的一致。如果你手动追了3级,列表里那些层级数更多(比如6级)的候选,多半是绕了远路,优先级靠后。
  3. 找那些指针链逻辑"干净"的——每一级偏移都是正偏移(很少有负偏移),且偏移值不大。我自己遇到过层级里的负偏移候选也能用,但耦合程度高,游戏版本一更新就容易挂。

选好候选指针链后,点"OK"或"Add to address list",这条指针链会被加进CE地址列表。注意,这时候一定要重启一次游戏验证!重启游戏后,红色地址全部变成灰色失效,但这颗绿帽子的指针链如果重启后依然能正确解析出血量地址,才说明它是真基址。如果解析失败,说明候选筛选错了,回去重新选别的候选。

很多人以为指针扫描扫完就万事大吉,其实筛选候选和实机验证才是真正决定成败的环节。我在实际项目里扫一个多级指针,经常要试两三个候选才能找到重启后依然稳定的那条。为什么?因为指针扫描是"猜测+匹配"式的,它不知道程序作者真正的意图,只能从内存布局里找出数百万种组合,然后挑出看起来像指针链的那些。所以人工验证不可省。

这里再补充一个经验:如果你做的是Unity或Unreal引擎的游戏,多级指针的层级通常会比较深(有时会超过5级),因为引擎本身要经过"游戏世界管理器→关卡→Actor→角色组件→属性容器"这一大串。不要怕层级深,只要最后一条候选链经过重启验证没问题,哪怕8级也照用不误。

6. 第五步:C++模拟还原——用ReadProcessMemory把指针链变成代码

到这里,CE里已经拿到了一条稳定的多级指针链。但CE只是工具,真正要做成工具、脚本或者外部修改器,还得靠代码。这也是为什么我会在这里放一组C++模拟代码——把CE里看到的东西翻译成代码语言,你对多级指针的理解才真正“落地”

C++访问其他进程内存,核心API就三个:OpenProcess、ReadProcessMemory、WriteProcessMemory。多级指针的代码实现,本质上就是一层层"读指针——加偏移——再读指针"的循环。

先看一个简化的模拟场景。假设游戏内角色对象结构如下:

// 模拟游戏内部对象结构(仅用于理解偏移) struct HealthComponent { float currentHP; // 偏移 0x18 float maxHP; // 偏移 0x1C }; struct Character { HealthComponent* healthComp; // 偏移 0x30 }; struct Actor { Character* character; // 偏移 0x20 }; struct GameWorld { Actor* mainActor; // 偏移 0x10 };

在CE里找到的指针链可能是:

gc.exe + 0x2A3F50 -> GameWorld* + 0x10 -> Actor* + 0x20 -> Character* + 0x30 -> HealthComponent* + 0x18 -> float currentHP

翻译成C++代码,就是下面这样。注意这段代码不是直接复制到游戏项目里能跑,而是模拟“外部修改器”读取目标进程内存的通用写法

#include <windows.h> #include <iostream> #include <vector> #include <cstdint> int main() { // 目标进程ID,实际使用时可先用FindWindow + GetWindowThreadProcessId获取 DWORD processId = 12345; HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId); if (!hProcess) { std::cerr << "OpenProcess failed, error: " << GetLastError() << std::endl; return 1; } // 模块基址需要先从进程的模块列表里拿,这里假设是0x00007FF6A2A3F50 // 实际可用EnumProcessModulesEx或CreateToolhelp32Snapshot拿到模块入口点 uintptr_t moduleBase = 0x00007FF6A2A3F50ULL; // 多级指针链:从基址开始,逐级解引用,然后加偏移 uintptr_t offsets[] = {0x10, 0x20, 0x30, 0x18}; size_t levelCount = sizeof(offsets) / sizeof(offsets[0]); uintptr_t currentAddress = moduleBase + 0x2A3F50; // 第一步:基址本身 + 全局偏移 /* * 循环逻辑是: * 1. 读取当前地址中存放的“指针值” * 2. 再加上当前级偏移 * 3. 作为下一轮的地址 * 最后一轮加完偏移后,currentAddress就是血量地址 */ for (size_t i = 0; i < levelCount; i++) { if (i < levelCount - 1) { // 中间几级:读指针 uintptr_t pointerValue = 0; SIZE_T bytesRead = 0; BOOL ok = ReadProcessMemory(hProcess, (LPCVOID)currentAddress, &pointerValue, sizeof(pointerValue), &bytesRead); if (!ok || bytesRead != sizeof(pointerValue)) { std::cerr << "ReadProcessMemory failed at level " << i << ", error: " << GetLastError() << std::endl; CloseHandle(hProcess); return 1; } currentAddress = pointerValue + offsets[i]; } else { // 最后一层:只加偏移,不再解引用 currentAddress += offsets[i]; } } // 到这里 currentAddress 就是最终血量地址,读出来验证 float hp = 0.0f; SIZE_T bytesRead = 0; ReadProcessMemory(hProcess, (LPCVOID)currentAddress, &hp, sizeof(hp), &bytesRead); if (bytesRead == sizeof(hp)) { std::cout << "[+] current HP = " << hp << std::endl; std::cout << "[+] Address = 0x" << std::hex << currentAddress << std::endl; } else { std::cerr << "[-] Failed to read HP" << std::endl; } // 写内存:把HP锁成999 float newHp = 999.0f; WriteProcessMemory(hProcess, (LPVOID)currentAddress, &newHp, sizeof(newHp), NULL); // 再读一次验证 ReadProcessMemory(hProcess, (LPCVOID)currentAddress, &hp, sizeof(hp), NULL); std::cout << "[+] HP after write = " << hp << std::endl; CloseHandle(hProcess); return 0; }

这段代码的核心逻辑就一个循环解引用过程。很多人第一次写多级指针代码时犯的错,是把所有层级都当成"读指针+加偏移",最后一级也去读了一次指针,导致读出来的不是血量,而是地址。所以要特别注意循环边界:

// 判断当前是中间指针还是最终地址 bool isFinalLevel = (i == levelCount - 1); if (!isFinalLevel) { // 读指针 } else { // 只加偏移 }

这段代码里的模块基址,在真实场景中怎么拿?你要用CreateToolhelp32Snapshot遍历目标进程的模块列表,找到主模块(通常是exe文件名对应的模块)的MODULEENTRY32.modBaseAddr。这一步不能用硬编码,因为系统每次给模块分配的基址可能因为ASLR不同而不同。这也是为什么网上很多教程里CE找到的是gc.exe+0x2A3F50,代码里就要"模块基址+0x2A3F50"动态计算,而不是直接写一个绝对地址。

完整一点的模块基址获取方式大概是:

uintptr_t GetModuleBase(DWORD processId, const wchar_t* moduleName) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, processId); if (snapshot == INVALID_HANDLE_VALUE) return 0; MODULEENTRY32 modEntry; modEntry.dwSize = sizeof(MODULEENTRY32); uintptr_t result = 0; if (Module32First(snapshot, &modEntry)) { do { if (moduleName == nullptr || _wcsicmp(modEntry.szModule, moduleName) == 0) { result = reinterpret_cast<uintptr_t>(modEntry.modBaseAddr); break; } } while (Module32Next(snapshot, &modEntry)); } CloseHandle(snapshot); return result; }

这组代码写完,配合CE找到的指针链和数据偏移,就可以实现“重启游戏后依然能正确定位并修改血量”的外部修改器原型。再进阶一点,把偏移表换成std::vector<uintptr_t>,把修改逻辑封装成ReadMultiLevelPointerWriteMultiLevelPointer两个函数,你就有了一份能复用到其他游戏的基础代码库。

7. 多级指针的这个"多",到底要追到几层才算够?

我见过不少朋友在拿到一条3级或4级指针链之后,仍不满足,非要用指针扫描追到7级、8级,觉得层级越深越高大上。这里我想泼一盆冷水:多级指针不是深就好,而是“足够稳定、足够简单”就好。

追级数的原则是"追到绿色模块地址即可"。为什么会是这样?因为绿色模块地址(模块基址+偏移)是程序静态编译期就固定的,不随堆内存分配变化。当你找到gc.exe+0x2A3F50时,整条链的“根”已经稳定了,再往下不管有几级,都只是这棵树上的分支而已。游戏开发者再喜欢嵌套对象,也不可能把链表结构写成一个深不见底的套娃——他们的代码也需要维护和可读性。

实际上,游戏版本更新后,最容易变的不是指针链的深度,而是每一级的偏移值。因为程序员的类定义一变,结构体成员的内存排布就会变,之前+0x30的字段可能变成+0x38,甚至从类A挪到类B。这也是为什么所有正经的逆向分析都会强调"偏移跟着版本走"——你今天这套偏移值,明天游戏一更新可能就失效了。认识到这一点,你就不会在游戏更新后怀疑自己是不是能力退步了。

有一个实操习惯值得培养:拿到任何一条指针链后,把"最终地址的解析过程"和"CE里手动验证的每一步"截图或者记录下来。游戏更新后用CE重新附加,先手动走一遍链条,看看是哪一级偏移断了。修偏移比重新从零扫描省太多时间。我身边做单机修改器的朋友,日常维护就是"游戏更新→跑一遍指针链→改偏移→编译新版本",最多十分钟搞定。

8. 学会C++模拟之后,再往后怎么进阶?

如果你已经把上面这套流程走通了,那恭喜,多级指针这块你已经基本毕业。但逆向这条路很长,我觉得有几个方向是值得接着探索的。

第一个方向是代码注入与Hook。外部修改器通过ReadProcessMemory/WriteProcessMemory读写内存,本质上是在“偷看”和“篡改”目标进程的数据。但有些游戏或程序会把关键数值用算法加密后存在内存里,直接修改会立刻被检测甚至导致程序崩溃。这时候就需要往目标进程注入DLL,通过改写指令、Hook函数来在数据加解密之后、使用之前的那个瞬间动手脚。这个方向会让你从"内存视角"升级到"指令视角"。

第二个方向是理解汇编与调用约定。CE的汇编指令面板里,mov [rax+0x1C], edx这种指令背后,还藏着寄存器分配、函数调用参数传递(rcx、rdx、r8、r9,以及栈上的参数)等知识。如果你看懂了一整段游戏逻辑的汇编,就不只是改数值了,而是可以直接修改AI逻辑、修改掉落概率、修改函数返回值等。

第三个方向是应对反作弊与保护。市面上常见的单机反调试、反修改手段很多,比如检测调试器、CRC校验、内存保护、代码混淆等等。这一块水很深,而且边界很灰,我的建议是:如果你是出于学习目的,就只碰单机游戏和可公开的CTF题目,不要去碰任何联机游戏。

再补充一点:这个东西的技术价值不止于游戏外挂。多级指针、内存追踪、代码Hook这些能力,在企业安全、恶意软件分析、主机游戏汉化、老游戏修复、漏洞研究里都是基本功。学会CE和C++模拟读取,本质上是在学习"进程内存布局"和"指针在真实程序中的存在形式",这对理解任何中大型C++项目都有帮助。

9. 一些写在最后的大实话

本来计划写到C++模拟代码就收尾,但想了想,还是有必要把一些"安全边界"和个人心得放进来。这里说的边界不只是法律层面的,更是技术习惯层面的。

第一,不要用CE去修改任何在线游戏。现在主流的联机游戏都有内核级反作弊,CE一附加就有风险,轻则封号,重则被记录到异常行为库。技术练习完全可以在单机游戏上进行,Steam单机、各类独立游戏、甚至CE自带的教程程序(Tutorial-x86_64.exe)都是绝佳的练手靶场。CE自带那个"Tutorial"程序,专门讲了从一级指针到多级指针、从精确扫描到代码注入,是全网最被低估的逆向入门教材。

第二,学习逆向最好的心态不是"破解",而是"分析"。拿到一个程序,先琢磨它的对象是怎么组织的、指针链是怎么设计的、为什么这么设计,而不是一上来就想着怎么改。当你用分析的心态做逆向,积累的经验是可以迁移的;脑子里只想着"破解",那你学的就只是零散的操作步骤,换个游戏照样抓瞎。

第三,记录是提升最快的加速器。我在做这一行的时候,习惯用一个文档专门记录不同游戏、不同引擎的指针链结构、典型偏移值、模块基址的获取方法。时间久了,你会惊喜地发现:很多游戏虽然表面玩法完全不同,但底层引擎的指针链设计居然惊人地相似。比如不少Unity游戏,血量数值都挂在"角色组件+属性容器"这条链上,偏移值甚至都一样。这就是逆向从"玄学"变成"经验科学"的过程。

最后再分享一个我实操时的小技巧:CE里的地址列表建议命名规范一些,比如"HP_final""MP_base""pointer_lv1"这种,改数之前先存个.CT文件存档。很多人在CE里找到一堆地址后不存档,一关CE全没了,下次拿到新版本游戏又要从头扫。存档这习惯虽然不起眼,但配合多级指针链的"偏移记录",能让你在新游戏里快人一步。

多级指针这条路,说到底就是一层窗户纸,捅破了你回头看会发现,它不过是C++里那个让你头疼的"指向指针的指针"在真实世界里的实体化。把CE里的指针链和代码里的对象嵌套对上,你以后读任何项目的内存结构都会快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询