
做游戏反作弊这两三年我最深的体会就是游戏里的金币、活力、血量和攻击力这些关键数值只要在客户端内存里是一块普通的int、float、long就一定会有人用内存修改器去改。用struct也好用class也好把这些数值封装成一个带读写接口和校验逻辑的独立类型也就是我今天要重点讲的ProtectedInt是目前成本最低、见效最快的一种客户端防线。这套思路既能用在单机/弱联网游戏里防止“改个百万金币”的尴尬局面也能在竞技类游戏里大幅提高本地数值被篡改的门槛适合客户端程序、反作弊开发以及独立游戏开发者参考。很多同学最早学 C 语言的时候就接触过struct的用法点坐标、学生信息、RGB 颜色本质上就是把一组相关变量打包成一个复合类型。但很少有人会想到这个基础机制同样可以用在反作弊上。本文我会从内存修改器的原理讲起把ProtectedInt的字段设计、读写流程、性能开销和避坑点全部拆开最后给出可以直接抄的 C 实现。1. 为什么要给游戏关键数值上锁从一次“无限金币”事件说起1.1 内存修改器是怎么一步步定位到金币地址的先还原一个真实场景。某个弱联网卡牌项目上线不到一周论坛上就有人发帖子标题写着“教你 5 分钟改出无限金币”。我们技术组第一时间把举报截图拉下来看发现对方的操作流程非常标准化打开游戏记录当前金币数值再挂上 Cheat Engine 扫描内存进游戏花掉一点金币再次扫描新的数值重复两三轮后金币在内存里的地址就只剩下三五个候选然后把这几个地址全部锁定成一个天文数字回游戏一看金币果然变了。这就是典型的内存修改术。游戏运行的时候int coin 5000;这一个变量最终会变成进程地址空间里某一块内存上的 4 个字节。内存修改器不需要理解游戏逻辑它只做一件事扫描符合特定值的内存区域再根据数值变化缩小范围最后通过写内存接口直接改掉那块字节。整个过程中游戏代码本身不参与任何校验所以数值“啪”一下就被改了。这个过程还有一个更隐蔽的变种不直接搜静态数字而是用“未知初始值 比较变化”的方式先扫描全部内存再根据数值增大/减小/不变来过滤地址。这种方式连金币当前是多少都不用知道只要玩家操作会导致数值变化修改器就能一路收敛到目标地址。所以只要关键数值以明文、固定偏移的形式存在于内存中被找出来就只是时间问题。1.2 明文数值为什么那么容易被改如果一个数值在内存里的表现形态越是“标准”被改起来就越快。int32_t就是 4 字节、小端序、连续存储搜索条件极其简单float虽然带小数但搜索器也支持浮点比较。这些基础数据类型没有任何“自我保护”的概念改者可以直接定位、直接写入游戏进程完全不知道发生了什么。举个例子你用 4 字节搜索搜到当前金币地址后在 CE 里把值改成99999999游戏 UI 上立刻就会显示“金币99999999”。更麻烦的是很多游戏客户端在收到变化后的数值后还会把它用于下一步逻辑比如用本地金币来判断是否允许升级、抽卡、购买道具。这样一来作弊者不只是改了个显示而是真的绕过了客户端的消耗校验把游戏经济体系直接打穿。你可能会问那服务器不校验吗答案是服务器能校验“最终请求”没法校验所有“中间过程”。战斗中的实时血量、临时增益效果、位置同步、伤害预测这些高频数据如果每一跳都让服务器裁决延迟和带宽谁也扛不住。所以客户端必须保留一部分数值的计算权和存储权而这部分恰恰就暴露在了修改器面前。1.3 ProtectedInt 到底想防住谁先说清楚一个观点ProtectedInt防的不是那种能把整个客户端逆向成伪代码、再用调试器单步跟踪的顶级安全研究员它防的是海量的普通作弊玩家和半吊子修改党。这一类人用现成工具扫描内存、锁定数值技术水平不高但数量极大对游戏生态的污染也最严重。我们要做的就是把他们的操作门槛抬高让“一键改金币”变成“找不到、改不了、改了就被发现”。所以ProtectedInt的目标很明确关键数值不能以明文形式直接躺在内存里至少不能是“搜索一个数字就能定位”的状态所有读写必须走受控接口不允许外部代码直接操作底层字节即使数值被硬生生写坏读取时也要能检测出异常而不是傻乎乎地把脏数据返回给业务逻辑性能损耗要足够低不能因为加防护就把战斗模块拖垮。这套思路本质上就是给关键数值穿上一层“变形外衣”真正的数值被加密、打散、备份任何直接读取内存的人看到的都是一堆看不出规律的数据。2. ProtectedInt 核心设计用 struct 把一个数值变成一座小堡垒2.1 三层防线加密存储、冗余校验、版本时间戳我在设计第一版ProtectedInt时给自己定了一个原则不要追求军事级安全而是要追求“在性能可接受的前提下让篡改难度和检测概率同时拉高”。因此我最终采用了三层防线叠加的方案。第一层是加密存储。真实值不直接放进结构体而是用一把随机密钥做异或后存起来。密钥每次写入时都重新生成避免同一把钥匙用太久、在内存里留下固定模式。为什么选异或而不是 AES因为关键数值的读写频率太高战斗循环里一个攻击力数值可能一帧要读几十次AES 的全量加解密开销在 ARM 设备上不够划算。异或虽然只是基础运算但配合每次换 key足以让内存扫描器找不到一个稳定的明文数字。第二层是冗余校验。结构体里保存一个完整备份值和一个校验和。每次读取的时候把解密后的值、备份值、校验和三者对齐检查一次只要发现不一致就说明某块内存被改过了立刻走异常处理流程。这一层防的是那些“先搜到地址、再直接改字节”的粗暴操作你把加密后的字节改了备份没改你把备份改了校验和又对不上。第三层是版本号和时间戳。客户端每一次写入都让版本号自增并记录一个写入标记。如果作弊者试图把数值“冻结”在一个旧状态也就是反复改回同一个值版本号和时间戳依然会暴露问题。这一层尤其重要因为很多锁血挂、锁金币挂的本质就是内存锁定循环写入不只是改一次而是每帧都强制覆盖。2.2 struct 内部字段逐项拆解我这里用 C17 写一个简化但可运行的基础版字段设计如下#include cstdint #include random namespace anti_cheat { class ProtectedInt { public: explicit ProtectedInt(int32_t initial 0) { set(initial); } int32_t get() const { if (!validate()) { OnTamperDetected(); return default_value_; } return decode(encrypted_value_, key_); } void set(int32_t value) { key_ generate_key(); encrypted_value_ encode(value, key_); shadow_value_ value; checksum_ compute_checksum(value); version_; last_write_tick_ get_tick(); } private: int32_t encode(int32_t v, uint32_t k) const { return v ^ static_castint32_t(k); } int32_t decode(int32_t v, uint32_t k) const { return v ^ static_castint32_t(k); } uint32_t compute_checksum(int32_t v) const { uint32_t h 2166136261u; h (h ^ static_castuint32_t(v)) * 16777619u; h (h ^ key_) * 16777619u; return h; } bool validate() const { int32_t decrypted decode(encrypted_value_, key_); if (decrypted ! shadow_value_) return false; if (compute_checksum(decrypted) ! checksum_) return false; return true; } uint32_t generate_key() const { std::random_device rd; return static_castuint32_t(reinterpret_castuintptr_t(this)) ^ rd(); } static uint64_t get_tick() { return static_castuint64_t( std::chrono::steady_clock::now().time_since_epoch().count()); } int32_t encrypted_value_ 0; int32_t shadow_value_ 0; uint32_t key_ 0; uint32_t checksum_ 0; uint64_t version_ 0; uint64_t last_write_tick_ 0; static constexpr int32_t default_value_ 0; }; }逐个字段说理由encrypted_value_加密后的值是内存中唯一直接和真实值相关的数据。它的特点是每次set后都可能不同即使写入同一个数字因为key_变了字节模式也在变。shadow_value_明文备份。有同学会问都加密了为什么还要存明文备份因为校验需要成本低解密一次再比对比做一次复杂哈希要快得多。它单独被改也不会通过校验因为校验和还会核对。key_写值时的随机密钥每次写入都更新。generate_key()里用了reinterpret_cast(this)把对象地址混进去这样不同对象即使写入相同数值加密后字节也不一样避免“同数值同字节”的规律被扫描器抓到。checksum_FNV-1a 风格的轻量校验和把值和密钥一起混入。任何单独修改encrypted_value_、shadow_value_或key_的行为最终都会导致校验失败。version_和last_write_tick_用于检测内存冻结型修改。如果攻击者把值锁死在一个旧数值上即使他同时把几个字段全部改回一致版本号也会停在旧值无法模拟正常逻辑里的连续写入。2.3 读写流程每次 set/get 背后发生了什么set流程不是“赋值”那么简单它要做的事依次是生成新密钥、加密数值、更新备份、计算校验和、版本号自增、更新时间戳。这里有一个被我反复强调的细节必须在写入所有冗余信息之前先保证当前值处于合法状态。换句话说逻辑层如果依赖旧值做增量操作应该先get()一次确认没被篡改再算出新值传给set()。get流程则分三步走先解密加密值和备份值比对再用校验和函数重新算一遍结果和存储的checksum_比对最后检查版本号与时间戳是否满足业务预期。只要任何一步不对函数就不会返回那个可疑数值而是触发OnTamperDetected()。OnTamperDetected()具体干什么取决于业务策略。我见过三种常见处理方式静默修正把值重置成一个安全默认值比如 0 或最近一次服务器下发的合法值玩家无感知但日志里记录下来。上报加回滚把篡改现场数据上报到服务器同时本地尝试用服务器存档覆盖。直接踢下线适用于竞技游戏检测到关键数值被篡改直接断开连接后台拉黑设备信息。这三种没有绝对优劣。经济类数值可以偏向轻处罚、多记录竞技类数值可以偏向重处罚、零容忍。我个人的习惯是客户端只做“检测与纠正”是否封禁永远以服务器收集到的多次证据为准。2.4 为什么用 struct 而不是直接改普通变量这个问题我常被问到。核心原因有两个类型语义和访问控制。先说类型语义。如果你把金币从int coin改成ProtectedInt coin所有使用点都必须通过coin.get()和coin.set()访问。这个改动会让编译器帮你审查一遍全工程任何直接对coin做算术的地方都会报错。这就从类型层面强制收口了所有读写路径而不是靠代码规范“约定大家都别直接改”。再说访问控制。C 里struct和class其实只有默认访问权限的区别我这里为了贴近玩家熟悉的写法用struct关键词把多个字段打包成一个独立类型。但放在 C 语言视角看struct的意义本来就是“把相关数据聚合成一块自定义内存布局”。int做不到这一点因为它就是一个裸的 4 字节谁都能读写。而当字段被私有化、接口被公开后外部代码就没办法绕过校验直接修改内部状态。当然struct不是银弹。如果整个ProtectedInt对象在内存里能被识别出来攻击者依然可以整体操作。这也是为什么我会在 3.2 节加入多副本投票、在 4.2 节强调内存布局对齐的原因。3. 手写 ProtectedInt从基础版到多副本投票的实现细节3.1 基础版加密值加备份五分钟跑起来上面的代码已经是一个基础版可以直接粘进工程跑。接下来我带你过一遍我实际测试时用的单元测试代码主要验证三件事正常读写是否正确、篡改加密字节后能否检测、重复写入同一数值后内存字节是否有变化。#include cassert void test_basic() { anti_cheat::ProtectedInt coin(100); assert(coin.get() 100); coin.set(2500); assert(coin.get() 2500); // 尝试篡改这里用 const_cast 只是为了模拟内存写入攻击 // 实际攻击者会用外部工具逻辑上同等地直接修改对象字节 anti_cheat::ProtectedInt hack(999); // 我们无法访问私有成员所以在测试里可以写一个友元函数模拟 // 或者用 memcpy 强行覆盖。memcpy 方式后面 4.2 节会细讲。 }实际项目里为了测试篡改检测我会在ProtectedInt里加一个友元测试类专门模拟内存损坏。这一点很重要你的检测逻辑是不是真的有效必须靠这种“人为破坏字段”的测试来保证而不是只在心里觉得“应该能发现”。基础版的实现已经能挡住“搜索 100 改 99999”的基础玩法。因为搜索器在内存里看到的不是 100 的明文而是一个随机的 4 字节结果。除非攻击者知道你的加密算法和密钥位置否则无从下手。3.2 加码多副本交叉验证与版本号防冻结基础版有一个弱点如果攻击者经验丰富他把encrypted_value_、shadow_value_、checksum_全部抓出来然后同时修改校验就不一定拦得住。应对办法是增加冗余副本做“投票式”验证。我的进阶版实现思路很简单不存一份加密值而是存三份加密结果三份用不同的密钥、不同的偏移做异或读取时对三份独立解密然后两两比对取“得票最多”的那个值作为结果。如果三份都不一致说明内存被严重破坏直接走异常流程。class ProtectedIntV2 { public: int32_t get() const { int32_t v0 decode(enc_values_[0], keys_[0]); int32_t v1 decode(enc_values_[1], keys_[1]); int32_t v2 decode(enc_values_[2], keys_[2]); if (v0 v1) return v0; if (v0 v2) return v0; if (v1 v2) return v1; OnTamperDetected(); return default_value_; } void set(int32_t value) { for (int i 0; i 3; i) { keys_[i] generate_key_with_salt(i); enc_values_[i] encode(value, keys_[i]); } shadow_ value; version_; } private: int32_t enc_values_[3]; uint32_t keys_[3]; int32_t shadow_; uint64_t version_; };多加两份副本性能开销自然会涨。但收益也很明显攻击者至少需要同时伪造三个位置的加密数据还要保证三份解密后一致单靠 CE 那种“找到地址改数值”的工具基本不可能完成。版本号防冻结功能在竞技游戏里很有效。有的作弊者习惯开着内存锁定功能每帧强制把血量写回满值。这种情况下就算他能同时伪造三份副本他也没法把version_模拟成“每帧增加一次”。所以业务逻辑可以在get()的时候额外检查两次读取之间版本号有没有合理推进如果连续 N 次读取版本号完全没变判定为可疑对象。3.3 替换到游戏逻辑里的使用示例与测试真正替换的时候我建议分两步走。第一步把底层数据成员类型改了让编译错误告诉你哪些地方用了旧逻辑。第二步逐个修复编译错误把读改get()、写改set()并且顺手把、-、*这类操作改成显式计算。举个例子玩家购买道具的消耗逻辑// 旧代码 gold - price; // 新代码 int32_t current_gold gold.get(); if (current_gold price) { return PurchaseResult::NotEnoughGold; } gold.set(current_gold - price);注意不要在业务层写gold.set(gold.get() - price)这种一行的写法。因为get()可能触发篡改检测先读再写会让逻辑变得难以排查。宁可多写两行也要保证“读取结果”和“写入结果”之间的语义连贯。测试方面除了单元测试我强烈建议加一个“长期运行压力测试”在游戏主循环里跑几千帧每帧对ProtectedInt随机读写同时用另一个子线程随机篡改它的内存看游戏能不能稳定检测到并恢复。这种测试能帮你找到很多隐蔽问题比如对象被拷贝后密钥错乱、多线程竞争导致误判等等。3.4 性能实测这几百纳秒花得值不值我最初担心ProtectedInt会拖慢性能所以专门在 iOS 和 Android 真机上做过压测。结论是基础版单次get()大约比直接读int慢 100~300 纳秒set()因为要生成新密钥和计算校验和慢 300~600 纳秒。在大多数游戏逻辑里关键数值的读写不是每帧几万次所以这个损耗完全可接受。但要注意热路径问题。战斗循环里面如果有一个“每帧读几十次”的攻击力数值500 纳秒乘以几十次再乘以 60 帧也会产生可感知的 CPU 开销。我的优化建议是对真正的一线热数据可以降级为“低频校验 缓存值”模式比如每帧只做一次校验帧内使用缓存的明文下一帧开始前再重新校验或者直接把这个数值放到服务器权威计算客户端只做表现层同步。另外一个优化点是编译器内联。getter/setter必须写在类定义里让它有机会被内联不要搞成虚函数虚函数调用本身的开销比校验还大。用constexpr和 C20 的consteval也能在编译期做一些哈希预计算但这些属于锦上添花先把基础版跑通再考虑。4. 接入游戏时的避坑清单这些坑我都踩过4.1 不是所有数值都值得上锁有一段时间我犯过“全盘保护”的错误把攻击力、防御、暴击率、金币、钻石、体力、经验、甚至道具叠加数量全部换成ProtectedInt。结果就是代码改得人仰马翻内存占用翻了快两倍性能数据也不好看而实际效果并没有质变。后来我总结了一套筛选原则只保护能直接兑换真实价值、或者能直接影响竞技公平的数值。经济类数值比如金币、钻石、点券、体力一定保护战力类数值比如攻击力、血量、护甲、技能冷却要有选择地保护那些只在客户端表现层使用的数值比如特效播放次数、头像框 ID完全没必要加保护。保护过多不仅浪费性能还会让攻击者更容易从“大量相似结构体”中反推出规律。4.2 内存布局、对齐与 C# 值类型的坑一个很隐蔽的坑是结构体对齐。ProtectedInt里有int32_t、uint32_t、uint64_t这些字段编译器会按对齐规则插入 padding。比如某个字段组合后结构体实际大小可能不是各字段之和而是被对齐到 8 或 16 字节。攻击者如果学过 C 语言 struct 的用法他反而会利用这种对齐特性判断结构体边界。解决办法是在结构体里加static_assert确保sizeof(ProtectedInt) 期望值同时不要依赖memcpy直接把结构体当成一块普通内存去复制。C# 和 C 的坑不一样。C# 的struct是值类型把它放进ListProtectedInt后list[i]返回的是副本直接调用list[i].set(100)不会修改原数据必须写成list[i] new ProtectedInt(100)。我见过同事在这个坑上卡了一下午。如果你对 C# 的struct语义不够熟我建议直接用class实现ProtectedInt反正对外接口一样但引用类型在使用上更符合直觉。还有一个和反调试相关的点不要在结构体里用一个固定签名字段来识别自己比如故意存一个魔数0x504941用来标记“我是 ProtectedInt”。这个做法等于给攻击者发地图对方只要扫描这个魔数就能把所有受保护数值一网打尽。我早期踩过这个坑后来把识别逻辑改成“版本号 内存地址 校验”的组合魔数方案彻底废弃。4.3 客户端只是守门员服务器校验才是底线哪怕你把ProtectedInt写到完美也只能提高内存修改的门槛拦不住更高级的手段。比如有人直接把游戏的 Assembly/C 逻辑逆向出来在调用set()的地方注入代码绕过你的加密流程直接构造一个合法对象又比如有人抓包篡改客户端发给服务器的请求把“购买”的价格改成 1。所以我在任何项目里都强调一句话客户端安全是守门员服务器校验才是底线。ProtectedInt只是把“改内存”这种低端行为挡在门外真正决定一个数值是否合法必须靠服务器账本、增量校验和操作鉴权。具体做法上服务器可以维护每个玩家的经济流水账。客户端上报“金币增加 1000”时服务器不仅要看最终余额对不对还要看这条流水是否符合业务逻辑比如来源是完成任务还是购买道具。对于战斗数值服务器做不到每帧裁决但可以抽帧校验随机挑几个关键节点要求客户端上报最近 30 帧内的关键数值变化轨迹服务器做一次重演对比。4.4 和其他安全手段的搭配日志、上报、反调试ProtectedInt单独用效果有限和日志、上报、反调试搭配起来就能形成一个完整的“发现-追踪-处置”闭环。我的习惯是在OnTamperDetected()里记录这些内容被篡改的对象标识、所在模块、当前解密值、异常类型、时间戳、设备 ID、IP 段。这些日志不进游戏日志文件而是走加密上报通道发到服务器由后端做聚合分析。不要把篡改日志写得太大否则线上流量会很难看。我通常只记录前 64 字节的内存快照和调用栈摘要够排查就行。反调试手段可以作为另一个独立层级检测到调试器附加时让数值进入“高敏感模式”提高校验频率没有调试器时保持低频校验省性能。不过反调试本身是一个非常深的领域和ProtectedInt的配合点到为止就好。5. 常见问题与排查技巧实录5.1 游戏卡顿先查热路径上的 get 频率接入ProtectedInt后如果出现卡顿第一件事不是调校验算法而是打开采样性能分析器找到get()的调用频率和调用来源。我遇到过最夸张的一次是某个 UI 界面的金币文本每帧刷新 60 次每次刷新都要读 30 个ProtectedInt结果一帧里光是读写就多了近 10 万次额外指令。解决办法通常有两种把高频读的数值缓存到普通变量在需要展示时才同步一次或者干脆在 UI 层直接订阅数值变更事件数值不变就不刷新。核心思路是让ProtectedInt服务在“状态变化”的场景而不是“每帧轮询”的场景。5.2 检测到篡改后该静默修正还是直接踢人这个问题的答案取决于游戏类型。经济类游戏我推荐静默修正加服务器上报因为直接踢人会让正常玩家在数据异常时感到困惑竞技类游戏我推荐第一时间上报并断开对局因为哪怕只让作弊者多打完一局对局内的其他玩家体验就会崩塌。还有一种中间策略首次篡改只回滚不处罚短时间内多次篡改则触发生效。这个策略可以有效防止“误杀”真实玩家也能让作弊者意识到系统已经看着他了。我实际项目中就把这个策略做成了可配置项运营团队可以在后台调整阈值。5.3 存档序列化和网络同步时不踩雷ProtectedInt在外存和网络传输里不能直接二进制定位。因为序列化本质上要“把对象变成一堆字节”version_、last_write_tick_、key_这些字段对存档来说全是垃圾信息。正确做法是只对真实值做一次额外加密再写入存档或同步包到达对端后重新构造ProtectedInt。要注意加密后的值不要直接拼接进 JSON 之类的文本格式最好转成 Base64 或十六进制字符串。后续读取存档时先用存档自己的校验机制验一遍完整性再还原成ProtectedInt。这样即使攻击者把存档文件改了也过不了存档校验这一关。5.4 一个简单的性能/内存/检测能力对比方案单次 get 开销单次 set 开销内存占用篡改检测能力普通int约 1ns约 1ns4 字节无ProtectedInt基础版约 150ns约 400ns约 32 字节能检测单字段篡改ProtectedInt多副本版约 300ns约 700ns约 64 字节能检测多字段伪造服务器权威数值网络延迟网络延迟客户端无存储最强但实时性差这张表是我在移动设备上的大致测量结果真实数值会因编译器、CPU 和设备型号波动。从表里能看出基础版的开销其实是完全可以接受的多副本版适合最关键的核心数值服务器权威数值则负责最顶层的公平性保障。5.5 反作弊是猫鼠游戏我的个人看法做了几年反作弊我的心态已经变得务实很多不存在一劳永逸的方案任何客户端保护都只是在跟作弊者赛跑。ProtectedInt能挡住大量低成本的“搜索-修改”流程它把作弊入门门槛从“5 分钟”拉高到了“需要逆向代码”的程度这对绝大多数游戏来说已经足够有价值。我在实际项目中越来越发现最有效的反作弊组合拳是客户端用ProtectedInt挡住低端修改服务器用账本和抽帧校验守住数值底线运营侧用日志和封禁策略形成威慑。三者缺一不可但ProtectedInt永远是那个最优先落地、成本最低、收益最直接的一环。如果正在看这篇文章的你也遇到了“游戏金币被刷爆”的糟心事不妨先从这个小小的 struct 开始。