记一次存档安全排查:明文金币、内存修改和几个踩过的坑
前段时间帮一个项目做客户端安全检查,查到存档这块有点意外:金币、等级、背包全明文躺在本地,字段名一眼能对上,改完塞回去客户端照读。这篇不铺理论,把当时排查的过程和踩过的几个坑记一下,代码也贴上,给做单机、弱联网的同行避个坑。
先说查到的。Unity 的 PlayerPrefs 在 Android 上就是 /data/data/<pkg>/shared_prefs/<pkg>.v2.playerprefs.xml(老版本没那个 .v2)一个明文 XML,金币明晃晃一行 <int name="gold" value="99999" />。Unity 官方文档自己都写着 PlayerPrefs 不加密、别存敏感数据。背包、任务也一样,加个道具往 JSON 塞一项,过个任务把 flag 从 0 改成 1。root 机直接翻,没 root 的包如果 allowBackup 没关、或者是可调试包,adb backup 也能把文件导出来。
为什么存档这么不经改?核心是它得游戏自己反复读写:代码能混淆到让人难读,存档还得写回成下次自己能认的格式,那份数据对拿着手机的人天生就是敞开的。它又是磁盘上的持久状态,攻击者能拷走、离线慢慢改、再塞回去,没有时间压力。下面几个坑,都是真花了时间才想明白的。
坑一:整包 AES 加密,启动直接卡死
图省事,加载时把整个存档 AES 解密一遍。低端机上启动时间从 3.2 秒涨到 11 秒多,测试机直接卡。后来改成按需分块:只解当前场景要用的那部分,解完进缓存,别一次性全解。安全和流畅是同一本账,护太狠把游戏拖卡等于赶客。尤其别在 autosave 里对整份存档重加密、重算 hash,存档一大就是肉眼可见的掉帧。
坑二:加密了文件,金币照样被改
以为加密就完事了,结果 GameGuardian 根本不碰文件,直接搜内存。存档在磁盘上是密文没错,可游戏一加载,金币在内存里就是个明文 int,搜当前值、花一点再搜、几轮锁定地址,改掉、冻住——文件加密这条路攻击压根没走。
所以内存里的值也不能明文存。改成 ObscuredInt 那套,内存里躺异或后的值:
int _key = Rand(); // 每次运行随机
int _enc = gold ^ _key; // 内存里是异或后的值
int Gold => _enc ^ _key; // 用时才还原
但这只挡图省事的。GG 有针对 XOR 的加密搜索(输两个值 a;b),还有模糊搜索,不看具体值、只看这数变大变小,顺着花钱赚钱照样把地址逼出来。所以还得叠一层:给存档算 HMAC,或者一个数存两份(值 + 反码)交叉校验,改了一份对不上就发现:
string mac = HmacSha256(saveKey, saveBytes); // 存成 {data, mac}
// 加载重算比对,对不上 = 被篡改,按作弊处理
坑三:拿 IMEI 当加密密钥
想让存档绑设备、防拷贝,顺手拿了 IMEI 当密钥。上线后一堆玩家换机、重装、云恢复之后存档读不出来了,IMEI 会变、还受权限限制,等于把存档焊死在一台设备上。后来改成安装时生成随机密钥、存进 Android Keystore(iOS 用 Keychain),别落明文;再把账号 ID、存档槽位、版本号丢进认证加密的关联数据(AAD),跨账号拷的、换槽位的、版本对不上的,解密时直接失败:
var aad = accountId + "|" + slot + "|" + version;
var blob = AeadEncrypt(saveBytes, installKey, aad); // 换个环境解密就报错
顺带一提,这层也只是文件层:密钥进了 Keystore,可攻击者要是跟到解密入口、或者内存里加载完的对象,照样绕过文件本身,无非多花点时间。
排查下来结论其实很朴素:存档在玩家手里、能读能写这条改不了,铁了心的总能改。能做的是把用编辑器和 GG 的大多数挡在外面,投入产出算得过就够;真到了存档能卖、能上榜那步,权威数据就得挪服务端,客户端只当缓存,而且服务端别把上传的存档原样入库,得判断状态合不合理,不然篡改照样进。
(完整的分层做法和更多代码我整理在字节暗面主站版里了,这篇只记排查和踩坑。)
浙公网安备 33010602011771号