做独立游戏那会儿,我就被内存修改工具上了一课:朋友用修改器搜了一下金币数量,把数值从 100 改成了 999999,血条怎么打都不掉。后来我在几个项目里都补上了反作弊加固,其中一个很实用、上手成本也极低的关键实践,就是用一个 struct 封装 ProtectedInt 类型,给金币、血量、攻击力这类游戏关键数值“上锁”。这篇文章把整个实践拆开讲清楚:为什么偏偏是 struct、底层如何实现、性能上有哪些坑、接入项目时要注意什么。特别推荐给独立游戏开发者、中小型游戏客户端团队,以及所有想低成本拦下一批内存修改型作弊的同行。
1. 先搞清楚你防的是谁:CE 扫内存的完整链路
1.1 精确值扫描:全内存拉网到唯一地址
以最常见的 Cheat Engine(CE)为例。第一次扫描时,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 是不是 4+32+4=40?不一定。很多 64 位平台上,如果成员换成 double 或指针,编译器会插入 padding 让每个成员对齐到合适的边界。理解这一点对后续控制 ProtectedInt 的内存布局很重要。
struct 的核心价值在于:它把“一个数值”从“孤儿变量”变成了“一个受管理的复合对象”。对 ProtectedInt 来说,数据、随机密钥、冗余备份、校验码全部塞进同一个结构体,访问路径必须经过公开接口。C++ 里 struct 还能像 class 一样写成员函数和访问控制,用 private 隐藏内部字段,外部代码根本碰不到这些成员,只能通过 set/get 操作。
2.3 为什么我用 struct 而不是 class
C++ 里 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_cast<uint32_t>(c)) * 0x85EBCA6B; sum = (sum + static_cast<uint32_t>(k)) * 0x85EBCA6B; sum = (sum + static_cast<uint32_t>(a)) * 0x85EBCA6B; sum = (sum + static_cast<uint32_t>(b)) * 0x85EBCA6B; return sum; } public: ProtectedInt(int32_t value = 0) { set(value); } void set(int32_t value) { m_key = static_cast<int32_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_cast<ProtectedInt*>(this)->set(vA); return vA; } if (vA == vC) { const_cast<ProtectedInt*>(this)->set(vA); return vA; } if (vB == vC) { const_cast<ProtectedInt*>(this)->set(vB); return vB; } // 三个全不一致:彻底被改,回退到备份A const_cast<ProtectedInt*>(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_A,m_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 万次大约 10ms,ProtectedInt::get() 大约 100ms,慢 8~12 倍。这个数据说明它不适合放在每帧几十万次的循环里。
但游戏关键数值恰恰是低频访问的:玩家血量一帧读几次,金币在结算时读,技能 CD 在触发时读。所以只要选对场景,性能完全无感。另外一个重要经验:不要为了减少 get() 调用,把值缓存到普通 int 上。一旦缓存,内存里又出现明文地址,前面全白干了。我见过有人做“短期缓存窗口”,实际用下来得不偿失,因为窗口期就是明文暴露期,很容易被捕捉。
5.2 内存占用与对齐:一个对象膨胀到 20 字节
一个 ProtectedInt 有 5 个 int32,以及可能的 padding,sizeof 通常是 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 在这里没用,它只防编译器优化,不能解决数据竞争。C++20 的 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,跨平台可用 C++11 的 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 就是这样一个性价比极高的起点:它不完美,但它能把绝大多数只会上网搜修改器的玩家挡在门外——在资源有限的团队里,这就已经赢了。