有一回我差点被玩家投诉弄到心态爆炸,明明服务端对账没问题,但截图上背包金币就是 999999999。排查到凌晨才发现,客户端本地变量直接用int gold存,内存修改器扫到地址后把值一改,整个经济系统就失灵了。从那次以后我养成了一个习惯:凡是玩家可见的关键数值,绝不用裸的int和float摆在内存里。
这篇文章分享一下我用一个自定义ProtectedInt结构体给游戏关键数值上锁的经验。会从内存修改器的扫描原理讲起,再拆解struct为什么适合干这件事,最后给出可直接抄走的 C/C++ 实现以及上线后踩过的几个坑。不管你做的是单机游戏、弱联网手游,还是想在工具类软件里防数据篡改,这套思路都能直接落地。
1. 先搞清楚敌人是谁:内存修改器是怎么找到你的数值的
1.1 精确扫描和模糊扫描的套路
以常见的 Cheat Engine 为例,找游戏数值基本就两步。
第一步是你知道当前值是多少。比如角色金币是 100,CE 做一次“精确数值扫描”,把所有 4 字节等于 100 的内存地址挑出来,候选可能有几十万个。第二步你在游戏里花掉一个金币变成 99,再扫描“数值减少”的地址,候选瞬间从几十万掉到几十个。重复两三轮,唯一地址就出来了。
更麻烦的是模糊扫描。很多游戏数值根本不显示具体数字,比如血条、怒气槽。CE 虽然不知道当前值,但它支持“比上次增加”“比上次减少”“和上次相同”这种筛选。你只需要在战斗中反复操作,让数值按已知方向变化,几次下来同样能锁定地址。
锁定之后的事情就简单了:直接改值,或者干脆“锁定数值”让每次读取都返回一个指定值。对明文存储的游戏来说,整个过程不需要任何编程知识,一个普通玩家在网上看五分钟教程就能做到。所以不要天真地以为只有专业外挂才危险,“一键修改器”才是绝大多数休闲玩家作弊的主流方式。
1.2 明文 int 在内存里就是裸奔
为什么明文int挡不住?因为它把数值原封不动地暴露在内存里。扫描器看到 100 就是 100,你花掉 1 变成 99,扫描器又看到 99。整个过程毫无遮掩。
有些项目把所有玩家属性放进一个大结构体,金币、钻石、体力、攻击力整整齐齐排在一起,这在外挂作者眼里简直是一张地图。定位到金币地址后,往上翻几行就是钻石,往下翻几行就是体力,改起来比自己写业务代码还方便。
这就引出了ProtectedInt的核心目标:让内存里的数值不再是“一眼可读”的明文,同时让即使被改了也无法通过程序自检,从而从根上废掉“扫内存改数值”这条作弊路径。
2. ProtectedInt 的设计思路:从裸数据到锁住的值
2.1 三道防线:密文存储、动态掩码、校验和
先说结论,一套能扛住普通修改器的 ProtectedInt,至少要有三层设计。
第一层是密文存储。游戏逻辑里写入 100,内存中存的不是 100,而是100 ^ key。这样扫描器直接搜 100 搜不到,搜 99 也搜不到,候选地址一开始就可能为零,从源头破坏精确扫描的前提。
第二层是动态掩码。每个 ProtectedInt 实例在构造时生成一个随机 key,不同实例即使存相同的值,密文也完全不同。如果 key 写死成一个固定常量,攻击者只要对比几次数值变化就能反推出规律,动态掩码的作用就是打散这种特征。
第三层是校验和。存储的时候除了密文和 key,再算一个校验值。每次读取前重新计算校验值,如果对不上,说明内存中的密文或者 key 被人动过。这时候程序可以选择返回安全值、回滚到上次合法值、弹窗提示,或者上报服务端。很多外挂改完数值发现没效果,就是因为这个校验机制拦住了非法数据。
这三层不是孤立的。没有校验和的密文存储,其实只是把作弊门槛从“搜到直接改”抬到“先逆向算法”,对于愿意动手的人仍然不够安全。加了校验和之后,攻击者需要同时绕过读取接口、修改校验和、处理校验失败分支,复杂度就上去了。
2.2 为什么偏偏用 struct:因为它天生适合打包关联字段
你可能想问,这三个字段拆开写不就行了?我在第一版实现里还真犯过这个错:
uint32_t gold_encrypted; uint32_t gold_key; uint32_t gold_checksum;结果就是维护灾难。三个散装变量彼此之间没有任何约束,改密文的时候容易忘了改校验和,换 key 的时候又可能漏掉校验和。更麻烦的是,游戏里有金币、钻石、体力、经验、等级等几十个数值,如果每个数值都这样散着定义,代码很快就失控了。
C 语言里struct的用法,本质上就是把一组相关联的数据打包成一个新的复合类型。这里正好用上了这个特性:
typedef struct ProtectedInt { uint32_t encrypted; uint32_t key; uint32_t checksum; } ProtectedInt;gold这个变量的密文、key、校验和从此绑定成一个整体。配套函数只接受ProtectedInt*指针,不允许把字段拆出来单独操作。这就是 struct 在反作弊场景里最大的价值——它不是性能优化,也不是语法花活,而是把“一组数据必须一起出现、一起变化”这一约束,从口头约定变成了编译期结构。
C++ 里struct和class其实没有本质区别,只是默认访问权限不同。所以我在实际项目里直接用struct承载构造函数、操作符重载这些能力,既保留了 C 风格的数据聚合语义,又拿到了面向对象的封装性。
2.3 和简单的固定 XOR 加密有什么不同
不少人会想到用 XOR 加密存储。比如把gold存成gold ^ 0xAA55,这个思路方向对,但距离可用还差两步。
第一步是固定 key 的问题。如果所有玩家的所有数值都用同一个 key,攻击者只要观察到两次变化,比如看到密文从 100^0xAA55 变成 99^0xAA55,基本就能猜出 key 值。就算猜不出,把所有可能的 key 空间穷举一遍也只是时间问题。
第二步是校验的问题。固定 XOR 只做了混淆,没有校验。攻击者直接把密文改成一个乱值,程序读取时照样按同样的 key 解出来一个乱数,游戏逻辑就会用这个乱数继续跑,结果可能是金币变成负数、购买逻辑错乱,行为不可预测。
ProtectedInt 的意义在于把混淆和校验放到一起。动态掩码让每个实例独立,校验和让任何对密文或 key 的篡改都能被发现。如果攻击者改了密文但没同步更新校验和,读取时校验失败,程序可以拒绝使用。如果攻击者试图同时改密文和校验和,那他必须先破解校验算法,门槛又高了一层。
我整理过一张对比表,可以更直观地看到差异:
| 方案 | 扫描可见性 | 被修改后的结果 | 能否感知篡改 | 实现成本 |
|---|---|---|---|---|
| 明文 int | 直接可见 | 数值直接变化生效 | 无 | 无 |
| 固定 XOR 加密 | 乱码但规律固定 | 可被猜到 key 后绕过 | 无 | 低 |
| ProtectedInt 三层设计 | 乱码且每实例不同 | 校验失败,返回安全值 | 能通过 isValid 感知 | 中 |
3. 动手实现:先给 C 语言原型,再上 C++ 模板
3.1 C 语言版本:用 struct 组织字段,配函数操作
先从最朴素的 C 语言版本开始。这个版本虽然不能直接用进大型项目,但它把思路表达得最清楚:struct负责把三个字段绑在一起,函数负责对这个整体做加解密和校验。
#include <stdint.h> #include <string.h> #include <time.h> typedef struct ProtectedInt { uint32_t encrypted; uint32_t key; uint32_t checksum; } ProtectedInt; static uint32_t protected_hash(uint32_t cipher, uint32_t key) { uint32_t h = cipher ^ key; h ^= h >> 16; h *= 0x7feb352dU; h ^= h >> 15; return h; } static uint32_t protected_rand_key(void) { /* 示例用,实战换成更可靠的随机源 */ uint32_t x = 0x9e3779b9U; x ^= x << 13; x ^= x >> 17; x ^= x << 5; return x ^ (uint32_t)time(NULL); } void protected_init(ProtectedInt* p, uint32_t value) { p->key = protected_rand_key(); p->encrypted = value ^ p->key; p->checksum = protected_hash(p->encrypted, p->key); } uint32_t protected_get(const ProtectedInt* p) { if (protected_hash(p->encrypted, p->key) != p->checksum) { /* 检测到篡改:回滚、上报、或返回安全值 */ return 0; } return p->encrypted ^ p->key; } void protected_set(ProtectedInt* p, uint32_t value) { p->encrypted = value ^ p->key; p->checksum = protected_hash(p->encrypted, p->key); }这个版本完整展示了核心流程。protected_init生成随机 key 并计算初始密文和校验和;protected_get在每次读取前校验,校验失败直接返回 0;protected_set负责更新密文和校验和。
注意 C 语言里struct的用法在这个例子里的体现:ProtectedInt是一个复合类型,函数操作的是整个结构体,而不是三个独立变量。这样做的好处是,即使你只有这个结构体的指针,也不会忘记某个字段的存在,因为三个字段始终在一起。
3.2 C++ 模板版本:把替换 int 的成本降到最低
C 语言版本的最大问题是使用不够方便。游戏代码里已经写好了gold += 50、if (gold >= price)这种表达式,如果每个地方都要改成protected_set和protected_get,改动量非常大。
C++ 版本通过模板和操作符重载解决这个问题。模板支持int、uint32_t、float、double等所有算术类型,操作符重载让ProtectedInt用起来和普通算术类型几乎一样。
#include <cstdint> #include <cstring> #include <random> #include <type_traits> #include <iostream> template <typename T> class ProtectedInt { static_assert(std::is_arithmetic_v<T>, "T must be arithmetic"); T m_cipher; uint32_t m_key; uint32_t m_checksum; static uint32_t makeKey() { std::random_device rd; return rd(); } static uint32_t checksum(T cipher, uint32_t key) { uint64_t raw = 0; std::memcpy(&raw, &cipher, sizeof(T) < sizeof(raw) ? sizeof(T) : sizeof(raw)); uint32_t h = static_cast<uint32_t>(raw) ^ key; h ^= h >> 16; h *= 0x7feb352dU; h ^= h >> 15; return h; } static T encode(T value, uint32_t key) { T out; const unsigned char* src = reinterpret_cast<const unsigned char*>(&value); unsigned char* dst = reinterpret_cast<unsigned char*>(&out); for (size_t i = 0; i < sizeof(T); ++i) { dst[i] = src[i] ^ static_cast<unsigned char>((key >> (i * 8)) & 0xFF); } return out; } void update(T value) { m_cipher = encode(value, m_key); m_checksum = checksum(m_cipher, m_key); } public: ProtectedInt() : m_key(makeKey()) { update(T{}); } explicit ProtectedInt(const T& value) : m_key(makeKey()) { update(value); } ProtectedInt(const ProtectedInt& other) : m_key(makeKey()) { update(other.get()); } ProtectedInt& operator=(const ProtectedInt& other) { if (this != &other) { update(other.get()); } return *this; } void set(const T& value) { update(value); } T get() const { if (!isValid()) { // 触发反作弊逻辑:回滚/上报/拒绝服务 return T{}; } return encode(m_cipher, m_key); } bool isValid() const { return checksum(m_cipher, m_key) == m_checksum; } ProtectedInt& operator=(const T& value) { set(value); return *this; } friend T operator+(const ProtectedInt& a, const ProtectedInt& b) { return a.get() + b.get(); } friend T operator+(const ProtectedInt& a, const T& b) { return a.get() + b; } friend T operator-(const ProtectedInt& a, const ProtectedInt& b) { return a.get() - b.get(); } friend T operator-(const ProtectedInt& a, const T& b) { return a.get() - b; } friend T operator*(const ProtectedInt& a, const ProtectedInt& b) { return a.get() * b.get(); } friend T operator*(const ProtectedInt& a, const T& b) { return a.get() * b; } friend T operator/(const ProtectedInt& a, const ProtectedInt& b) { return a.get() / b.get(); } friend T operator/(const ProtectedInt& a, const T& b) { return a.get() / b; } ProtectedInt& operator+=(const T& b) { set(get() + b); return *this; } ProtectedInt& operator-=(const T& b) { set(get() - b); return *this; } ProtectedInt& operator*=(const T& b) { set(get() * b); return *this; } ProtectedInt& operator/=(const T& b) { set(get() / b); return *this; } friend bool operator==(const ProtectedInt& a, const ProtectedInt& b) { return a.get() == b.get(); } friend bool operator==(const ProtectedInt& a, const T& b) { return a.get() == b; } friend bool operator!=(const ProtectedInt& a, const ProtectedInt& b) { return a.get() != b.get(); } friend bool operator!=(const ProtectedInt& a, const T& b) { return a.get() != b; } friend bool operator<(const ProtectedInt& a, const ProtectedInt& b) { return a.get() < b.get(); } friend bool operator<(const ProtectedInt& a, const T& b) { return a.get() < b; } friend bool operator>(const ProtectedInt& a, const ProtectedInt& b) { return a.get() > b.get(); } friend bool operator>(const ProtectedInt& a, const T& b) { return a.get() > b; } }; template <typename T> std::ostream& operator<<(std::ostream& os, const ProtectedInt<T>& v) { return os << v.get(); }如果用这个模板替换原来的int,大部分业务代码不用改。原来写gold += 50,现在只要gold的类型变成了ProtectedInt<int>,这个表达式依然成立。原来写if (gold >= price),比较操作符也复刻了。
3.3 关键代码解读:掩码生成、加解密、校验和的细节
makeKey()用std::random_device生成随机掩码。每个 ProtectedInt 实例在构造时拿到独立 key,这意味着即使两个玩家初始金币完全相同,内存中的密文也完全不同。模拟器或者云真机环境下如果random_device不可靠,可以退化成混合系统启动时间、线程 ID、帧计数器等熵源的方式。
encode()是对内存字节做逐字节 XOR。这里没有直接用整型异或,是因为要兼容float和double。浮点数的位模式同样是字节序列,按字节 XOR 可以保证加密和解密完全无损。你不用担心加密会破坏浮点数值的精度,因为加解密是镜像操作,解密后得到的位模式和原始值一模一样。
checksum()用了位混合和乘法散列,目的是让校验和与密文、key 之间形成强关联。这里刻意没有用 CRC32 这类公开标准算法,因为我希望校验算法本身不那么容易被静态识别。你可以把这个函数替换成自己设计的混合方程,规则是:输入稍微变化,输出变化明显,同时计算开销足够小。理论上最理想的情况是,任何一位密文发生变化,校验和都有大约一半的概率不匹配。
get()里的恶意篡改处理策略需要根据你的项目来定。返回 0 只是最保守的兜底,实际项目中我更推荐在检测到篡改时,把状态回滚到上次合法值,同时记录日志并上报服务端。有些项目会选择弹窗提示玩家“检测到非正常数据,已恢复”,这种方案对抗轻度作弊也有不错的效果。
4. 在游戏里落地:替换 int 的实操步骤与性能体验
4.1 替换原则:哪些数值该锁,哪些不该锁
不是所有变量都值得用 ProtectedInt。我通常按这个标准筛选:
- 玩家可感知、可积累、可交易的经济类数值,必须锁:金币、钻石、体力、声望、道具数量、积分。
- 战斗属性中影响平衡的关键数值,建议锁:攻击力、防御力、血量上限、暴击率、冷却时间。
- 服务器权威判断后下发的数值,不着急锁:如果每次操作都经过服务器校验,客户端数值只做展示,锁的优先级可以降低。
- 高频临时变量,不锁:粒子数量、插值动画的中间值、临时遍历索引。这些被改了影响不大,但高频调用会放大性能开销。
替换动作本身很简单。把int gold_ = 0;改成ProtectedInt<int> gold_{0};,然后把编译报错的地方逐个修正。由于操作符重载覆盖了加减乘除和比较,大部分业务代码会直接通过编译,少部分需要把gold.Get()显式取出来做格式化或者序列化。
一个典型的金币类目如下:
class PlayerAccount { ProtectedInt<int> gold; ProtectedInt<int> diamond; public: void AddGold(int delta) { gold += delta; // 在这里同步给服务器,客户端值只是本地展示和预扣 } int GetGold() const { return gold.Get(); } bool TryPay(int amount) { if (gold < amount) return false; gold -= amount; return true; } };这个类目展示了一个很重要的落地姿势:客户端用 ProtectedInt 保证本地数据没有被篡改,但真正的合法性校验仍然交给服务端。客户端 ProtectedInt 解决的是“客户端内存被改导致本地状态混乱”的问题,而不是“服务器该不该扣钱”的问题。
4.2 性能开销:几十帧才多花不到一个毫秒
很多人担心加解密性能扛不住,我实测下来这个担心是多余的。get()做一次字节 XOR 加一次校验哈希,大概几十纳秒到几百纳秒量级。就算一个界面同时读取 100 个 ProtectedInt,总开销也在几微秒级别,对 60fps 的帧循环来说可以忽略不计。
我自己的一个项目里,玩家对象上挂了大概 200 个 ProtectedInt,每帧都有 UI 或战斗逻辑触发读取,实测性能面板里查询加解密总耗时不到 0.05ms。相比逻辑运算、物理计算和渲染,这个开销完全可以接受。
真正需要注意的反而是那些高频循环里的读取。比如每帧遍历 1000 个怪物,每个怪物都要读攻击力做伤害计算,如果每一帧都对这 1000 个 ProtectedInt 做一次完整校验,累计起来就会有影响。这种情况建议在架构层面做批处理,或者在进入战斗前把高频属性快照到普通变量,战斗结束后再统一写回。
4.3 存档和网络同步的兼容处理
如果你的游戏已经有存档系统,替换类型后需要注意版本兼容。旧的存档里gold是明文写入的,新版本加载时如果直接用ProtectedInt::set写入,得到的是正确的解密值,这没问题。但如果你的存档系统把整块内存直接复制出来序列化,那就要先通过get()取值再写盘,保证磁盘上仍然是明文或者你自己定义的格式。
网络同步也是同理。发数据包时用get()取真实值,收数据包时用set()写入。千万不要把 ProtectedInt 的原始内存直接塞进网络协议,因为密文、key、校验和是每实例独立的,传到另一个设备上不可能还原出正确值。
还有一点,跨平台时要注意字节序和sizeof(T)的差异。比如 PC 上sizeof(long)是 8,在部分 32 位平台上可能只有 4。如果你的存档或网络数据里混入了这种平台相关类型,建议统一用uint32_t、uint64_t这种固定宽度类型,避免线上环境踩坑。
5. 排坑手记:实测中踩过的五个问题
5.1 浮点数的按位异或要小心
对float做按字节 XOR 本身是安全的,解密后位模式完全还原,精度无损。但真正麻烦的是浮点数的比较。比如血量是ProtectedInt<float>,你用if (hp > 0.0f)判断是否存活,如果从get()得到的是一个经过浮点运算后的值,比如0.0000001,那这个判断就和用普通 float 时完全一样,都可能出现边界问题。
这不是 ProtectedInt 造成的,而是浮點运算本身的特性。解决方案是老生常谈的 epsilon 比较:判断两个浮点数是否相等时,用fabs(a - b) < 1e-6而不是a == b。加密解密本身不会带来额外精度损失。
5.2 操作符重载的二义性:容易踩但也好避
最典型的是同时定义了operator T()隐式转换和operator==(const ProtectedInt&, const T&)之后,p == 100这类表达式会让编译器摸不着头脑:到底是把 p 隐式转换成 T 再比较,还是走 friend 版本的直接比较?我在早期版本里两个都定义了,结果一编译报出一堆 ambiguous,人直接破防。
解决方式是二选一。如果你要走操作符重载路线,就不要定义隐式转换operator T(),所有读取显式走get()。反过来,如果你想要类似整数的隐式类型行为,那就不要定义那些 friend 版本,只保留operator T(),但这样在p == 100时会隐式转换后比较,虽然能用,可读性稍差。我推荐前者,显式get()更清楚,也方便在取值时处理反作弊判断。
5.3 警惕编译器优化和调试器“帮助”
在 Release 模式下,编译器可能把get()解密后的值优化到寄存器或栈上缓存,导致内存里虽然存的是密文,但寄存器窗口里弹出明文。对内存扫描器来说,它扫描的是内存区域,寄存器内容扫不到,所以问题不大。但如果你开了某些调试工具的内存转储,就有可能在堆栈上留下明文痕迹。
另外,不要在解引用 ProtectedInt 后用volatile强制绕过优化。volatile会告诉编译器不要对这个变量的访问做优化,听起来像是“更安全”,实际上会让编译器放弃很多优化机会,同时也不能真正防住内存修改器。正确做法是保持正常的内存访问语义,把防线放在校验和上。
5.4 多线程读写:get 和 set 不是原子的
get()和set()内部有多个内存读写,不是原子的。如果两个线程同时对一个 ProtectedInt 做读取和写入,密文、key、校验和可能处于不一致状态。比如线程 A 刚更新完密文还没更新校验和,线程 B 进来读取,校验失败返回 0,看起来就像数据被篡改了一样。
如果多线程必然访问同一个 ProtectedInt,建议在业务层加锁,或者用std::atomic包裹整个结构体。不要试图在单次get()/set()内部解决所有并发问题,那会让代码复杂到难以维护。多数游戏的数值写入集中在逻辑线程,读取也集中在逻辑线程,真正跨线程访问的数值其实很少,这类数值单独走加锁通道即可。
5.5 校验和算法太弱会被逆向者绕过去
校验和是最后一道防线,但也是攻击者重点分析对象。如果校验算法是固定写死的,攻击者逆向一次就能知道规则,然后作弊时同步修改密文和校验和,校验机制就会形同虚设。
对策是让校验算法“动起来”。我的做法是把几个随机常量在程序启动时生成,嵌入到哈希计算流程中,同时定期在版本迭代时更换混合方程。这样攻击者破解的产物有保质期,下个版本一更新,之前的破解数据就失效了。另一个思路是把校验结果分散存多个副本,读取时做多数投票,增加攻击者同步修改的难度。这两招叠加以后,普通作弊者基本没有能力维护一个长期可用的破解方案。
6. 最后再多说几句:反作弊是分层防御,不是一把锁打天下
把 ProtectedInt 引入项目后,最直接的感受是堵住了“一键修改数值”型的作弊玩家。以前金币被改成几千万,玩家截图发到社区,你说数据异常,还要花时间查证。现在客户端本地数值在内存里是乱码,修改器搜不到、改了也无效,外挂玩家自己就放弃了。对轻度和中度作弊者来说,这个成本已经足够劝退。
但有一点必须说清楚:ProtectedInt 挡不住专门逆向你的核心玩家。他们可以挂调试器跟踪get(),可以分析校验算法,可以 patch 掉校验分支,甚至可以 hook 游戏内核函数直接改返回值。任何纯客户端方案都做不到绝对安全。真正关键的经济数据和状态变更,最终都要以服务器校验为准。我的习惯是客户端用 ProtectedInt 提高作弊门槛,服务端对每个关键请求做合法性校验,两边一配合,普通玩家作弊的成本和收益就严重失衡了。
最后分享一个小技巧:上线后不要只看外挂投诉,多去看看社区玩家晒出的“惊人战绩”。如果你的充值系统或者排行榜上出现明显不真实的数据,而客户端居然没有拦截,那大概率不是 ProtectedInt 失效,而是某个入口把解密后的值当成了可信输入。每次遇到这种情况,我都会顺着数据流回溯一遍,看看是哪个地方没走get()直接裸读了内存。这种事踩多几次,就慢慢养成看到裸int就条件反射的习惯了。