那是一个周末,我刚把一个成长系统放进测试服。还没等我看完后台日志,就有两个玩家离线前用同一种姿势“卡”出了超过服务器上限的金币,接着在排行榜上来了一波操作。查来查去,问题出得特别朴素:客户端内存里的int gold被改了,改完又通过正常协议同步到了服务器。
当时我犯过一个新手式错误——把金币、血量这些关键数字从int改成数组,再用索引去访问。结果修改器换了一种扫描方式,又把数组揪出来了。真正让我停下来想明白的,是这件事:只有改变数据在内存里的整体形态,才能把“内存搜索修改”这条路的基本盘堵住。后来我把这套思路落成了一个结构体ProtectedInt,专门用来给游戏里的关键数值“上锁”。这篇文章就把完整的设计思路、实现代码、接入方式和踩坑记录都摊开讲一遍。
如果你是做客户端游戏的开发者,或者正在应付那些只会用内存修改器改数值的不速之客,这应该是一篇能直接抄作业的参考。就算你最后不会全盘采用这套方案,里面的“为什么这么设计”也值得看两遍。
1. 从一次修改金币的实测说起:裸 int 存在的真正问题
大多数新手游戏项目里,金币、血条、弹药这类数值就是简单的全局变量或结构体成员。开发者总觉得“玩家看不到内存就行”,但事实是,任何能跑在用户机器上的修改器,都有能力看到完整的内存布局。
1.1 修改器的“五步搜索”是如何找出变量地址的
一个内存扫描工具(类似 Cheat Engine)找数值的套路,我说出来你可能觉得简单到发指:先让游戏暂停,搜索当前金币的数值;回游戏里花掉一点金币,再搜索“减少了多少”;花两次之后,候选地址从几百万条缩到几十条;锁定唯一地址后,直接双击改成999999。
这背后依赖的核心逻辑只有一条:目标内存中的值,在某个时间点能够与一个可预测的数值匹配,并且在变更时也有可预测的增量。玩家操作和数值变化之间是有因果关系的,修改器只要通过“过滤法”不断缩小范围,裸int就藏不住。
我后来做过一个测试:在测试版本里把金币定义成:
int gold;然后模拟一个正常玩家的操作序列,修改器从首次扫描到最终锁定地址,全程不到三分钟。锁定之后把值改成任意数字,游戏内立刻显示出来。这就是裸int最真实的安全水平。
1.2 常规加密全局变量的缺陷
看到这里,很多有经验的开发者会说:那我直接用异或加密存,行不行?行,但要注意,很多人的实现是写成下面这样的:
int enc_gold = 100 ^ 0x5A5A5A5A; int get_gold() { return enc_gold ^ 0x5A5A5A5A; } void set_gold(int v) { enc_gold = v ^ 0x5A5A5A5A; }这个方案比裸int好一点,但依然很脆弱。第一,密钥0x5A5A5A5A是写在代码里的常量,只要有人用调试器找到get_gold函数,反编译后这个常量立刻暴露。第二,游戏的每次运行密钥都一样,意味着加密后的值也是固定的,只要不同账号、不同进度的玩家运行时,扫描两次就能通过“加密数据模式”反推密钥。
所以这个问题的本质并不是“要不要加密”,而是密钥是否每实例唯一、校验是否独立、存储形态是否离散。只对裸变量做一层固定异或,本质上只是把门从纸糊的换成了木头做的。
2. 设计 ProtectedInt:让内存里不再出现明文数值
我第一次用 struct 来封装这个逻辑时,其实没有想得很宏大。目标只有三个:内存里不出现明文、同一份数值存在多个冗余副本、每次读取时都要做一致性校验。看起来简单,但合并到一起之后,攻击者的成本就会从“三分钟”上升到“需要逆向好几小时”。
2.1 设计目标:让每次扫描结果都“不知所值”
第一个要解决的问题,是让修改器的过滤法直接失效。
如果游戏里有一个数值,它永远不会以原始大小出现在内存里,那么“搜索当前值”这第一步就会失败。修改器搜100找不到,搜“未知初始值”再过滤“减少”也找不到稳定的目标,因为玩家每次花金币时,内存里变化的不是那 4 个明文字节,而是结构体里好几个关联字段。攻击者看到的内存变化是一团乱麻,自然就无从下手。
这也是为什么我在设计时故意放弃了“只对明文做简单异或”这种极简写法。要在结构体里同时保存加密副本、校验信息和随机盐。每次游戏启动、每次生成新实例时,随机盐都不一样,哪怕两个玩家拥有完全相同的金币数,它们在内存里的呈现形式也完全不同。
2.2 结构体字段怎么摆:为什么是“盐 + 双副本 + 校验和”
ProtectedInt的完整内存布局是这样规划的:
struct ProtectedInt { uint64_t salt; // 随机盐,实例创建时生成 uint32_t store[2]; // 同一数值的两个加密副本 uint32_t check; // 校验和,用于检测篡改 };每个字段我都不是拍脑袋定的:
salt有 64 位,用来生成加密用的密钥。它保证每个实例的“加密形态”都不一样。store[2]存两份加密副本。为什么要两份?因为如果只存一份,攻击者改掉这个字段,校验和马上能发现,但他也可以把校验和一起改掉,一次改两个字段并不难。如果存两份,攻击者得同时修改两份副本、还有校验和,三个字段必须保持一致,改造成本线性上升。check是校验和。我用它把原始值和一个派生出来的“局部盐”混合起来算。注意校验和不是简单地value + salt,我会用到一个更抗碰撞的哈希函数,后面代码部分会展开。
用 struct 而不是散落的几个全局变量,还有一个容易被忽略的隐蔽优势:调试器在扫描内存布局时,全局变量会排在一块固定的内存区域,而 struct 是作为对象的一部分存在的。攻击者如果想写一个通用的“找金币偏移”辅助脚本,他会发现每个对象的 salt 和 store 字段都在不同偏移,偏移表没法像以前那样写死。
2.3 为什么不用 class 而坚持用 struct
代码风格上,我明确选择了struct而不是class。不是因为 C++ 里两者性能有差别,而是因为:
- struct 在语义上更贴近“内存形态”,更容易让团队理解这是一个值类型,而不是业务逻辑。
- struct 默认公开成员,写运算符重载时更顺手。
- 在需要与 C API 交互或者做内存归档时,struct 的布局更可控。
说白了,这是一个心智模型的问题。一个人看到class ProtectedInt,很容易往继承、多态、虚函数的方向想;而看到struct ProtectedInt,就会意识到“这只是一段带防护的内存”。
3. 手写 ProtectedInt:从初始化到运算符重载
理论基础说完了,直接上代码。这个版本我已经用在项目里,不是教学 Demo,是可以照抄的程度。
3.1 初始化与 set/get 的核心实现
整个结构体最关键的是三个操作:初始化、写入、读取。写入时只更新加密副本与校验和,读取时先解密再校验,校验不通过就返回默认值并报异常。
#include <cstdint> #include <cstdlib> // 一个轻量级整数哈希,用来算校验和 static inline uint32_t mix32(uint32_t x) { x ^= x >> 16; x *= 0x85ebca6b; x ^= x >> 13; x *= 0xc2b2ae35; x ^= x >> 16; return x; } struct ProtectedInt { uint64_t salt; uint32_t store[2]; uint32_t check; // 初始化:生成随机盐,然后复用 set 逻辑 void init(int32_t v) { salt = ((uint64_t)rand() << 32) ^ (uint32_t)rand(); set(v); } void set(int32_t v) { uint32_t raw = (uint32_t)v; store[0] = raw ^ (uint32_t)salt; store[1] = raw ^ (uint32_t)(salt >> 32); check = mix32(raw ^ (uint32_t)(salt >> 16)); } int32_t get() const { uint32_t raw0 = store[0] ^ (uint32_t)salt; uint32_t raw1 = store[1] ^ (uint32_t)(salt >> 32); uint32_t ck = mix32(raw0 ^ (uint32_t)(salt >> 16)); // 两份副本不一致,说明被局部篡改 if (raw0 != raw1) return 0; // 校验和不一致,说明数据被改过 if (ck != check) return 0; return (int32_t)raw0; } };有几个细节值得展开讲。
第一,set里计算校验和时,我没有直接对raw取哈希,而是混入了salt >> 16。这样攻击者就算知道校验和算法,不知道怎么拿到完整的 64 位盐,也没法伪造出正确的校验值。
第二,get里做校验时并没有直接信任store[0]或store[1],而是先把两份都解密出来,然后做了一次“冗余比对”。如果攻击者只改了一份副本,或者只改了校验和而没改另一份副本,这个函数会直接发现异常。
第三,返回0并不是最好的策略,它只是一个安全兜底。在真实项目里,我习惯把这种异常情况交给上层处理,比如记录一条log、触发一次“疑似作弊”标记,或者从服务端状态里做一次权威拉取。因为get()是高频调用,直接在这里做太多事会影响游戏流畅度。
3.2 升级校验:用哈希函数取代简单的值加盐
很多初版 ProtectedInt 实现会写这种校验和:
check = raw + (uint32_t)salt;这种加法校验的问题在于:攻击者只要对整块内存做一次memcpy级别的复制,然后在不破坏其他字段的前提下,把 store 和 check 按偏移全部改掉,就能伪装成“合法数据”。因为加法校验对字节排列不敏感,两个字段顺序换一下校验值完全一样。
所以我在实际项目里选择了类似mix32这种小型混合哈希。它足够快,一次计算大概几个纳秒,同时具备雪崩效应:原始值只要变动 1 位,校验和就会完全不一样。校验和的目标不是抗恶意构造,而是让“随手改内存”这种低水平操作立刻现形。
3.3 运算符重载:让 ProtectedInt 用起来像普通整数
如果每个调用点都写gold.get()和gold.set(100),那接入成本就太高了,业务代码会变得非常丑。我通过运算符重载,让ProtectedInt在大部分场景下可以直接替代int:
struct ProtectedInt { // 上面的字段和成员函数省略,保持同一份实现 operator int32_t() const { return get(); } ProtectedInt& operator=(int32_t v) { set(v); return *this; } ProtectedInt& operator+=(int32_t v) { set(get() + v); return *this; } ProtectedInt& operator-=(int32_t v) { set(get() - v); return *this; } ProtectedInt& operator++() { set(get() + 1); return *this; } int32_t operator++(int) { int32_t prev = get(); set(prev + 1); return prev; } };重载之后,业务代码写起来几乎和普通int一模一样:
player.Gold = 500; player.Gold += 50; player.Gold -= 30; int now = player.Gold;注意我故意没有重载operator int32_t()之外的其他隐式转换,也没有让ProtectedInt支持乘除法、位运算。因为游戏里这些数值基本只会做加减和赋值,接口暴露得越少,团队误用的概率就越低。如果有人真想拿金币做乘法,那应该先把它取到局部变量里:
int gold = player.Gold; int newGold = gold * 2; player.Gold = newGold;这样语义清晰,也不会因为运算符重载过度导致代码可读性崩坏。
3.4 多线程环境下的补充:原子版本
如果你的游戏里,逻辑线程和渲染线程、登录线程都会访问同一个 ProtectedInt,那上面的实现还不够。get内部有多次内存读取,如果写入线程同时在写store和check,读取线程可能读到“一半新一半旧”的状态,导致校验失败。
我在项目里对跨线程的 ProtectedInt 做了一层原子化包装:
struct AtomicProtectedInt { std::atomic<uint64_t> salt; std::atomic<uint32_t> store[2]; std::atomic<uint32_t> check; };这只是一个提示性实现。真正要保证跨线程安全,还需要在写操作里使用atomic_store和 memory order 的细致控制。好在绝大多数游戏逻辑不会在一帧里高频跨线程改金币,大多数情况下单线程版本够用。这点我在项目接入时单独给团队发了告警。
4. 游戏项目实战:Player 结构体改造与存档处理
代码写完之后,真正的工作是接入。这里我拿一个常见场景演示:玩家实体。
4.1 把角色属性塞进 ProtectedInt
假设原来的Player是这样的:
struct Player { int Hp; int Mp; int Gold; int AttackBuffLevel; };改造后变成:
struct Player { ProtectedInt Hp; ProtectedInt Mp; ProtectedInt Gold; ProtectedInt AttackBuffLevel; };如果之前已经写了大量player.Hp = xxx和if (player.Hp > 10),那么改造之后它们依然能编译通过。这就是我坚持运算符重载的原因:成本低,团队不抗拒。
真正需要开发者手动处理的,是那些直接取地址的代码。比如以前有人为了性能写过:
int* hpPtr = &player.Hp;这种代码在改造后必然报错。我遇到的最大一批问题就是这类“指针访问绕过了接口”的写法。解决方案很简单:直接改成通过接口读写。因为这种地址暴露本身就等于给 ProtectedInt 开了后门。
4.2 扣血、扣钱这类复合操作的写法
游戏里最常见的不是“设置一个值”,而是“判断够不够,再扣掉”。只靠运算符重载写起来很容易埋 bug:
if (player.Gold >= 100) { player.Gold -= 100; }“先判断再扣款”在单线程、无作弊环境里没问题。但在内存可被篡改的客户端环境里,这个写法有一个极小的窗口:判断时Gold是 100,执行扣款前攻击者把内存改成 90,结果扣款失败,玩家白白丢了钱。这种窗口只有在精密攻击时才可能触发,但我还是建议把“判断+扣款”收敛成一个封装函数:
bool TryCostGold(Player& p, int32_t cost) { int32_t now = p.Gold.get(); if (now < cost) return false; p.Gold.set(now - cost); return true; }虽然本质上还是 get 和 set 两步,但在同一个函数内完成,攻击者要在两条指令之间插入内存修改,难度会大很多。从业务层面看,这也是推荐的方式:金币、血量的变化都应该走专门的“资金事务/伤害结算”函数,而不是散落在各处直接改成员。
4.3 存档序列化与跨版本问题
ProtectedInt 的内存布局不能直接写进存档,因为 salt 和 store 是运行时生成的状态,序列化到磁盘再读回来没有必要保留。我在项目里做了一个明确的约定:存档里只保存 get() 返回的明文数值,加载时再通过 set() 写回。
// 存档写入 void SavePlayer(Stream& out, const Player& p) { out.WriteInt32(p.Gold.get()); out.WriteInt32(p.Hp.get()); out.WriteInt32(p.Mp.get()); } // 存档读取 void LoadPlayer(Stream& in, Player& p) { p.Gold.init(in.ReadInt32()); p.Hp.init(in.ReadInt32()); p.Mp.init(in.ReadInt32()); }这里有一个很关键的设计取舍:不要在玩家本地存档里保存“被校验过的服务器权威值”,因为玩家可以直接改存档文件。ProtectedInt 只负责内存层的防护,存档完整性应该靠服务端签名或者加密存档来解决。如果你做的是单机游戏,那存档加密是另一套独立的系统,不要和内存防护混在一起。
还要注意跨版本。如果未来给 ProtectedInt 增加字段,比如多存一份副本,旧存档并不受影响,因为存档里存的是明文。真正要注意的是新旧代码对同一份临时数据读取时,去引用已销毁的 salt 会导致校验失败,这是自己代码的问题,不是存档问题。
5. 换个身份攻击自己:验证校验与查缺补漏
写完这些之后,我干了一件研发时最该干的事:把自己想象成作弊者,拿出常用的修改器和调试器,对着新代码打了一遍。这一节就是模拟攻击与防御升级的记录。
5.1 第一轮攻击:传统值扫描还能不能建立“金币地址”
我先用修改器对金币做常规扫描。因为内存里根本没有一个与金币值相等的明文uint32_t,所以第一次按数值搜就扑空了。接着尝试“未知初始值”配合“减少”过滤法:玩家花金币的时候,内存里有多个字段在变化,store[0]、store[1]、check都在变,攻击者想要单一地址过滤,得到的是一个完全混乱的目标集。
第一轮攻击以失败告终。这正是我想要的效果:让通用修改器的自动化扫描工具失去作用。
但第二轮就不能大意了。
5.2 第二轮攻击:用调试器定位 get 函数并绕过校验
有经验的逆向者不会傻到继续扫数值。他会用调试器在游戏进程里搜索“引用金币”的代码,找到调用get()附近的相关指令,然后通过断点观察返回值。一旦他发现了store[0]和store[1]的偏移,就可以写一个小脚本,每次读这两个地址、解密、比对,然后批量修改,同时自动修正check。
这个攻击链是比较现实的。我采取的防御升级有两层:
第一层是多副本加随机洗牌。把store[2]扩展到store[3]或store[4],并且在每次读写时随机选择一个副本作为主副本,定期做一致性巡检。攻击者改完一个地址后,下一次get()可能会验证另一个副本,于是立刻被识破。
第二层是异常陷阱。在get()发现校验失败时,除了返回兜底值,还会把该对象的salt立即刷新、把所有副本重写为默认值,同时向日志系统上报“疑似篡改”。这么做的好处是,攻击者即使试图写脚本,也会发现脚本一旦触发,数据立刻变成另一组随机值,他必须不断重新扫描,成本急剧上升。
5.3 第三轮攻击:绕过锁值逻辑,直接 hook 返回值
更高级的攻击者其实会走另一条路:不管你的内存布局多复杂,只要程序最终要把数值提交到游戏逻辑,他就可以 hookget()的返回值,或者直接修改get()函数体,强制返回999999。
我在实验环境里用调试器测试了这个思路,发现ProtectedInt对这种攻击几乎无能为力。因为只要你拥有进程的任意代码执行权限,任何内存防护都是虚设。这时候唯一靠谱的方案是服务端权威:客户端内存被锁多少都没关系,最终扣款、扣血都必须在服务器端做校验。客户端的内存保护只是减少被攻击的概率,而不是让攻击彻底失效。
5.4 性能实测:到底慢到什么程度
测了攻击,当然也要测性能。我在一次连续造成 10 万次金币变动的模拟里,对比了裸int和ProtectedInt的读写耗时:
| 操作类型 | 单次读取耗时 | 单次写入耗时 |
|---|---|---|
| 裸 int | < 1 ns | < 1 ns |
| ProtectedInt(无异常路径) | 约 4-6 ns | 约 3-5 ns |
| ProtectedInt(每次启动重新 init) | 约 6-8 ns | 约 4-6 ns |
结论也很明确:在一次游戏帧循环里,如果核心数值读写次数在几百到几千这个量级,ProtectedInt 带来的开销可以忽略不计。相比一次物理碰撞检测或者寻路计算,这个代价非常低。但要注意,没有人会把这种结构体放在最底层百万次调用的热循环里,那种场景应该先取出明文局部变量,用完之后再写回。
6. ProtectedInt 的边界:哪些作弊它挡不住,应该怎么补
文章写到最后,必须诚实地聊边界。我给不少团队做过这个方案,最容易产生的问题就是:主程觉得“内存加密了 = 安全了”,然后把服务端校验砍了,结果被迟早出现的作弊者打脸。
6.1 它能挡住谁
- 只会用现成内存修改器的普通玩家。
- 使用通用数值扫描、一键锁血、一键改钱的脚本工具。
- 不愿意花时间逆向分析的低成本作弊者。
对这个群体,ProtectedInt 的威慑力是足够的。他们改不到内存里的明文,找不到固定偏移,也不会写自定义脚本来逐字节伪造校验和。
6.2 它挡不住谁
- 能做 DLL 注入、能 hook 关键函数、能附加调试器进行逆向分析的攻击者。
- 直接绕过客户端,伪造网络数据包的攻击者。
- 和你玩“策略类”的:不改内存,单纯用脚本模拟鼠标点击、一键连招,这属于宏操作,内存层任何防护都管不到。
这也就是为什么我在所有项目里都会反复强调:ProtectedInt 只是纵深防御里的一层,不是全部。最终的可靠防线永远在服务端:关键数值的运算要在服务端或权威逻辑里有独立记录,客户端提交的数值要做合法性校验,校验规则要写清楚上限和增量。
6.3 接入前最容易被忽略的三个细节
最后给三个落地经验,都是实际项目里踩过的坑。
第一,初始化函数必须在对象构造时调用。如果某个ProtectedInt忘记init(),它的 salt 是未定义值,读取时会随机校验失败。我用一个简单办法阻止这类问题:所有ProtectedInt默认自带一个无效盐值,第一次init之前调用get()会返回兜底值并打日志,这样“没初始化”的情况会在第一时间暴露。
第二,不要为了省性能把 get 的结果长期保存。有同事为了“避免重复解密”,在玩家结构体里额外保存了一个明文 cached 值,结果这个 cached 值直接成了新的攻击目标,等于把保护全部绕开。ProtectedInt 的正确用法是每次要比较、计算时现场 get,取到局部变量后短周期使用完就丢。
第三,尽量保持实现独立,不要依赖引擎。我第一版是写在具体项目里的,后来发现要复用非常麻烦。后来我把 ProtectedInt 抽成独立的头文件,不引用任何引擎头文件,这样不管是 Unity 的 C++ 插件、Unreal 的模块,还是自研引擎,都能直接拖进去用。这份通用性反而成了团队信任它的原因之一。
我自己的体会是:这种结构体级的内存防护,不应该是“上线的补丁”,而应该是项目一开始设计数值系统时就该具备的基础设施。前期花半天时间把一个ProtectedInt写好,后面能省下的排查时间和玩家投诉,远比这几百行代码的价值大。如果你正被外挂改数值折腾,不如从今天就把最关键的那几个字段,先锁起来。