ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

游戏数值防篡改:ProtectedInt结构体设计与C/C++实现

游戏数值防篡改:ProtectedInt结构体设计与C/C++实现 有一回我差点被玩家投诉弄到心态爆炸明明服务端对账没问题但截图上背包金币就是 999999999。排查到凌晨才发现客户端本地变量直接用int gold存内存修改器扫到地址后把值一改整个经济系统就失灵了。从那次以后我养成了一个习惯凡是玩家可见的关键数值绝不用裸的int和float摆在内存里。这篇文章分享一下我用一个自定义ProtectedInt结构体给游戏关键数值上锁的经验。会从内存修改器的扫描原理讲起再拆解struct为什么适合干这件事最后给出可直接抄走的 C/C 实现以及上线后踩过的几个坑。不管你做的是单机游戏、弱联网手游还是想在工具类软件里防数据篡改这套思路都能直接落地。1. 先搞清楚敌人是谁内存修改器是怎么找到你的数值的1.1 精确扫描和模糊扫描的套路以常见的 Cheat Engine 为例找游戏数值基本就两步。第一步是你知道当前值是多少。比如角色金币是 100CE 做一次“精确数值扫描”把所有 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在每次读取前校验校验失败直接返回 0protected_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_vT, 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_castuint32_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_castconst unsigned char*(value); unsigned char* dst reinterpret_castunsigned char*(out); for (size_t i 0; i sizeof(T); i) { dst[i] src[i] ^ static_castunsigned 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 ProtectedIntT v) { return os v.get(); }如果用这个模板替换原来的int大部分业务代码不用改。原来写gold 50现在只要gold的类型变成了ProtectedIntint这个表达式依然成立。原来写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;改成ProtectedIntint gold_{0};然后把编译报错的地方逐个修正。由于操作符重载覆盖了加减乘除和比较大部分业务代码会直接通过编译少部分需要把gold.Get()显式取出来做格式化或者序列化。一个典型的金币类目如下class PlayerAccount { ProtectedIntint gold; ProtectedIntint 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 本身是安全的解密后位模式完全还原精度无损。但真正麻烦的是浮点数的比较。比如血量是ProtectedIntfloat你用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就条件反射的习惯了。
返回列表