资讯动态

用ProtectedInt封装游戏关键数值,抵御内存修改器

发布时间:2026/9/18 15:06:27 来源:尧图企业网站定制
很多做游戏的朋友都遇到过这样的场景测试群里突然有人晒出“一刀999999”的截图血量锁在满值永远打不死金币十亿起步。这不是BUG是有人直接改了内存里的游戏数值。早年我做单机Demo时也被这样羞辱过后来认真研究了一圈反作弊方案发现先用一个 struct 把关键数值包装成带防护的 ProtectedInt是成本最低、见效最快的思路之一。这篇就结合我自己的踩坑过程把 struct 的基础用法、ProtectedInt 的具体实现、以及怎么进一步加固一次性聊清楚。如果你也在做游戏尤其是客户端里有血量、金币、耐久度这类容易成为作弊目标的数值这篇文章应该能给你一个能直接落地的方案。全文不堆术语代码可以拿回来直接改。当然我也得提前泼盆冷水客户端任何保护都是提高门槛不是绝对防御真正要命的数据校验还是得靠服务端。1. 为什么游戏数值会被秒改先搞懂内存修改器的原理1.1 一份明文数值在内存里有多脆弱在很多没有做过防护的游戏客户端里血量、金币、攻击力背后就是几个普普通通的 int 变量。比如一个 C 写的玩家类class Player { public: int hp; int gold; int attack; };这种代码写起来爽跑起来也爽唯独在反作弊面前等于裸奔。因为 hp 这个字段在进程内存中就是 4 个字节内容就是真正的血量。攻击者不需要理解游戏逻辑也不需要会逆向只要会点几下鼠标就能改。用 Cheat Engine 这类内存修改工具的完整操作流程是这样的记下当前血量值比如 1000在 CE 中扫描 1000得到海量候选地址让角色挨一下打血量掉到 912在 CE 中继续扫描 912候选地址大幅收敛重复几次“掉血 - 搜新值”的操作最后只剩唯一一个地址双击这个地址直接把血量改成 999999。整个过程不到一分钟。更关键的是这种玩法对任何普通 int 数值都有效金币、经验、攻击力、暴击率只要你能让数值产生一次变化就能通过多次扫描把它们揪出来。假如这个游戏没有服务端或者服务端校验很弱攻击者把 hp 改成 999999 之后客户端本地战斗逻辑就会一直按 999999 去计算表现出来就是“怎么打都打不死”。哪怕服务端本来是权威的也会出现客户端显示血量满值、服务端判定角色已经死了的矛盾玩家的第一反应就是你游戏出了 BUG。这就是为什么很多团队宁可花点性能换一份内存上的“伪装”。1.2 反作弊到底在防什么四层目标对照客户端反作弊和“绝对安全”无关。一个运行在玩家机器上的进程只要权限足够任何数据都可以被修改只是成本和被发现的概率不同。所以做防护前心里要有这么一张分层表防护层思路成本能挡住什么第一层数值加密存储低挡住 90% 的简单搜索修改第二层备份交叉校验低篡改后读取时暴露异常第三层访问模式混淆中挡住“查找写入/读取”定位第四层服务端权威校验高最终兜底客户端再改也不认ProtectedInt 这类 struct 包装类型主要落在前两层。它做的事情可以概括成两句话内存里存的不是真实值让扫描修改无从下手读取时一旦发现值被改过立刻能感知到并采取行动。别小看这两层。现实中大量外挂都是“改内存”型挂用 CE 搜一遍改一遍就完事根本没到逆向你代码的地步。把这两层做好你就能过滤掉绝大多数普通作弊玩家剩下能绕过的人至少具备一定逆向能力这时就该靠第三第四层去兜了。2. 绕不开的底子struct 在 C/C 里的正确用法2.1 struct 不只是把数据捆在一起最近总看到有朋友在搜 C 语言 struct 的用法其实它的理解深度直接决定了你后面能不能看懂 ProtectedInt。struct 最基础的作用确实是把几个变量聚合在一起比如定义一个平面上的点struct Point { int x; int y; };看起来很简单但很多人没意识到这一行声明解决了一个大问题封装。x 和 y 从此成为一个整体不管是函数传参、数组管理还是数据复制都是围绕 Point 这个“概念”进行的。没有 struct 之前你想表达一个点的位置就得写int point_x; int point_y;一旦变量一多代码就会迅速退化成一堆前缀一样的散装变量。在真正的游戏工程里场景中的实体、玩家属性、掉落物、Buff 列表几乎都是靠 struct 来组织数据的。它是一层最朴素但非常有用的抽象让数据和数据之间的边界变得清晰。后面要做的 ProtectedInt本质上就是把“数值”和“守护数值的逻辑”封装到同一个 struct 里而这一步的理解起点就是 struct 这种天然的聚合能力。2.2 struct 能承载的不只是数据还有行为如果只是把数据聚在一起struct 的作用其实很有限。真正让它成为反作弊利器的地方在于它还能把“操作这些数据的代码”一起装进去。这一点在 C 语言里面要靠函数指针或者外部函数实现在 C 里则天然支持成员函数。还是以点为例。C 语言版本你往往得这样操作struct Point { int x; int y; }; void Point_Set(struct Point* p, int x, int y) { p-x x; p-y y; } int Point_LengthSq(struct Point* p) { return p-x * p-x p-y * p-y; }每个函数第一个参数都是那个 struct 指针本质上就是在手动模拟 C 的 this。到了 C完全可以写成这样struct Point { int x; int y; void Set(int nx, int ny) { x nx; y ny; } int LengthSq() const { return x * x y * y; } };对老手来说这是基本功但对新手来说这正是理解 ProtectedInt 的关键struct 给了我们一种“把数值和它的守卫逻辑绑定成同一个对象”的能力。后面做 ProtectedInt 时加密算法、备份策略、校验逻辑全部住进结构体内部对外部调用方完全隐藏。2.3 用生活类比理解“struct 上锁”用一个不那么严谨、但很直观的类比。普通 int 相当于把钞票直接摊在桌上谁来都能拿ProtectedInt 相当于把钞票锁进保险箱只有掌握钥匙才能查看并且保险箱带报警器一旦有人强行撬开警报就会响。这个保险箱就是 struct。保险箱里面放什么、怎么验证、怎么报警全部由结构体的内部实现决定。外部代码只看到“这里有一个整数型数值”它访问这个值的方式和访问普通 int 一样但背后的行为完全不同。3. 三道防线布局ProtectedInt 的核心设计思路3.1 第一道防线让内存里永远不出现真实值最简单的做法是异或“加密”。随机生成一个 key把真实值 value 与 key 异或后的结果存到内存里。因为在游戏运行期间这个 key 不断变化内存中看到的永远是一个伪随机数。攻击者用 CE 搜索 1000、搜索 912都搜不到因为内存储存的是类似 342518、127893 这样的数字。选异或而不是加减乘除原因是异或和它自己是互逆运算(value ^ key) ^ key value只需一个运算就能完成加密和解密性能极好。加法虽然也简单需要一正一反两道运算性能差异其实也能忽略但异或在混淆视觉上效果更好——看到的是一团乱码而不是“大数 小数”的结构。这里多说一句为什么不用 AES 这类正经加密因为反作弊不是密码学上的安全通信不需要重量级算法。我们要解决的核心问题是“内存里不能直接看到明文数值”异或配合随机 key 已经足够扰乱搜索型工具。每帧几万次的读操作如果都走 AES那性能损失就是你自找的。3.2 第二道防线双备份交叉校验改一个马上露馅光有异或还防不住一个作弊器思路既然搜不到数值那就搜索“写入这台地址的指令”锁定写指令后直接改成任意值。改出来的结果可能是一个随机值比如把加密后的 342518 改成 1000000。下次读取时还原血量可能变成一个巨大或者负数这虽然会造成玩家数据异常但不一定能被程序感知到。所以第二道防线是多存一份“校验备份”。比如同时存两个值cipher value ^ keybackup value key读取真实值时分别从 cipher 和 backup 还原v1 cipher ^ keyv2 backup - key如果 v1 不等于 v2说明 cipher 或 backup 至少有一个被人动过。此时程序可以返回一个合理的安全值并立刻标记 corrupted 状态业务层可以据此把玩家踢下线、上报服务器、恢复默认值。整个过程不再是“默默接受脏数据”而是“发现异常并做出响应”。3.3 第三道防线篡改后的响应策略比防篡改本身更重要第三道防线不是加密而是决策。当你发现整数值被改写下一步怎么办我见过不少项目在这里踩坑检测到 corrupted 后直接返回 0结果正常玩家如果遇到偶发误报账号数据会被清零非常难受。比较稳妥的做法是维护一个 fallback 字段在每次 Store 时保存上一个可信值。校验失败时先返回 fallback让业务不会立刻出现明显异常同时把 corrupted 标志置为 true。业务层可以选择在下一帧暂停使用这个值或者向服务器上报异常。响应策略也不能太宽容。如果检测到 corrupted 后什么都不做等于告诉攻击者“你随便改我就看着”那这个校验就白做了。我自己的习惯是发现异常后记录日志锁定该玩家当前会话的数值自动恢复同时把异常事件异步上报到后端。轻度伪造就静默恢复高频伪造直接踢下线或者加入观察列表。4. 完整代码实战从零封装一个 ProtectedInt4.1 能直接抄的 C 实现本着“能跑就行、先跑起来再优化”的原则我先把一份最精简的 C 实现贴出来。代码里我用了 uint32_t 做存储避免有符号整数溢出的未定义行为读回时再转换成 int这样在跨平台性上会稳很多。// ProtectedInt.h #pragma once #include cstdint #include random class ProtectedInt { public: ProtectedInt(int value 0) { Store(value); } // 读取真实值读取时会做交叉校验 int Get() const { uint32_t v1 cipher_ ^ key_; uint32_t v2 backup_ - key_; if (v1 ! v2) { corrupted_ true; // 记录被篡改 return fallback_; // 返回上一次可信值 } return static_castint(v1); } // 写入真实值 void Set(int value) { Store(value); } // 将 ProtectedInt 当作 int 使用隐式转换 operator int() const { return Get(); } // 运算符重载让业务代码几乎零改动 ProtectedInt operator(int value) { Set(value); return *this; } ProtectedInt operator(int delta) { Set(Get() delta); return *this; } ProtectedInt operator-(int delta) { Set(Get() - delta); return *this; } ProtectedInt operator() { Set(Get() 1); return *this; } ProtectedInt operator--() { Set(Get() - 1); return *this; } // 是否发生过篡改 bool Corrupted() const { return corrupted_; } private: void Store(int value) { key_ GenerateKey(); cipher_ static_castuint32_t(value) ^ key_; backup_ static_castuint32_t(value) key_; fallback_ value; corrupted_ false; } static uint32_t GenerateKey() { // 真实项目中建议换成更稳定的随机源 std::random_device rd; uint32_t x rd() ^ 0x9E3779B9U; x ^ x 13; x ^ x 17; x ^ x 5; return x; } uint32_t key_ 0; uint32_t cipher_ 0; uint32_t backup_ 0; int fallback_ 0; mutable bool corrupted_ false; };这段代码有几个点值得说。第一Get()被声明为 const但又需要修改corrupted_所以我把这个标志字段用了mutable这样在只读操作中也能记录异常状态。第二fallback_保存的是最后一次完整写入的可信值不是上一次读取的值避免把攻击者伪造出来的中间值当成“正常值”继续用。4.2 运算符重载把原业务代码改动降到最低如果只是封装了一个类业务代码里满屏都是hp.Set(hp.Get() - damage)那接入成本会很高还会漏改一堆地方。所以运算符重载是必须的。有了operator int、operator、operator、operator-、operator、operator--之后原来的代码一行都不用动。比如原来一段掉血逻辑是这样的void TakeDamage(Player p, int damage) { p.hp - damage; if (p.hp 0) { // 死亡流程 } }把int hp直接换成ProtectedInt hp之后这段代码依然编译通过但底下执行的是 ProtectedInt 的重载逻辑先 Get 校验、再做减法、最后 Set 重新加密。对策划和上层逻辑开发者来说这几乎是无感的。需要注意一个陷阱operator int隐式转换虽然方便但过多使用会掩盖临时真实值的生命周期。比如写int currentHp p.hp.Get();这种代码虽然合法但如果 currentHp 这个局部变量被保存在栈上攻击者还是可能通过搜索 currentHp 锁定到真实值。所以能隐式转换不等于应该到处存真实值后文踩坑部分会细讲。4.3 没有 C 怎么办C 语言版 ProtectedInt很多游戏项目尤其是嵌入式、手机引擎底层还是 C 为主。C 里没有类、没有成员函数、没有运算符重载但依然可以用 struct 把事情做出来。原理相同只是调用风格变成了函数式很像最早期 C 语言对对象模型的模拟。// protected_int.h #ifndef PROTECTED_INT_H #define PROTECTED_INT_H #include stdint.h #include time.h typedef struct ProtectedInt { uint32_t key; uint32_t cipher; uint32_t backup; int fallback; int corrupted; } ProtectedInt; void ProtectedInt_Init(ProtectedInt* p, int value); int ProtectedInt_Get(ProtectedInt* p); void ProtectedInt_Set(ProtectedInt* p, int value); int ProtectedInt_Corrupted(ProtectedInt* p); #endif实现文件// protected_int.c #include protected_int.h static uint32_t ProtectedInt_Key(ProtectedInt* p) { uintptr_t addr (uintptr_t)p; uint32_t x (uint32_t)addr ^ (uint32_t)time(NULL); x ^ x 13; x ^ x 17; x ^ x 5; return x; } void ProtectedInt_Init(ProtectedInt* p, int value) { p-key ProtectedInt_Key(p); p-cipher (uint32_t)value ^ p-key; p-backup (uint32_t)value p-key; p-fallback value; p-corrupted 0; } int ProtectedInt_Get(ProtectedInt* p) { uint32_t v1 p-cipher ^ p-key; uint32_t v2 p-backup - p-key; if (v1 ! v2) { p-corrupted 1; return p-fallback; } return (int)v1; } void ProtectedInt_Set(ProtectedInt* p, int value) { ProtectedInt_Init(p, value); } int ProtectedInt_Corrupted(ProtectedInt* p) { return p-corrupted; }C 版本用起来比 C 啰嗦一些但核心保护效果是一样的。每个函数第一个参数都带上ProtectedInt*这就是 struct 封装带来的“把数据和操作绑在一起”的效果。4.4 接入示例一行代码都不用多写最后放一个最简单的玩家接入选型展示接入方式struct Player { ProtectedInt hp; ProtectedInt gold; int buffId; // 普通 int 仍然照常 }; void HurtPlayer(Player p, int damage) { p.hp - damage; if (p.hp 0) { // 进入死亡流程 } } void AddGold(Player p, int amount) { p.gold amount; }从调用方看p.hp和普通 int 没有区别但内存里的实际数据已经被伪装过了。这种接入成本几乎为零所以特别适合在项目后期临时补防护而不是必须从立项就规划。5. 进阶加固五个让 ProtectedInt 更难被绕过的技巧5.1 动态密钥更新打乱攻击者“找规律”的节奏基础版 ProtectedInt 的缺点是只要攻击者通过静态分析逆出了异或逻辑他就能算出 key 或者直接调用 Get/Set 的逻辑。为了增加难度同一个 ProtectedInt 实例应该在运行过程中频繁更换 key。最简单的方式是定期调用 Refresh 逻辑比如每 300 帧重新生成一个 key或者每次读取成功后有一定概率触发重加密void ProtectedInt::Refresh() { int value Get(); Store(value); }刷新还有一个额外好处如果攻击者持续往内存里写脏数据刷新时就会触发校验失败并重新执行 Store相当于我们把游戏数值从“被改就乱”变成了“被改就重置回可信状态”。当然这里要求 fallback 是一个真正可信的值否则它会变成“自动恢复脏数据”。5.2 把密钥藏到 struct 外面别让保险箱和钥匙放在一起基础实现里 key 和 cipher 存在同一个对象内内存地址相邻。攻击者只要稍微分析就能看出“有一个字段参与了异或另一个字段是异或后的结果”。所以进阶做法是把 key 和加密值拆开存放。比较常见的做法是搞一个全局数组每个实例存一个索引uint32_t g_keyPool[64] { /* 随机初始化 */ }; class ProtectedInt { uint32_t cipher_; int keyIndex_; // ... };每次读取时用g_keyPool[keyIndex_]作为 key。因为 key 不在当前对象附近CE 的数据扫描和结构分析器不容易直接定位到它。再狠一点可以让 keyIndex 本身也经过一层换算比如g_keyPool[(keyIndex_ * 31 7) % 64]进一步增加分析成本。5.3 编译期内联与指令混淆给逆向增加一个台阶如果 Get/Set 是独立的非内联函数反汇编代码里会有非常明显的函数特征攻击者看到后就懂了。C 里把实现写在类内部会默认建议内联Release 编译开高优化后很多 Get/Set 会被直接展开到调用点。这种代码分布是散的静态分析难度会高不少。另外异或操作在汇编里通常是xor指令一抓一个准。实际项目中可以考虑用等价的其他指令组合来替换比如cipher_ (~key_ 1)本质上就是减 key功能一样但指令特征完全不同。多换几种变形攻击者想用特征码定位逻辑就得花更多时间。5.4 与服务端组合拳客户端防篡改真正落地到这里再强调一次客户端 ProtectedInt 再强也只是“防御面”的一部分。真正决定数值合法性的最好还是服务端。一个简单的做法是客户端每隔一段时间把所有关键数值算一个哈希上报服务端用自己的权威数据算同样的哈希不一致就标记异常、下发回滚。我用过一个比较轻的方案是“服务端只校验关键节点”。战斗开始、结算、购买这些节点上传玩家当前血量/金币的哈希集合服务端与权威数值进行比对。这样不用每帧都做网络通信也能在关键节点拦截大量作弊数据。5.5 加一层“假读取”干扰访问模式前面提到 CE 可以通过“查找写入/读取访问”定位指令。高级作弊者会在这个层面下功夫。一个干扰技巧是在游戏逻辑的关键循环里插入对 ProtectedInt 的假读取这些读取不会影响业务但会产生大量的访问位置让攻击者在分析时看到一堆候选地址无从下手。比如// 假读取结果被丢弃仅用于干扰内存访问分析 volatile int dummy p.hp.Get(); if (dummy -1) { // 永远不会进入的分支 p.hp.Set(0); }这种方式不增加多少真实开销但确实能恶心到一部分靠“访问定位”来做修改器的人。6. 高频问题与实操避坑指南6.1 高频问题速查表表现可能原因解决办法用 CE 还能搜到血量业务代码里把 Get() 结果存到了普通 int 局部变量避免真实值长时间停留在局部变量/界面显示层值被改但 Corrupted() 没变 truecipher 和 backup 被同时改成等价脏数据增加第三份校验或周期性 Refresh 重加密数值看起来偶尔跳变key 在多线程下被并发改给 Store/Get 加锁或改用 thread_local 随机源检测触发过于频繁调试器单步执行导致时序变化把误报日志与服务器异常日志区分开接入后性能下降明显把高频循环里的临时变量也换成了 ProtectedInt只保护玩家属性等关键数值高频热数据另做处理存档读取后数值错乱序列化时直接取内存数据而不是 Get()统一通过 Get/Set 接口读写存档6.2 实际操作中容易被忽略的细节我实际接入 ProtectedInt 的时候踩过最深的坑是序列化。有些同事图省事直接对 Player 结构体做内存拷贝、写到存档文件等读取回来的时候发现所有数值都是乱码。因为内存里存的是 cipher 和 backup不是真实值结构体拷贝只会把 cipher/backup 原样搬走可信值并没有被正确保存。所以所有存档、网络包、日志里出现的数值必须通过Get()统一转成真实值后再走序列化绝对不要把底层 struct 的内存直接 dump 出去。这个约束要在代码评审阶段就定死不然上线后会有一堆“存档损坏”的工单飞过来。另一个容易被忽略的细节是不要在日志里打印真实值。日志文件是本地可读的如果血量、坐标、金币信息都明文躺在日志里等于给攻击者发了一本“关键数据说明书”。即使要打日志也只打校验结果和异常码。6.3 这几个场景千万别用 ProtectedInt先说高频临时变量。如果你有一个粒子系统的位置偏移一帧要被读取几千次每次都是 Get 解密 校验性能开销会立刻变得扎眼。这种情况下内存被篡改导致的后果也基本没有不值得保护。数值保护要用在刀刃上血量、攻击力、金币、经验、装备数值、Buff 剩余时间这些才值得。还有一类是数组或批量数据。几千个 NPC 同时在线的实例字段如果全部套 ProtectedInt内存膨胀和维护成本都会上涨。比较合理的做法是玩家背包、排行榜这类高频且关键的数据只保护总量和关键索引细节数据用服务端校验解决。最后就是数值本身不敏感的场景。比如一个 UI 过渡动画的进度因子改了无非是特效播放速度奇怪一点不影响公平性那就不需要上锁。防护覆盖面太广不仅性能压力大还会让真正的关键数据淹没在大量误报里。最后说句实在话ProtectedInt 不是万能钥匙。我目前的做法是客户端 ProtectedInt 负责掩盖真实值和感知篡改服务端每 3 秒做一次关键值 hash 校验两边一起用效果最好。而且哪怕被破日志里也会留下异常记录方便反作弊后台追责迭代。如果你也在折腾数值防护建议从 ProtectedInt 开始先挡住 90% 的脚本小子再慢慢往底层加东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价