ARTICLE DETAIL

资讯详情

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

Unity游戏内存防修改:八槽密文阵保护数值不被Cheat Engine篡改

Unity游戏内存防修改:八槽密文阵保护数值不被Cheat Engine篡改 说实话我见过太多 Unity 项目栽在同一个坑上游戏上线第二天玩家群里就有人晒出 999999999 金币的截图后台一看不是服务器流水里的正常数据是客户端内存被内存修改器直接改了。在 Unity 里一个int不过就是普普通通的 4 个字节Cheat Engine 这类工具按数值一搜一个准你辛苦设计的经济曲线和数值成长别人半小时就给你拉平了。这篇文章想聊的就是怎么让这个“普普通通的 4 字节”不再普通。核心思路一句话同一个逻辑 int在内存里藏 8 份互不相同的密文读的时候必须凑齐多数派才放行写的时候 8 份一起刷新。我习惯管它叫“八槽密文阵”。配合种子轮换、自愈重写和后台看门狗基本能把“打开修改器搜个数值改一改”这个入门级操作直接废掉。适合谁看正在用 Unity 做游戏、担心客户端数值被改的开发者特别是没有预算上商业反作弊方案的独立团队。看完你至少能多一层底气。1. 修改器凭什么能改你的 int1.1 Unity 内存里 int 的真面目不管你是 Mono 还是 IL2CPP 打包一个int字段最终落到内存里都是 4 个连续字节。Mono 模式下更惨托管堆里对象布局、字段偏移都相对规整内存编辑器只要扫描进程地址空间输入当前金币数就能把所有包含这个值的地址列出来。改起来更简单锁定数值游戏一读就是被改过的。IL2CPP 会好一点吗算好一点但也只是一点。代码被转成 C 原生代码字段以原生结构体形式存在扫描依然可行只是字符串和元数据少了逆向门槛从小白级变成入门级。注意我用的是“门槛”这个词不是“不行”。内存里的 4 字节永远能被搜到问题只在于搜到之后改了有没有用。1.2 修改器的三板斧做过对抗的人都知道内存修改器找数值的手段其实就那么几种万变不离其宗精确数值扫描先在游戏里把金币赚到 100暂停输入 100得到一批地址花掉一点变 80再筛一遍反复几次地址就收敛到唯一一个。这是最原始的玩法对付明文 int 百发百中。变值扫描游戏数值变化很快、或者带小数不好锁定就用“未知初始值 数值减小/增大的地址”。修改器一次性标记全部内存每次变化后筛掉没变的剩下的就是目标。写入断点找到地址后不急着改而是让修改器监控“谁往这个地址写入”。一旦你游戏里用代码给这个字段赋值断点就会逮住那条写指令直接定位到你的赋值逻辑连函数都能反出来。这三板斧里前两板打的是“值”第三板打的是“代码”。单纯的密文方案对付前两板很有效对付第三板就得叠加别的招后面我会专门讲。1.3 为什么“加个密”会被秒破很多刚接触防护的同学第一反应是给数值异或一个 key 不就行了比如内存值 真实值 ^ 0x5A。这种方案的破法是钥匙就藏在代码里。用 Mono 打包时Assembly-CSharp.dll 直接躺在 Managed 目录里反编译工具一把梭你那个0x5A就是白纸黑字写在get/set方法里的常量。逆向者找到你的加密函数直接在内存编辑器里写出解密表达式等于没加密。密文规律太明显。如果所有槽位都用同一把 key那么 8 份密文在内存里长得一模一样。修改器搜“100”搜不到但搜“0xA5C3”能搜到 8 个一模一样的地址全改就完事。密钥和密文挨得太近。有些人把 key 放在字段旁边内存 dump 一拉密文旁边就是一个可疑常量几乎等于把钥匙挂在保险柜门上。所以真正的防护绝不是“存一个加密值”而是要让内存里的密文多份、互异、带独立钥匙、能自愈。这就是下面方案的核心动机。2. 一个 int 藏 8 份密文方案设计与取舍2.1 八槽冗余密文的基本模型我们要做的不是把“一个值”加密成“一个密文”而是把“一个逻辑值”在内存里拆成8 个密文槽位。每一个槽位都用不同的钥匙加密同一个明文所以同一份明文在 8 个槽里产生 8 个完全不同的数字。读取流程是这样把 8 个槽位分别解密得到 8 个候选明文正常情况下 8 个候选完全一致直接返回如果有 1~2 个槽位被污染多数派仍然成立返回正确值并且顺手把被污染的槽位按正确值重写——这就是自愈如果超过 3 个槽位对不上判定为内存篡改触发告警并按多数派重建。写入流程更简单外部给它赋新值时8 个槽位全部用各自的钥匙刷新一遍。为什么要选 8 而不是 2、不是 16我算过这笔账2 个槽位修改器只要改对 1 个就能骗过检测自愈毫无意义8 个槽位攻击者想强制制造一个“错误的多数派”至少得同时改对 5 个槽因为阈值设在 6而他不知道每把钥匙和盐值改 5 个槽靠猜的命中率是天文数字级别16 个槽位理论上更稳但内存占用翻倍、每次读写计算量翻倍而且“变值扫描”时 16 个地址扎堆的线索太明显反而容易被一锅端。8 是个综合折中既能容忍少量噪声又不会把自己变成一个显眼的靶子。打个比方一栋楼有 8 根承重柱你想拆楼得同时拆掉 6 根才会塌而你不知道哪 6 根才是真的承重柱。2.2 为什么要“每个槽位一把钥匙”这是整个方案最容易翻车的细节。如果 8 个槽用同一把钥匙那密文长得都一样修改器用“搜索相似值”的脚本就能顺藤摸瓜而且只要逆向出一次加密函数8 个槽全军覆没。我的做法是每个槽位的钥匙从“实例种子 槽索引”混合派生格式类似private uint KeyOf(int slot) { uint x _seed (uint)slot * 0x9E3779B9u 0x12345678u; x ^ x 16; x * 0x7FEB352Du; x ^ x 15; x * 0x846CA68Bu; x ^ x 16; return x; }这把钥匙不是一个常量而是由实例种子_seed算出来的。_seed在创建对象时随机生成每次存档读档、每次重新加密都会换。于是同一个数值 100这次运行内存里是[0x1A, 0xC3, 0x52, ...]下次运行就是完全不同的另一串。这里的关键点在于钥匙的派生结果不会以明文形式出现在密文旁边。攻击者看到 8 个数字即使逆向出算法也必须先找到_seed在哪里“种子”是个很不起眼的字段混在对象里很难定位。为了进一步规避“密文旁边躺着个常量”这类联想我加盐的时候还会把槽索引、固定魔数一起混进去让每个槽的盐值也各不相同。2.3 紧凑版一个 int 里物理塞进 8 份 4bit 密文如果你保护的数值范围很小——比如关卡编号、装备品质、技能状态这种 0~15 的枚举不用开 8 个 int 槽可以直接在一个 int 里塞下 8 份密文。一个 int 是 32 位切成 8 个 nibble每个 4 位每份密文占一个 nibble这就是字面意义上的“一个 int 藏 8 份密文”。我写过这样一个紧凑结构public struct TinySecureInt { private int _packed; private uint _seed; private static uint Mix(uint x) { x ^ x 16; x * 0x7FEB352Du; x ^ x 15; x * 0x846CA68Bu; x ^ x 16; return x; } private int NibKey(int slot) { return (int)(Mix(_seed (uint)slot * 0x9E3779B9u) 0x0F); } public int Read() { int[] nib new int[8]; for (int i 0; i 8; i) { int raw (_packed (i * 4)) 0x0F; nib[i] raw ^ NibKey(i); } // 找多数派 int best nib[0], bestCount 0; for (int i 0; i 8; i) { int c 0; for (int j 0; j 8; j) if (nib[j] nib[i]) c; if (c bestCount) { bestCount c; best nib[i]; } } if (bestCount 6) return best; // 篡改告警按多数派重建 Write(best); return best; } public void Write(int value) { int packed 0; for (int i 0; i 8; i) packed | ((value ^ NibKey(i)) 0x0F) (i * 4); _packed packed; } }这样整个对象在内存里就是一个普通的 int 大小散落在结构体里毫不起眼。但代价是值域只有 0~15。如果你的状态值在 0~255可以用一个 ulong 装 8 个字节密文道理完全一样。这才是这个方案真正的“口袋版”不是所有值都值得开 8 个 int 槽小状态换来小体积性价比很高。2.4 自愈与告警让被改的槽“白改”我一直觉得内存防护最性感的地方不是“改不动”而是“改了等于白改”。八槽方案天然自带这个特性攻击者费了半天劲改掉了 3 个槽下次一读系统发现多数派不成立自动按多数派把 3 个槽重写回正确值。他前面做的事完全清零而且整个过程玩家无感知、游戏不崩溃。光自愈还不够我建议加一个连续异常计数如果自愈连续触发多次就判定存在持续篡改这时候别再当老好人直接触发上报本地记录日志、在界面上标记可疑用户、联网的就把事件带到服务器。哪怕当场不断言积累的数据也能帮你之后封禁和做风控策略。3. Unity 实操把方案写进项目3.1 完整版 SecureInt 实现与走读紧凑版适合小状态真正扛大梁的还是完整版。下面是实际可用的一版注释我都写在代码里public sealed class SecureInt { private const int SlotCount 8; private const int MinAgree 6; private readonly int[] _slots new int[SlotCount]; private readonly uint _seed; private int _shadow; private int _suspectCount; public SecureInt(int value) { _seed (uint)Environment.TickCount ^ (uint)value ^ 0x9E3779B9u; _shadow value; WriteAll(value); } private static uint Mix(uint x) { x ^ x 16; x * 0x7FEB352Du; x ^ x 15; x * 0x846CA68Bu; x ^ x 16; return x; } private uint Encrypt(int plain, int slot) { uint key Mix(_seed (uint)slot * 0x9E3779B9u); uint salt Mix(key 0x5A5A5A5Au); return ((uint)plain ^ key) salt; } private int Decrypt(uint cipher, int slot) { uint key Mix(_seed (uint)slot * 0x9E3779B9u); uint salt Mix(key 0x5A5A5A5Au); return (int)((cipher - salt) ^ key); } private void WriteAll(int value) { _slots[0] (int)Encrypt(value, 0); _slots[1] (int)Encrypt(value, 1); _slots[2] (int)Encrypt(value, 2); _slots[3] (int)Encrypt(value, 3); _slots[4] (int)Encrypt(value, 4); _slots[5] (int)Encrypt(value, 5); _slots[6] (int)Encrypt(value, 6); _slots[7] (int)Encrypt(value, 7); } private int Rebuild() { int[] candidates new int[SlotCount]; for (int i 0; i SlotCount; i) candidates[i] Decrypt((uint)_slots[i], i); int best candidates[0], bestCount 0; for (int i 0; i SlotCount; i) { int count 0; for (int j 0; j SlotCount; j) if (candidates[j] candidates[i]) count; if (count bestCount) { bestCount count; best candidates[i]; } } if (bestCount MinAgree) { _suspectCount; if (_suspectCount 3) OnTamperDetected(); } else { _suspectCount 0; } _shadow best; WriteAll(best); return best; } public int Get() { // 快路径先统计和上次影子值一致的槽位数 int agree 0; for (int i 0; i SlotCount; i) if (Decrypt((uint)_slots[i], i) _shadow) agree; if (agree MinAgree) { if (agree SlotCount) Rebuild(); // 有槽被动过自愈 return _shadow; } return Rebuild(); } public void Set(int value) { _shadow value; WriteAll(value); } private void OnTamperDetected() { UnityEngine.Debug.LogWarning([AntiCheat] 检测到持续的数值篡改); // 这里接上报、弹窗、封禁逻辑 } }几个细节值得说快路径先用_shadow上次通过校验的明文做票数统计绝大多数正常读取都只走“解密 8 次 比较 8 次”不触发全量重建性能可控只有异常才走 O(n²) 的找多数派逻辑。Rebuild()用的解密和写入是分开的WriteAll是纯赋值这样自愈不会引入额外的随机性保证多数派一致。我把Environment.TickCount混进种子保证每次创建对象的钥匙流都不同。3.2 接入 MonoBehaviour 与存档规范在游戏里接这个结构很简单关键字段声明为SecureInt类型通过Get()/Set()访问public class PlayerWallet : MonoBehaviour { private SecureInt _coin; private SecureInt _diamond; private void Awake() { _coin new SecureInt(1000); _diamond new SecureInt(50); } public bool TrySpendCoin(int cost) { int current _coin.Get(); if (current cost) return false; _coin.Set(current - cost); return true; } }这里有个容易踩的坑存档时千万不要把密文槽或种子存进本地存档。正确的做法是存档时只存最后的明文数值读档时用当时生成的随机种子重新构造SecureInt。为什么因为本地存档文件是静态的、可被反复分析下载的里面的密钥和种子一旦被破解内存侧花的心思全白费。存明文是有风险的但那个风险属于存档加密的范畴和内存防护是两回事别混在一起。3.3 后台看门狗设计八槽方案有一个软肋如果你只在玩家打开商店时才读取金币那么玩家趁“不读取”的窗口期把 8 个槽全部改一遍下次读取一样会炸。解决思路是放一个后台看门狗定期主动读取关键数值private IEnumerator WatchdogCoroutine() { var wait new WaitForSeconds(2f); while (true) { yield return wait; _coin.Get(); // 主动触发自愈/校验 _diamond.Get(); // 甚至可以对比两个 SecureInt 之间的逻辑关系 } }看门狗的周期我一般设在 1~3 秒太频繁耗 CPU太慢会给篡改者留下窗口期。另外看门狗还可以承载业务一致性校验比如金币和钻石的消费流水应该配对如果金币被改了而钻石没改流水逻辑上就会出现破绽这种破绽是内存方案自己发现不了的得靠业务规则来补。3.4 性能实测与使用边界很多读者关心性能我直接给一组实测参考值。在一台 i5 笔记本、单线程、Debug 模式下完整版SecureInt.Get()约 8 次派生 8 次解密单次耗时大约 60~120 纳秒1000 次调用不到 0.1 毫秒这开销对绝大多数游戏毫无压力。但请注意边界使用场景建议玩家钱包、体力、状态放心用量小收益大每帧遍历 100 个敌人血量并频繁读写不建议叠加起来每帧 1ms会拖帧只读不写的显示型数值可以不保护或只做影子校验需要服务器验证的货币内存侧可降级处理把重点放服务器我的经验是内存防护要做减法。挑 5~10 个作弊收益最大、被改后最影响平衡的数值重点保护别把整个游戏所有 int 都包一层。全包会显著增加代码复杂度和调试成本而且保护得太密也会让玩家“正常游玩”遇到莫名其妙的数值回滚。3.5 别忘了配合构建选项内存方案要发挥最大威力构建配置必须跟上IL2CPP 裁剪用 IL2CPP 打包把托管元数据和字符串信息去掉能大幅提高逆向成本代码混淆交给混淆工具处理让攻击者无法直接对应到源码逻辑link.xml 防裁剪这是 Unity 的经典坑如果你在代码里用反射或非显式方式引用了SecureInt相关的类型和方法IL2CPP 裁剪可能把关键代码直接裁掉运行期报 MissingMethodException。务必在Assets/link.xml里保留相关程序集和类型。4. 修改器的反击与我们的反制4.1 变值扫描他能找到那一窝槽吗修改器最常规的“变值扫描”在八槽方案面前依然可能找到目标你给金币赋值时8 个槽同时变化扫描“变化地址”会把 8 个槽全圈出来。这个无法完全隐藏——数据要写内存写内存就会产生变化特征。我的反制办法有两个加诱饵在SecureInt旁边故意放几个随业务变化的假字段写值时让它们也一起抖动。修改器按“变化地址”筛出来一二十个地址分不清哪个是真槽哪个是诱饵逐一验证成本大增。内存平移每隔一段时间用新的随机种子重建整个SecureInt对象让 8 个槽整体搬家。地址一旦换了修改器之前冻结的地址全部失效。4.2 写断点顺着写指令摸进函数这是最棘手的一招。修改器用硬件断点监控写入地址一旦逮到写指令就能定位到WriteAll对应的函数然后反编译、找种子、找算法。如果只有这一道防线等于把宝全押在算法保密上这不现实。对应策略IL2CPP 让函数变成原生代码反推难度远高于 Mono 的托管函数混淆器打乱流程和控制流让写指令的位置变得难以定位不要在同一个函数里连续写完 8 个槽把槽位写入顺序打乱、分散到几个方法里破坏写断点的线性分析。说白了内存防护防的是“改内存的人”写断点攻击的是“代码本身”需要用代码层面的防护去兜底。两者结合才能形成闭环。4.3 全内存冷搜暴力改成本决定一切还有一种极端做法不管 8 个槽的结构直接全内存扫描“看起来像金币的值”一口气全改。但现在的方案里值被拆成了 8 份异质密文没有任何一份是明文冷搜根本搜不到目标值。想暴力改8 个槽里至少有 3 个必须改去同一个人为值而每把钥匙都不同得先解出 8 把钥匙。就算解出来了后台看门狗一轮自愈就把他给改了。攻击成本已经从“开修改器点几下”上升到“写一个针对性的插件”这本身就劝退了绝大多数作弊玩家。4.4 真正无法被改的方式服务器权威最后必须说一句大实话客户端内存做得再花也挡不住一个决心逆向到你代码层、把整个逻辑都 Hook 掉的高手。对经济系统这种核心资产终极方案是服务器权威金币、钻石、体力全部存在服务器客户端只做显示每一次扣费、消费都通过服务器校验。内存侧要防的是本地数值显示、单机关卡进度这种服务器无法实时接入的部分。我见过不少独立团队本末倒置花两周给客户端金币做内存保护却没有给服务器加一个“日流水异常检测”——结果挂照样刷只是方式从改内存变成了改协议。我的建议永远是纵深防御内存密文抬高门槛服务器校验兜住底线行为分析抓漏网之鱼。三层里至少要有两层游戏才有点安全感。5. 常见问题与避坑清单最后把实操中真正踩过的坑整理成一张速查表这些在文档里基本查不到现象本质解决办法存档里存了种子被破解后所有密文可解密钥管理错误存档只存明文运行时临时生成种子重新加密Debug 模式下性能尚可Release 后反而卡顿代码裁剪/优化改变引擎行为用 Profiler 在 Release 真机上重新测一遍热路径多人同时读写SecureInt导致自愈抖动多线程竞态只允许主线程读写或给 Get/Set 加锁IL2CPP 发布后报MissingMethodException类型被裁剪在 link.xml 里显式保留相关程序集并使用玩家报告“数值莫名其妙被回滚”自愈把合法修改当成篡改检查业务是否在后台线程改数值确认阈值 MinAgree 调合适只保护了钱包装备强化数值被改防护面不完整对装备属性做同样的 SecureInt 封装或加服务器校验想保护的值超过 32 位long方案扩展没跟上用两个 SecureInt 组合或者升级成 8 字节 ulong 版本修改器把 6 个槽都改了攻击者的确下了功夫触发持续异常上报配合服务器风控封禁再补充几条实战心得最小可用原则第一版先只保护 3 个核心数值跑一周看日志、看自愈次数、看有没有误伤正常玩家确认稳了再铺开。一上来全项目替换调试期会非常痛苦。自愈要留痕每次Rebuild都打一条带时间戳的日志不用太细方便出线上事故时回查。不要迷信“绝对安全”内存防护本质是“提高作弊的边际成本和不确定性”。真正上线后我建议团队自己先当一回黑客用常见的修改工具对发布版本做一轮温和攻击测试——如果连自己都能轻松改穿那就别指望别人改不穿。我做 Unity 对抗内存修改这条路走了挺久最大的体会就是现代作弊拼的不是谁的加密算法更花哨而是谁能在“改一个值”和“被发现改值”之间制造更多的不确定性。八槽密文阵能做到的就是让每个想靠内存修改器白嫖数值的人必须先翻过 8 道互不相同的锁一边翻一边还要提防着看门狗把锁悄悄换掉。合理用在一个项目里它能拦掉 90% 的脚本小子剩下的 10%请交给服务器和风控去处理。
返回列表