C++游戏逆向实战:从内存扫描到稳定修改整数变量

📅 2026/8/9 11:33:22
C++游戏逆向实战:从内存扫描到稳定修改整数变量
你是否曾好奇那些在游戏中“为所欲为”的修改器比如锁定生命值、无限金币其底层究竟是如何运作的很多初学者以为这需要高深的汇编和内核知识但实际上很多基础功能的核心仅仅是定位并修改内存中的一个整数变量。这篇文章要解决的正是这个看似简单却至关重要的起点如何在一个C编写的游戏进程中逆向分析、定位并修改一个整数变量如血量、金钱、分数。我们将彻底抛开“外挂”这个充满争议的标签专注于Windows平台下C程序的逆向工程与内存读写技术本身。这是安全研究、漏洞分析、游戏Mod开发乃至调试复杂软件都必须掌握的基础技能。我的核心判断是掌握整数变量的逆向是理解程序运行时内存模型的“钥匙”。它远不止是改个数字那么简单而是涉及指针寻址、数据结构、反汇编调试和进程间通信的综合性实践。盲目搜索教程只会得到一堆零散的“CECheat Engine使用截图”而不知其所以然。本文将带你从零构建一套清晰的、可落地的分析流程。你会学到原理层理解进程虚拟内存、变量在内存中的布局。工具层熟练使用调试器x64dbg和内存扫描器Cheat Engine进行协同分析。实战层针对一个简单的C自制Demo程序完成从定位到稳定修改整数变量的全过程。进阶层分析多级指针、面对动态地址的应对策略以及编写一个极简的C注入器。本文假设你具备基础的C语法知识。我们的所有操作将在虚拟机或完全合法的测试环境中进行目标程序为自行编写的Demo。请务必遵守法律法规仅将技术用于安全研究、软件调试或学习目的。1. 为什么从“整数变量”开始——逆向工程的第一个里程碑在逆向工程的庞大体系中选择整数变量作为起点绝非因为它“简单”而是因为它最具代表性。整数int是编程中最基础的数据类型之一在内存中占用固定长度通常是4字节布局规整。游戏中的血量、弹药、金钱、分数系统软件中的计数器、状态标志很多都以整数形式存在。从整数入手你能一次性串联起多个核心概念内存地址每一个变量都存放在内存的某个地址上。指针地址本身就是指针。理解指针才能理解后续的多级指针寻址。读写权限内存页有可读、可写、可执行属性修改变量需要找到具有写权限的内存区域。值的变化与扫描通过对比变量变化前后内存的状态可以快速定位目标地址这是内存扫描工具如Cheat Engine的基本原理。数据断点当某个地址的值被写入或读取时让程序中断是逆向分析函数逻辑的利器。如果你能透彻理解如何定位并修改一个整数那么面对更复杂的浮点数、数组、字符串乃至自定义结构体时你拥有的将是一套可迁移的方法论而不仅仅是某个特定工具的用法。这就是本文希望带给你的核心价值——授人以渔。2. 核心概念与原理程序如何在内存中“生活”在动手之前我们必须建立正确的认知模型。一个运行中的C程序进程其内存空间是独立且受保护的“沙盒”。2.1 虚拟内存与进程空间现代操作系统为每个进程分配一个独立的虚拟地址空间对于32位程序是4GB。这意味着你的程序“认为”自己独占了整个内存从地址0x00000000到0xFFFFFFFF。操作系统和CPU的MMU内存管理单元负责将虚拟地址映射到物理内存的实际位置。这保证了进程间的隔离性也使得我们分析的地址都是虚拟地址。2.2 变量在内存中的形态当你声明一个全局变量int g_health 100;时编译器会在程序的数据区通常是.data或.rdata节为它分配空间。这个变量会有一个固定的虚拟内存地址在程序每次加载时确定但可以通过“基址偏移”的方式相对固定后面会讲。数值100十六进制0x64会以小端序Little-Endian存储在对应的4字节内存中。假设地址是0x00FF1000那么在内存中看起来可能是64 00 00 00。对于局部变量它位于线程的栈上地址是动态变化的每次函数调用都可能不同这增加了定位难度也是我们初期优先关注全局或静态变量的原因。2.3 工具链简介我们将主要使用两款工具Cheat Engine (CE)一款强大的开源内存扫描与调试工具。它擅长通过变化的值快速定位内存地址并分析指针链。我们将用它进行初步扫描和指针分析。x64dbg一款开源的x86/x64调试器。它比CE更接近“专业”调试器提供强大的反汇编、代码跟踪、断点设置和寄存器查看功能。我们将用它进行深入的代码级分析。3. 环境准备构建我们的“实验室”安全与合法是第一原则。我们将在完全可控的环境中操作。3.1 创建目标演示程序我们自己编写一个简单的C控制台程序作为分析目标。这避免了法律和道德风险并让我们确切知道内部逻辑。步骤1安装开发环境确保你安装了Visual Studio 2019/2022社区版即可或支持C的编译器。步骤2编写目标程序创建一个新的C控制台项目命名为MemoryDemo。// MemoryDemo.cpp #include iostream #include windows.h #include conio.h // 我们要定位和修改的全局变量 int g_playerHealth 100; int g_playerGold 500; bool g_isRunning true; // 一个会修改health的函数模拟游戏逻辑 void TakeDamage(int damage) { g_playerHealth - damage; if (g_playerHealth 0) g_playerHealth 0; } // 一个会增加gold的函数 void AddGold(int amount) { g_playerGold amount; } void PrintStatus() { std::cout 当前状态 std::endl; std::cout 生命值(Health): g_playerHealth std::endl; std::cout 金币(Gold): g_playerGold std::endl; std::cout std::endl std::endl; } int main() { std::cout 演示程序已启动进程ID: GetCurrentProcessId() std::endl; std::cout 全局变量地址仅供参考: std::endl; std::cout g_playerHealth: 0x std::hex (uintptr_t)g_playerHealth std::dec std::endl; std::cout g_playerGold: 0x std::hex (uintptr_t)g_playerGold std::dec std::endl; while (g_isRunning) { PrintStatus(); std::cout 请选择操作: 1.承受伤害(-10) 2.获得金币(50) 3.退出 std::endl; char key _getch(); switch (key) { case 1: TakeDamage(10); std::cout 受到10点伤害 std::endl; break; case 2: AddGold(50); std::cout 获得50金币 std::endl; break; case 3: g_isRunning false; break; default: std::cout 无效输入。 std::endl; } system(cls); // 清屏Windows系统 } return 0; }编译注意事项在Visual Studio中默认可能使用Debug模式这会影响优化和内存布局。为了更贴近“真实”程序建议切换到Release模式进行编译并关闭“增量链接”在项目属性 - 链接器 - 常规中设置。编译生成MemoryDemo.exe。3.2 准备分析工具下载Cheat Engine从其官网下载并安装。下载x64dbg从其官网下载发布版解压即可使用。重要在虚拟机中运行这些工具和目标程序是一个好习惯。4. 实战流程定位并修改g_playerHealth现在我们假设自己不知道源代码仅通过逆向手段来定位并锁定g_playerHealth。4.1 第一步使用Cheat Engine进行初始扫描启动目标程序运行MemoryDemo.exe。记下它打印的进程ID和变量地址这是我们作为“上帝视角”的验证实际逆向时没有。启动Cheat Engine点击左上角电脑图标选择MemoryDemo.exe进程。首次扫描Value输入框输入当前生命值100。Scan Type选择Exact Value。Value Type选择4 Bytes因为int通常是4字节。点击First Scan。结果列表会显示成千上万个存储着100的内存地址。这太多了需要进一步筛选。让值变化在目标程序中选择操作1让生命值减少到90。再次扫描在CE中Value输入框改为90。点击Next Scan。结果列表会大幅减少可能只剩下几个或几十个地址。其中就包含了我们真正的g_playerHealth地址。验证与锁定在结果列表中选择一个地址通常是看起来像0x...的双击它添加到下方的地址列表。在地址列表里双击该地址的Value栏将其改为200然后按回车。切换回目标程序查看状态。如果生命值显示变成了200恭喜你找到了你可以勾选地址前的Active复选框并点击下面的Set/Change hotkeys为其设置一个快捷键如CtrlH来快速锁定数值。至此你完成了最基础的内存扫描与修改。但这非常脆弱——因为每次重启程序这个绝对地址都会改变。我们需要找到基址偏移的稳定指针。4.2 第二步使用x64dbg进行代码级分析寻找指针我们的目标是找到是什么代码在读写g_playerHealth并回溯出它的地址是如何计算出来的。附加进程打开x64dbgFile - Attach选择MemoryDemo.exe。下硬件访问断点在CE中我们已经知道了g_playerHealth的当前地址例如0x012F5000。在x64dbg的Memory Map视图AltM中找到该地址所在的内存区域。右键点击该地址选择Breakpoint - Hardware, Access - Byte。这会在该地址被读取或写入时中断程序。触发断点切换回目标程序选择操作1承受伤害或2获得金币只要涉及读写生命值或金币程序就会在x64dbg中中断。分析反汇编代码; 假设中断在类似这样的指令上 01351000 | 8B 0D 00 50 2F 01 | mov ecx,dword ptr ds:[012F5000] ; 读取g_playerHealth到ecx寄存器 01351006 | 83 E9 0A | sub ecx,A ; ecx ecx - 10 (受到伤害) 01351009 | 89 0D 00 50 2F 01 | mov dword ptr ds:[012F5000],ecx ; 将结果写回原地址关键指令是mov ecx, dword ptr ds:[012F5000]。[012F5000]就是我们要找的地址。但它是硬编码的绝对地址吗不一定。检查地址来源观察这条指令上方或附近的代码看012F5000这个地址是否是通过某个寄存器加偏移计算得来的。例如; 可能是这样的模式 01350FFA | A1 00 30 57 01 | mov eax,dword ptr ds:[01573000] ; 从某个基址加载值到eax 01350FFF | 8B 88 00 20 00 00 | mov ecx,dword ptr ds:[eax2000] ; eax 0x2000 偏移得到目标地址如果找到这种模式那么01573000可能是一个模块基址如exe或dll的加载地址加上一个固定偏移而0x2000是二级偏移。在我们的简单Demo中由于是Release模式且变量是全局的编译器可能直接使用相对于模块基址的偏移。这个偏移是固定的。计算静态偏移在x64dbg中按Alt E打开模块列表找到MemoryDemo.exe的基址例如0x01350000。用找到的变量绝对地址0x012F5000减去模块基址0x01350000。注意这里0x012F5000小于0x01350000说明变量可能在基址之前的某个固定位置这不太常见。实际上更可能的是变量地址是模块基址 偏移。让我们重新计算0x012F5000 - 0x01350000 -0xB000。负偏移这通常意味着变量在.data节而代码在.text节它们相对于镜像基址有不同的RVA相对虚拟地址。更可靠的方法在x64dbg的Memory Map中查看变量地址所属的区段Section如.data。记下该区段的Address起始地址。这个区段地址相对于模块基址的偏移是固定的。变量地址相对于该区段起始地址的偏移也是固定的。最终我们得到的是模块基址 固定偏移1或.data区段地址 固定偏移2的关系。由于模块基址每次运行会变但偏移不变所以我们需要用“模块名偏移”来表示指针例如MemoryDemo.exe0x2F5000。4.3 第三步在Cheat Engine中验证指针回到Cheat Engine在地址列表中找到我们之前添加的g_playerHealth地址。右键点击该地址选择Pointer scan for this address。按照向导进行CE会分析内存找出所有可能指向该地址的指针链。这个过程可能需要一些时间。扫描完成后会弹出一个指针列表窗口。我们需要寻找那些基址是模块名如MemoryDemo.exe的指针链。找到一条看起来可靠的链例如MemoryDemo.exe2F5000。双击它CE会将其作为一个指针添加到主地址列表。重启程序验证关闭并重新运行MemoryDemo.exe在CE中重新附加进程。然后查看刚才添加的指针地址。如果它的值能正确指向当前的生命值并且修改有效那么你就找到了一个静态基址偏移它不随程序重启而改变。5. 编写一个极简的C内存修改器理解了原理我们可以用C写一个小程序模拟外挂或调试工具的核心功能打开进程、读写内存。// SimpleMemoryEditor.cpp #include windows.h #include iostream #include tlhelp32.h DWORD GetProcessIdByName(const wchar_t* processName) { DWORD pid 0; HANDLE snapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot ! INVALID_HANDLE_VALUE) { PROCESSENTRY32W pe32; pe32.dwSize sizeof(pe32); if (Process32FirstW(snapshot, pe32)) { do { if (_wcsicmp(pe32.szExeFile, processName) 0) { pid pe32.th32ProcessID; break; } } while (Process32NextW(snapshot, pe32)); } CloseHandle(snapshot); } return pid; } uintptr_t GetModuleBaseAddress(DWORD pid, const wchar_t* moduleName) { uintptr_t baseAddr 0; HANDLE snapshot CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid); if (snapshot ! INVALID_HANDLE_VALUE) { MODULEENTRY32W me32; me32.dwSize sizeof(me32); if (Module32FirstW(snapshot, me32)) { do { if (_wcsicmp(me32.szModule, moduleName) 0) { baseAddr (uintptr_t)me32.modBaseAddr; break; } } while (Module32NextW(snapshot, me32)); } CloseHandle(snapshot); } return baseAddr; } int main() { // 1. 获取目标进程ID和模块基址 const wchar_t* targetProcess LMemoryDemo.exe; DWORD pid GetProcessIdByName(targetProcess); if (pid 0) { std::cout 未找到进程: targetProcess std::endl; return 1; } std::cout 找到进程PID: pid std::endl; uintptr_t moduleBase GetModuleBaseAddress(pid, targetProcess); if (moduleBase 0) { std::cout 获取模块基址失败。 std::endl; return 1; } std::cout 模块基址: 0x std::hex moduleBase std::dec std::endl; // 2. 打开进程获取操作句柄 HANDLE hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); if (hProcess NULL) { std::cout 打开进程失败。错误代码: GetLastError() std::endl; return 1; } // 3. 计算目标变量地址 (假设我们通过逆向找到了这个偏移) // 例如变量地址 模块基址 0x2F5000 uintptr_t staticOffset 0x2F5000; // 这个值需要你通过CE/x64dbg分析得到 uintptr_t targetAddress moduleBase staticOffset; std::cout 计算出的目标地址: 0x std::hex targetAddress std::dec std::endl; // 4. 读取当前值 int currentValue 0; SIZE_T bytesRead 0; if (ReadProcessMemory(hProcess, (LPCVOID)targetAddress, currentValue, sizeof(int), bytesRead)) { std::cout 读取成功。当前值: currentValue std::endl; } else { std::cout 读取内存失败。错误代码: GetLastError() std::endl; CloseHandle(hProcess); return 1; } // 5. 写入新值 int newValue 999; SIZE_T bytesWritten 0; if (WriteProcessMemory(hProcess, (LPVOID)targetAddress, newValue, sizeof(int), bytesWritten)) { std::cout 写入成功。新值: newValue std::endl; } else { std::cout 写入内存失败。错误代码: GetLastError() std::endl; } // 6. 关闭句柄 CloseHandle(hProcess); std::cout 按任意键退出... std::endl; std::cin.get(); return 0; }编译与运行使用Visual Studio创建一个新的控制台项目粘贴上述代码。确保MemoryDemo.exe正在运行。将代码中的staticOffset(0x2F5000) 替换为你通过CE分析得到的实际偏移量。以管理员身份运行你的SimpleMemoryEditor.exe因为PROCESS_ALL_ACCESS可能需要提升权限。观察输出并切换到MemoryDemo.exe查看生命值是否被修改。6. 运行结果与验证当你运行自制的内存修改器后控制台应输出类似以下信息找到进程PID: 12345 模块基址: 0x01350000 计算出的目标地址: 0x012F5000 读取成功。当前值: 90 写入成功。新值: 999同时MemoryDemo.exe程序界面中显示的生命值应立即变为999。验证成功的关键进程ID获取成功。模块基址获取成功非零。读取内存成功且读出的值与目标程序中当前显示的值一致。写入内存成功且目标程序中的值随之改变。如果任何一步失败请查看错误代码并进入下一节的排查环节。7. 常见问题与排查思路问题现象可能原因排查方式解决方案CE扫描不到值或结果过多1. 数值类型选错如int选成float。2. 程序使用了加密或校验和。3. 变量是局部变量地址每次变化。1. 确认变量类型4字节、浮点等。2. 尝试“未知初始值”扫描然后通过数值增减进行过滤。3. 尝试扫描“未改变的数值”或“改变的数值”。针对加密变量需要逆向解密函数本文不涉及。对于局部变量需定位调用它的函数并分析其在栈或寄存器中的位置。找到的地址重启后失效使用的是绝对地址而非基址偏移的指针。使用CE的“指针扫描”功能或使用x64dbg分析访问该地址的指令寻找其与模块基址或全局指针的关系。找到稳定的指针链。基址应选择系统模块如exe、dll的加载地址其偏移是固定的。x64dbg下断点后程序不中断1. 断点地址错误。2. 硬件断点数量有限通常4个。3. 程序有反调试检测。1. 确认断点地址是否与CE中找到的地址一致。2. 检查是否已设置过多硬件断点。3. 观察程序行为或使用插件隐藏调试器。使用内存访问断点Memory Breakpoint作为替代。对于反调试需要学习相关对抗技术这属于进阶内容。自制修改器OpenProcess失败权限不足。目标进程是系统进程或受保护进程。检查GetLastError()返回的错误码。常见的是ERROR_ACCESS_DENIED (5)。以管理员身份运行你的修改器。对于受保护进程如某些反作弊游戏普通方法无效这涉及驱动级技术风险极高且违法。Read/WriteProcessMemory失败1. 地址错误。2. 内存页不可读/写如代码区。3. 进程已退出。1. 用CE验证地址是否正确。2. 在x64dbg的Memory Map中查看该地址的权限。3. 检查进程是否还存在。确保地址计算正确。对于没有写权限的内存通常不是我们的目标变量所在。变量应在.data、.rdata或堆栈中。修改后游戏崩溃或被检测1. 修改了代码或关键数据结构。2. 游戏有内存校验机制。3. 修改了不该改的变量如数组长度。1. 确认修改的只是纯数据变量。2. 观察崩溃上下文分析是否为并发访问冲突。3. 了解游戏的反作弊机制。仅用于学习在商业游戏或软件上使用极易导致封号或法律风险。务必在自制Demo或明确允许的测试环境中练习。8. 最佳实践与深入学习的建议从静态分析开始在动态调试前先用IDA Pro、Ghidra等静态分析工具查看程序结构了解函数和字符串引用能事半功倍。理解调用约定熟悉__cdecl,__stdcall,__fastcall等调用约定这能帮助你在反汇编中理解函数参数传递。善用注释和标签在x64dbg或IDA中为你分析出的函数、变量添加有意义的注释和命名构建你自己的分析地图。关注数据结构和类C程序中的对象类实例在内存中有特定的布局vtable、成员变量。学习通过RTTI或模式识别来逆向类结构。学习反反调试了解常见的反调试技术如IsDebuggerPresent,NtQueryInformationProcess以及如何在调试器中绕过它们使用插件如ScyllaHide。合法合规是底线所有练习必须在你自己拥有完全控制权的程序上进行。对他人软件进行逆向工程可能违反《计算机软件保护条例》或最终用户许可协议EULA。转向建设性领域将逆向技能用于安全研究漏洞分析、恶意软件分析、软件兼容性修复、考古式学习优秀代码设计其价值和成就感远大于破坏性用途。9. 总结从整数变量到逆向思维通过这个针对“整数变量”的完整逆向实战我们走完了一个最小化的闭环扫描定位 - 动态调试 - 指针分析 - 编写读写工具。你学到的不仅仅是CE和x64dbg的按钮怎么点而是程序在内存中的生存状态变量不是孤立的它通过指针与代码和其他数据相连。动态分析的思路利用变化去定位利用断点去追溯。稳定性的追求从绝对地址到相对偏移是让修改持久化的关键。工具只是延伸核心是你的推理和对程序逻辑的理解。整数变量是入口门后的世界广阔而复杂。接下来你可以尝试用同样的方法论去分析浮点数变量如坐标、速度。数组和字符串如何定位数组基址和索引。结构体如何确定大小和成员偏移。函数调用如何定位并调用游戏内的函数。逆向工程是一门需要耐心、逻辑和大量实践的艺术。建议你反复练习本文的流程直到对每一步的原理都了然于胸。然后尝试分析一些开源的、古老的小游戏在合法的范围内不断挑战更复杂的目标。记住最强的“外挂”永远是你对系统深入的理解。