
很多朋友玩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里输入100Value Type选Exact ValueScan Type选Exact Value点First Scan。此时CE会给你几千甚至几万个匹配地址。接下来你让角色被打一下血量变成75回CE输入75点Next Scan地址列表会被大幅过滤。重复几次最后剩下一两个地址这就是血量的动态地址。到这一步为止大部分教学视频就结束了然后教你进内存查看器找绿色地址说绿色的才是基址。但我想多说几句因为这个绿色地址的细节直接关系到你后面学多级指针能不能成才。在CE的地址列表里如果你看到地址前面有个绿色的小箭头图标意思是这个地址是通过某个模块基址偏移计算出来的静态地址比如gc.exe0x1234567。没有小箭头、纯黑色十六进制地址那就是纯粹的动态堆地址是malloc或new出来的内存块。这二者的区别是什么呢用大白话说模块基址比如gc.exe的基址在程序每次启动时通常是同一个值在没有ASLR的系统上完全一样开了ASLR会变但偏移不变动态地址则是每次程序启动都随机变。所以想要游戏重启后修改仍然生效你必须锁定那个绿色静态地址。但问题来了绿色地址不一定是最终血量地址它可能只是指针链中的一个节点。我在做CE实战时就遇到过血量地址是黑色动态地址但顺着它向上找一层、两层、三层终于看到一个绿色模块地址。这种情况下血量地址本身是动态的但那个绿色模块地址是稳定的通过它加上一串偏移才算最终的血量。这里就引出了一个关键操作——不要满足于找到能改的地址要养成找到它为什么是这个地址的习惯。CE地址列表里右键那个最终地址选Find out what writes to this address找到什么改写了这个地址这个功能才是通往多级指针的核心钥匙下一步就靠它。还有个小技巧扫描数值的时候如果游戏内数值是浮点数比如血量是100.0Value 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 [rax0x1C], edx或者add [rax0x18], eax这行汇编就是元凶——它是游戏引擎更新血量的指令。括号里[rax0x1C]表示以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.exe0x2A3F50说明指针链的起点就是主模块的一个全局变量稳定可靠。到这一步我们已经有了一个完整的指针链雏形gc.exe 0x2A3F50 → 存放第一级指针 0x1C偏移 → 存放第二级指针 0x30偏移 → 存放第三级指针 0x18偏移 → 最后血量地址但注意这是你在理论推导层面得到的链条手动验证的时候很容易因为一次寄存器值看错、一次偏移算错导致整个链条断裂。所以在拿到这个链条后我强烈建议用CE的指针扫描功能做一次自动化和交叉验证这就是第四步的内容。5. 第四步指针扫描不是玄学但筛选候选列表是有方法论的CE自带一个非常强大的功能叫Pointer scan指针扫描快捷键是CtrlAltP在找到最终地址后可以用它来扫描最终地址可能的指针链。这个功能特别适合多级指针场景。操作流程是在地址列表里选中那个最终血量地址右键→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这就是一条完整的多级指针链。列表里成千上万条怎么筛选我的筛选顺序是优先看包含模块名的候选比如[ gc.exe xxx]或者[ nt.dll xxx]这些是静态基址稳定性最好。纯黑色地址开头的候选重启游戏后极大概率失效。看层级数是否和你手动追踪的一致。如果你手动追了3级列表里那些层级数更多比如6级的候选多半是绕了远路优先级靠后。找那些指针链逻辑干净的——每一级偏移都是正偏移很少有负偏移且偏移值不大。我自己遇到过层级里的负偏移候选也能用但耦合程度高游戏版本一更新就容易挂。选好候选指针链后点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.exe0x2A3F50代码里就要模块基址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_castuintptr_t(modEntry.modBaseAddr); break; } } while (Module32Next(snapshot, modEntry)); } CloseHandle(snapshot); return result; }这组代码写完配合CE找到的指针链和数据偏移就可以实现“重启游戏后依然能正确定位并修改血量”的外部修改器原型。再进阶一点把偏移表换成std::vectoruintptr_t把修改逻辑封装成ReadMultiLevelPointer和WriteMultiLevelPointer两个函数你就有了一份能复用到其他游戏的基础代码库。7. 多级指针的这个多到底要追到几层才算够我见过不少朋友在拿到一条3级或4级指针链之后仍不满足非要用指针扫描追到7级、8级觉得层级越深越高大上。这里我想泼一盆冷水多级指针不是深就好而是“足够稳定、足够简单”就好。追级数的原则是追到绿色模块地址即可。为什么会是这样因为绿色模块地址模块基址偏移是程序静态编译期就固定的不随堆内存分配变化。当你找到gc.exe0x2A3F50时整条链的“根”已经稳定了再往下不管有几级都只是这棵树上的分支而已。游戏开发者再喜欢嵌套对象也不可能把链表结构写成一个深不见底的套娃——他们的代码也需要维护和可读性。实际上游戏版本更新后最容易变的不是指针链的深度而是每一级的偏移值。因为程序员的类定义一变结构体成员的内存排布就会变之前0x30的字段可能变成0x38甚至从类A挪到类B。这也是为什么所有正经的逆向分析都会强调偏移跟着版本走——你今天这套偏移值明天游戏一更新可能就失效了。认识到这一点你就不会在游戏更新后怀疑自己是不是能力退步了。有一个实操习惯值得培养拿到任何一条指针链后把最终地址的解析过程和CE里手动验证的每一步截图或者记录下来。游戏更新后用CE重新附加先手动走一遍链条看看是哪一级偏移断了。修偏移比重新从零扫描省太多时间。我身边做单机修改器的朋友日常维护就是游戏更新→跑一遍指针链→改偏移→编译新版本最多十分钟搞定。8. 学会C模拟之后再往后怎么进阶如果你已经把上面这套流程走通了那恭喜多级指针这块你已经基本毕业。但逆向这条路很长我觉得有几个方向是值得接着探索的。第一个方向是代码注入与Hook。外部修改器通过ReadProcessMemory/WriteProcessMemory读写内存本质上是在“偷看”和“篡改”目标进程的数据。但有些游戏或程序会把关键数值用算法加密后存在内存里直接修改会立刻被检测甚至导致程序崩溃。这时候就需要往目标进程注入DLL通过改写指令、Hook函数来在数据加解密之后、使用之前的那个瞬间动手脚。这个方向会让你从内存视角升级到指令视角。第二个方向是理解汇编与调用约定。CE的汇编指令面板里mov [rax0x1C], 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_finalMP_basepointer_lv1这种改数之前先存个.CT文件存档。很多人在CE里找到一堆地址后不存档一关CE全没了下次拿到新版本游戏又要从头扫。存档这习惯虽然不起眼但配合多级指针链的偏移记录能让你在新游戏里快人一步。多级指针这条路说到底就是一层窗户纸捅破了你回头看会发现它不过是C里那个让你头疼的指向指针的指针在真实世界里的实体化。把CE里的指针链和代码里的对象嵌套对上你以后读任何项目的内存结构都会快很多。