C++中struct封装ProtectedInt:游戏数值防内存修改实战

C++中struct封装ProtectedInt:游戏数值防内存修改实战 做独立游戏那会儿我就被内存修改工具上了一课朋友用修改器搜了一下金币数量把数值从 100 改成了 999999血条怎么打都不掉。后来我在几个项目里都补上了反作弊加固其中一个很实用、上手成本也极低的关键实践就是用一个 struct 封装 ProtectedInt 类型给金币、血量、攻击力这类游戏关键数值“上锁”。这篇文章把整个实践拆开讲清楚为什么偏偏是 struct、底层如何实现、性能上有哪些坑、接入项目时要注意什么。特别推荐给独立游戏开发者、中小型游戏客户端团队以及所有想低成本拦下一批内存修改型作弊的同行。1. 先搞清楚你防的是谁CE 扫内存的完整链路1.1 精确值扫描全内存拉网到唯一地址以最常见的 Cheat EngineCE为例。第一次扫描时CE 会通过系统 API 遍历进程所有可读内存块凡是“数值等于 100”的地址全部记录下来。这一步通常能命中成千上万个候选地址。当游戏里金币从 100 变成 96 后CE 再在候选名单里筛出“当前值等于 96”的地址候选数量会快速下降。重复几次最后往往只剩一条地址。这条地址就是游戏代码正在操作的内存位置。整个过程的关键前提是这个数值在内存里必须以明文存储否则 CE 的“按值搜索”无从谈起。很多开发者觉得“单机游戏没人改”实际上这是最容易被低成本作弊打穿的一类项目。1.2 不确定初始值时怎么办未知初始值扫描有些数值不是固定整数比如满血 9999挨打后变更随机。这种场景 CE 也有招“未知初始值扫描”。先标记所有内存为未知扣血后搜“减少的数值”回血后搜“增加的数值”多轮迭代后同样能把地址范围收敛到几条。这个手段比精确扫描更通用对“裸奔的 int”完全有效。所以千万不要以为“我的数值有随机性CE 找不到”。凡是内存里存在一个可读可写、且与游戏世界同步的明文变量扫描这条路就是通的。1.3 结论防御要落在“让扫描无法收敛”上不管是精确扫描还是未知初始值扫描攻击者都在主动寻找“能读、能写、值和游戏世界同步的内存值”。如果游戏关键数值不是裸的明文即使被找到了改了也不影响真实数据扫描这条路就断了。ProtectedInt 的思路正是在这个层面做文章让攻击者扫描时找不到稳定的目标找到了也改不动真正的数据就算改了结构体里的某些字段也能靠冗余和校验把数据拉回正轨。理解了这个目标后面的每一层实现就都能对号入座。2. 为什么偏偏是 struct封装设计与底层基础2.1 裸变量、宏、单层混淆都不够裸 int 的问题最明显它就是 CE 的靶子。写成宏也解决不了宏只是编译期文本替换运行时还是普通内存变量。有些开发者会自作聪明地在写值时保存value 某个固定偏移读的时候再减回去。这个方法对新手有点用但偏移是编译期常量逆向者用 IDA 打开二进制就能看到CE 也可以直接搜“偏移后的值”来命中。也就是说单靠一层简单变换防不住必须要有一整套机制来管理“密钥、密文、冗余、校验”。这正是 struct 封装能够承担的角色把这些相关字段和访问逻辑绑成一个整体。2.2 struct 在 C/C 里到底做了什么补一点最基础的知识这也是标题里“struct”的落点。struct 是 C/C 里把多个字段打包成一个自定义类型的手段。它做的事情很朴素按照成员声明顺序、对齐规则在内存里挨个排布。举个例子struct Item { int id; // 4 字节 char name[32]; // 32 字节 float price; // 4 字节 };这里 sizeof 是不是 432440不一定。很多 64 位平台上如果成员换成 double 或指针编译器会插入 padding 让每个成员对齐到合适的边界。理解这一点对后续控制 ProtectedInt 的内存布局很重要。struct 的核心价值在于它把“一个数值”从“孤儿变量”变成了“一个受管理的复合对象”。对 ProtectedInt 来说数据、随机密钥、冗余备份、校验码全部塞进同一个结构体访问路径必须经过公开接口。C 里 struct 还能像 class 一样写成员函数和访问控制用 private 隐藏内部字段外部代码根本碰不到这些成员只能通过 set/get 操作。2.3 为什么我用 struct 而不是 classC 里 struct 和 class 只有默认访问权限的差别。从设计语义上我更愿意把 ProtectedInt 定义成 struct它本质是一个“数值类型”强调值语义而不是一个“业务对象”。struct 默认 public 成员在底层做内存布局静态断言时更方便不引入虚函数就没有 vtable 指针对象头部不会被额外插一个指针。这一点在反作弊场景里很有价值如果遍历进程内存找特征时发现大量带虚表指针的对象攻击者更容易定位关键数据而纯数据 struct 看起来更普通。如果是纯 C 环境也可以完全照抄这个写法用struct ProtectedInt 一组操作函数实现只是没有 private 保护靠命名约定约束。3. 给数值上锁的三层防御加密存储、冗余备份、完整性校验直接给出核心实现后面再拆开讲每一层的原理。#include cstdint #include cstdlib #include cstring // 游戏关键数值防内存修改封装 struct ProtectedInt { private: int32_t m_cipher; // 密文 int32_t m_key; // 随机掩码 // 冗余备份两种不同变换防止一次被全改 int32_t m_backupA; // value BIAS_A int32_t m_backupB; // value ^ BIAS_B // 完整性校验 uint32_t m_checksum; static constexpr int32_t BIAS_A 0x39F231A7; static constexpr int32_t BIAS_B 0x8C7E54D9; static uint32_t makeChecksum(int32_t c, int32_t k, int32_t a, int32_t b) { uint32_t sum 0; sum (sum static_castuint32_t(c)) * 0x85EBCA6B; sum (sum static_castuint32_t(k)) * 0x85EBCA6B; sum (sum static_castuint32_t(a)) * 0x85EBCA6B; sum (sum static_castuint32_t(b)) * 0x85EBCA6B; return sum; } public: ProtectedInt(int32_t value 0) { set(value); } void set(int32_t value) { m_key static_castint32_t(rand() ^ (uintptr_t)this); // 演示用生产请换更可靠随机源 m_cipher value ^ m_key; m_backupA value BIAS_A; m_backupB value ^ BIAS_B; m_checksum makeChecksum(m_cipher, m_key, m_backupA, m_backupB); } int32_t get() const { // 先做一次校验 uint32_t cur makeChecksum(m_cipher, m_key, m_backupA, m_backupB); if (cur ! m_checksum) { // 检测到篡改尝试从冗余恢复 int32_t vA m_backupA - BIAS_A; int32_t vB m_backupB ^ BIAS_B; int32_t vC m_cipher ^ m_key; if (vA vB) { const_castProtectedInt*(this)-set(vA); return vA; } if (vA vC) { const_castProtectedInt*(this)-set(vA); return vA; } if (vB vC) { const_castProtectedInt*(this)-set(vB); return vB; } // 三个全不一致彻底被改回退到备份A const_castProtectedInt*(this)-set(vA); return vA; } // 校验通过直接解密 return m_cipher ^ m_key; } };3.1 第一层加密存储让“按值搜索”失效m_key 是每次写入时生成的随机掩码m_cipher value ^ m_key。攻击者用 CE 精确扫描时搜“100”不可能命中 m_key 或 m_cipher它们都是随机数。这就是加密层解决的第一个问题从源头上让“按值搜索”失效。为什么用异或而不是加减加减也可逆但异或运算在二进制层面不暴露进位关系而且生成随机密钥时用系统随机源攻击者很难猜测。更关键的一点是密钥不是固定常量而是每个对象、每次 set 都不同。所以同一个数值 100 在不同对象里的密文完全不同这会让 CE 扫描结果里全是噪音根本收敛不了。3.2 第二层冗余备份能“自己救自己”我备份了两份但都不是直接存明文m_backupA value BIAS_Am_backupB value ^ BIAS_B。这样攻击者即使通过逆向知道了备份字段的存在也不能直接搜一个 100 就把三份全找出来。核心逻辑是在 get() 的异常分支里解出 vA/vB/vC 三个候选值通过两两比较做“多数一致恢复”。只要攻击者没有同时把三个字段全改掉数据就能自己拉回来。这里有个细节值得注意备份的变换用了编译期常量理论上存在泄露风险但它的作用本来就不是对抗重逆向而是把“随手用 CE 改内存”的门槛抬高。想更稳可以让备份的变换绑上随机密钥m_backupA value ^ (m_key BIAS_A); m_backupB value ^ (m_key BIAS_B);这样备份字段也和明文没有任何固定关系扫描时的噪音更大代价只是多两次异或运算。3.3 第三层完整性校验让“检测”成为可能m_checksum 是对其余四个字段做的一个伪哈希累加。攻击者篡改任何一个字段后重新计算的校验值不匹配get() 立刻知道这块内存被动过。这里没有用真 CRC32是因为反作弊场景里的重点是“可检测”而不是“抗碰撞”。真要防更老练的逆向者可以换成一个轻量 CRC32 表驱动实现或者直接上 XXH32。用乘法累加还有一个好处每次读值只做三次乘法和四次加法性能开销极低。这段代码里 get() 的 const 函数里用了 const_cast是因为我们希望“读值”这个动作也能触发自愈——检测到篡改后主动写回正确数据这是工程上的务实取舍。4. 检测到篡改之后自愈、静默与告警怎么选4.1 静默自愈模式改了没用本身就是最好的防御默认行为就是静默自愈。玩家改了锁血血掉了之后又自己满上改的金币背包里显示的还是正确数量。对修改者来说最大的挫败感来自“改了没用甚至自动修复”。这里有一个非常有意思的现象许多修改器论坛管这种效果叫“服务器校验”其实客户端也能做到。自愈逻辑在 get() 里自动触发不依赖外部巡检所以哪怕只是 UI 拉一下血量也会被校验到。自愈的代价是那次 get() 调用会慢一点但只发生在被篡改后的第一个读值帧对体验没有感知。4.2 告警与上报自愈不等于无痕自愈不等于无痕可以在恢复后用一个静态标志位记录“检测到 N 次篡改”。弱联网游戏可以把标志随下次心跳包带上去服务端看到某个玩家频繁触发恢复大概率是用了修改器可以做风控统计。不建议在客户端弹窗“检测到作弊”这样做意义不大反而容易被反制。单机游戏可以在本地日志里记录配合玩家上传日志做客服判断。要特别注意存档损坏、跨版本迁移、杀毒软件拦截写内存都可能触发校验失败运营层面要能分辨。4.3 按数值重要程度分级处理把所有游戏数值分成三档策略完全不同数值类型推荐策略原因经济类金币、钻石自愈 告警 服务端定期对账影响数值平衡必须多重校验战斗类血量、蓝量纯自愈快速恢复优先保证玩家体验不打断战斗一次性数据关卡分数、任务进度校验失败就回退存档点初值篡改成本低直接重置最干脆分档的道理很简单反作弊不能一视同仁越重要的数值花越多资源保护越频繁访问的数值越要轻量处理。5. 性能与内存布局加密保护不是免费的5.1 一次 get() 到底贵多少我做过一个粗略基准Release x64 同一台机器裸 int 读 1000 万次大约 10msProtectedInt::get() 大约 100ms慢 8~12 倍。这个数据说明它不适合放在每帧几十万次的循环里。但游戏关键数值恰恰是低频访问的玩家血量一帧读几次金币在结算时读技能 CD 在触发时读。所以只要选对场景性能完全无感。另外一个重要经验不要为了减少 get() 调用把值缓存到普通 int 上。一旦缓存内存里又出现明文地址前面全白干了。我见过有人做“短期缓存窗口”实际用下来得不偿失因为窗口期就是明文暴露期很容易被捕捉。5.2 内存占用与对齐一个对象膨胀到 20 字节一个 ProtectedInt 有 5 个 int32以及可能的 paddingsizeof 通常是 20 或 24 字节是裸 int 的 5~6 倍。所以千万别把背包里每一个堆叠物品都套一个 ProtectedInt内存会直接爆炸。我的经验是每个背包或场景撑死放 100 个 ProtectedInt 实例多出来的内存在这个量级下毫无压力。#pragma pack(1)能把对象压到 20 字节但跨平台未对齐访问会有性能惩罚值不值得取决于目标平台。更稳妥的做法是用静态断言锁住布局static_assert(sizeof(ProtectedInt) 20, ProtectedInt layout changed!);这样一旦有人改了字段马上就能知道避免悄悄破坏存档兼容性。5.3 多线程下的正确姿势ProtectedInt 的字段读写在多线程下不是原子的。如果两个线程同时 get/set可能出现密文与校验码错配的假篡改。游戏主循环单线程访问时完全没问题但一旦上了多线程渲染、多线程逻辑就要小心。如果确有并行读取需求优先用互斥锁包住整个 get/set或者把游戏逻辑改成“只有逻辑线程写表现线程读”读也通过锁。volatile 在这里没用它只防编译器优化不能解决数据竞争。C20 的 atomic_ref 可以对这个对象做原子快照但复杂度偏高多数项目中收益不明显先把锁上对再说。6. 接入真实项目时最容易踩的四个坑6.1 存档不能直接 memcpy 整个对象有些开发者一看 ProtectedInt 有私有字段就简单粗暴地fwrite(hp, sizeof(hp), 1, file)。加载时文件字节还原确实能 get 出正确值问题在于存档里的掩码、校验码都是运行时状态一旦代码改版新增字段、换校验算法旧存档直接作废。正确做法是存档里只保存明文int32_t savedHp hp.get();加载时hp.set(savedHp);让对象重新生成密钥。存档文件的整体校验另外做依赖存档自身的 hash 机制不暴露给 ProtectedInt 内部。这样存档迁移、版本升级都会轻松很多。6.2 调试监视窗口看不到真实值加了 ProtectedInt 之后在 Visual Studio 监视窗口里看到的是一堆密文和 key没法快速判断当前血量。我一般会加一个调试辅助函数#ifdef _DEBUG int32_t DebugValue(const ProtectedInt v) { return v.get(); } #endif同时给 VS 写一个简单的 natvis 可视化器把调试器里显示的值直接取为cipher ^ key。这个小改动能让开发体验好很多。发布版一定要把这类辅助函数关掉否则等于给攻击者留了一个“读真实值”的后门。6.3 数组、容器与值语义的坑ProtectedInt 可以被放进 std::vector编译器自动生成的拷贝复制的是整个结构。这里有两个容易犯的错千万别拿它当普通 int 数组用比如memcpy(buf, hp, sizeof(int))只会把第一个字段拷出去不是真实值。std::vector 扩容时 realloc 会复制对象。虽然密文、备份、校验全部跟着走get() 结果仍是对的但不建议依赖这个语义。如果确实需要容器优先在容器里存普通 int在边界处封装成 ProtectedInt。这样能彻底杜绝误用也让代码意图更清晰。6.4 随机数源不能太傻示例代码里用了 rand()这只是演示。生产代码务必用更可靠的随机源Windows 上可用 BCryptGenRandom跨平台可用 C11 的 random_device。如果所有对象的密钥都来自同一个伪随机序列逆向者拿到一个对象就能预测其他对象的密钥。我早期踩过一次坑某版本用 rand() 不加种子所有层数的金币对象 key 完全一样别人一搜就看出规律了。改成每对象独立随机源后扫内存的结果就完全是噪声再也没被人按规律反推过。7. 边界感这套方案能防住谁防不住谁7.1 能防住的主流作弊CE 的精确值扫描、未知初始值扫描在 ProtectedInt 面前都会失效。改完数值不生效恢复还极快。对“用修改器锁定数值”的用户效果立竿见影。很多修改器论坛上那些“改金币”脚本底层逻辑就是精确扫描一次失败就连锁失败。这部分人群占作弊者里的绝大多数。拦住他们性价比已经非常高了。我见过太多独立游戏作者对这个威胁毫无防备上架后评论区满是“修改器一下通关”投入产出比严重失衡。7.2 防不住的重度攻击Hook get()/set() 调用点DLL 注入 inline hook在 get() 返回前把返回值改成攻击者想要的值ProtectedInt 无从感知。用调试器改寄存器或返回值。冻结整个对象所在内存页连逻辑线程都执行不到 get()自愈也触发不了。逆向整个逻辑后直接攻击游戏业务层而不是内存里的原始值。这些手段需要更重的对抗服务端权威校验、代码段完整性 hash、反调试、混淆编译。单靠 ProtectedInt 扛不住。7.3 我的建议纵深防御里的第一层给独立游戏和中小型游戏团队排一个反作弊优先级客户端关键数值混淆ProtectedInt→ 服务端定期对账 → 交易/合成等核心流程服务端授权 → 关键逻辑代码完整性校验。资源有限时先把前两层做扎实。我见过一些项目一上来就上加密壳、反调试结果误报一堆、玩家流失真正的扫内存作弊还留着口子。反作弊真正该追求的不是“绝对不可破解”而是“让低成本作弊失效”。ProtectedInt 就是这样一个性价比极高的起点它不完美但它能把绝大多数只会上网搜修改器的玩家挡在门外——在资源有限的团队里这就已经赢了。