资讯动态

Unity热更新安全排查:CDN清单签名校验与本地缓存防篡改

发布时间:2026/10/3 5:16:10 来源:尧图企业网站定制
AssetBundle 热更新做过的人都知道最怕的不是功能写不出来而是整条更新链路被别人摸透了。我这两年排查过不少热更新事故真正出问题的地方往往不在业务代码而在 CDN 清单和本地缓存这两段看起来没人管的环节。这篇就把我做 Unity AssetBundle 热更新安全排查的完整思路整理出来从 CDN 下发的 manifest 清单文件讲到客户端落地的本地缓存目录每一步该查什么、该验什么、有哪些坑全部摊开讲。适合客户端主程、热更新负责人、运维同学一起看。就算你是刚接触热更新的 Unity 开发者照这篇文章的检查清单走一遍也能避开绝大多数低级事故。1. 热更新链路的安全盲区到底在哪1.1 AssetBundle 热更新的标准链路先对齐基本认知。Unity AssetBundle 热更新的完整链路通常是这样构建机打包 AssetBundle同时生成总的 manifest 文件里面记录每个 bundle 的名称、哈希、大小和依赖关系。版本发布工具把 bundle 文件和 manifest 一起上传到 CDN。客户端启动后先拉取远程版本描述文件常见命名是 version.txt、update_config.json 之类拿它和本地记录的版本号比较。如果远程版本比本地新客户端按差异清单下载新增或发生变化的 AssetBundle。下载完成之后做完整性校验写入本地缓存目录一般是 Application.persistentDataPath 下的子目录后续用 AssetBundle.LoadFromFile 加载。功能层面这条链路没有任何问题但安全视角下每一步都有可乘之机。CDN 本质上是公开的静态资源服务只要有人猜出 URL 规则就能直接下载你的 bundle 文件。manifest 不带签名的话等于把 bundle 的哈希和依赖关系白送给别人对方改一个字节你都察觉不到。这就是为什么排查一定要从 CDN 清单开始而不是只盯着客户端。1.2 威胁模型谁在动你的包体做安全排查之前先想清楚对手是谁。我习惯把威胁分成三类恶意用户与外挂作者。他们的目标是篡改本地缓存里的 AssetBundle实现改数值、解锁付费内容、绕过反作弊检测。这类攻击对游戏收入和安全策略的伤害最直接。中间人流量劫持。在不安全的公共 Wi-Fi 环境下攻击者拦截客户端发往 CDN 的请求并返回伪造文件比如把公告图替换成钓鱼图片或诱导客户端下载恶意构造的 bundle。虽然 HTTPS 能挡掉一大部分但证书校验配置不对照样白搭。内部流程失误。这是最容易被忽略的威胁构建机打出的 bundle 没真正更新、CDN 缓存节点没有刷新、版本号配置填错。线上客户端拉到旧包或者错包表现和遭遇攻击几乎一样本质上就是发布链路缺少校验。对应到排查动作上我在检查清单里固定写三条清单必须可验签名加哈希、文件必须可查完整性校验、版本必须可溯防回滚。1.3 排查的三个核心检查点基于威胁模型热更新安全排查聚焦三段CDN 清单段版本描述文件、Unity 生成的 manifest 文件是否被篡改下载是否走安全协议。传输下载段bundle 文件本身的完整性与来源是否可信。本地缓存段写入后的 bundle 是否被二次修改缓存目录是否可被其他进程写入。这三段是串联关系。清单被篡改后面所有校验都建立在错误基准上清单验过了但缓存文件被替换加载阶段照样出问题。所以排查顺序必须从 CDN 一路往下走到本地缓存一条线捋完不能只盯其中某个点。2. CDN 清单段先保证下发的东西是真的2.1 manifest 里到底暴露了多少信息Unity 在打包 AssetBundle 时生成的 manifest 文件很有用但也非常敏感。以 AssetBundleManifest 为例它包含所有 bundle 的哈希值、CRC、依赖关系以及构建时的一些元数据。这些信息对热更新 diff 是必需品对攻击者同样是宝物。举个例子攻击者拿到 manifest 后可以精确知道某个 UI bundle 或数值配置 bundle 的哈希然后在本地构造一个同名的修改版 bundle篡改哈希后替换缓存。如果你的客户端下载后不校验 manifest 里的哈希加载的就是攻击者的文件。我见过一个项目manifest 文件直接以明文形式放在 CDN 上并且 CDN 没有任何鉴权策略。这意味着任何人花几分钟就能拿到完整的资源目录结构把整个游戏的资源清单摸得一清二楚。这不是危言耸听而是真实发生过的。所以 CDN 清单段的排查第一步就是给版本描述文件和 manifest 加上数字签名。2.2 清单签名校验的两层做法签名校验的目的是让客户端能够确认这份清单确实来自官方且内容没有被改动过。标准的做法是 RSA 签名密钥对由发布工具持有私钥客户端内置公钥。具体来说发布流程里要对版本描述文件做两层处理第一层计算内容哈希并签名。签名对象建议是版本描述文件的完整字节而不是只签版本号字段。只签版本号的话攻击者可以修改 bundle 下载地址再重新打包描述文件照样造成危害。第二层把签名附在描述文件尾部或者单独生成 .sig 文件客户端下载后先验签再解析。在 Unity 里用 C# 实现验签大概是这样using System.Security.Cryptography; public class ManifestVerifier { private readonly RSACryptoServiceProvider _rsa; public ManifestVerifier(string publicKeyXml) { _rsa new RSACryptoServiceProvider(); _rsa.FromXmlString(publicKeyXml); } public bool Verify(byte[] contentBytes, byte[] signatureBytes) { using (var sha256 SHA256.Create()) { var hash sha256.ComputeHash(contentBytes); return _rsa.VerifyHash(hash, CryptoConfig.MapNameToOID(SHA256), signatureBytes); } } }使用前注意几个细节公钥要写在代码里或者嵌进 So 层不要放在 StreamingAssets 下明文读取。验签失败时的处理逻辑必须是拒绝更新并上报不能静默走本地旧版本。否则攻击者可以伪造一个旧版本清单诱导客户端跳过更新形成事实上的降级攻击。每次构建版本号都要变化签名必须重新生成发布工具链里要加一步自动签名的流水线。2.3 CDN 侧配置的常见隐患清单文件本身安全了CDN 的传输和缓存配置也会挖坑。排查时我固定检查三个地方是否强制 HTTPS。HTTP 环境下验签做得再好也没意义因为攻击者可以直接替换整个响应。现在的 CDN 基本都支持免费 HTTPS 证书这一步没有理由不做。Cache-Control 与回源策略。版本描述文件这类小文件如果 CDN 节点缓存了旧版本且客户端又没带版本参数就会出现明明发布了新包用户还是拉取到旧清单的问题。我建议在版本描述文件的 URL 上追加版本号参数或者对这类文件设置较短的 max-age。CDN 防盗链与签名 URL。对于敏感的业务资源可以开启 Referer 防盗链或者 URL 鉴权。但要注意Unity 客户端的 User-Agent 和 Referer 不一定稳定防盗链策略过严可能误伤正常用户。我的经验是正常游戏包体用鉴权 URL内测包可以用简单防盗链别把线上整崩了。提示CDN 鉴权 URL 通常有时间戳客户端和服务器时间偏差大时会出现签名过期导致下载失败。排查这类问题要看客户端本地时间是否被用户篡改过必要时用服务器下发的时间戳来校验。3. 本地缓存段防篡改与防回滚3.1 缓存目录与文件权限排查本地缓存目录是热更新链路的终点也是攻击者最常动手的地方。Unity 的 Application.persistentDataPath 在 Android 上对应 /data/data/包名/files在 iOS 上对应 App 沙盒内的 Documents 或 Library。正常情况下这两个目录只有应用自己能写入但排查时不能想当然。我在实际排查中遇到过两种情况第一部分安卓渠道包把持久化目录设置到公共存储区域。公共存储区其他应用可以写等于你的 bundle 文件直接暴露在广场上谁都能替换。这种渠道包为了绕过存储权限限制会这样配置但安全等级直接降了一个档。第二Root 设备或越狱设备。这类设备上所有沙盒隔离都形同虚设攻击者可以直接操作文件系统。面对这种环境单纯靠文件权限已经不够必须在客户端逻辑里做校验。所以本地缓存段的第一道防线不是加密而是确保缓存目录确实在应用私有沙盒内并且所有写入只走自己的下载器。3.2 加载前 Hash 与 CRC 双重校验下载时校验一次、加载时再校验一次这不是冗余而是两道完全不同的防线。下载时校验防的是传输过程中文件损坏或者被 CDN 回源错误。这个阶段拿到的是网络字节流校验通过后写入缓存。但注意写入之后文件在磁盘上躺着从下载完成到下次启动加载之间有大量时间被攻击者利用。加载时校验防的是缓存文件被二次修改。我建议在 AssetBundle.LoadFromFile 之前对文件做一次快速哈希比对。如果文件过大全量 SHA256 会带来明显的加载耗时这时可以用两种方式折中对文件做样本哈希即读取文件头部、中间段、尾部分别取一部分字节做哈希。这样能覆盖大部分篡改场景性能开销小很多。对文件记录 CRC32 或更轻量的校验值在加载时先算快速 CRC异常时再升级为全量 SHA256 复核。需要说明的是Unity 的 AssetBundle 加载本身带 CRC 校验参数在 LoadFromFile 时可以传入 crc 值。但 Unity 的 CRC 校验主要针对文件完整性和传输损坏对恶意构造的同长度同 CRC 文件并没有抗性。排查中如果发现有人故意构造碰撞文件还是要以哈希校验为主。3.3 回滚攻击与版本锚点回滚攻击是我重点想讲的点。它的原理很简单攻击者不需要篡改任何文件只需要把版本描述文件替换成旧版本客户端一对比发现远程版本小于本地版本如果代码逻辑里没有处理远端版本比本地旧的情况更新流程就直接跳过了。后果是什么玩家停留在旧版本上旧版本里如果有已知的数值漏洞或者安全漏洞等于给攻击者留了一扇随便进的门。很多游戏为什么改了配置半天不生效排查到最后发现是版本被回滚了。防回滚的核心思想是本地要有一个不能被轻易覆盖的版本锚点。我常用的做法是双写版本信息一份写在 PlayerPrefs一份写在应用私有目录的版本文件里并且版本文件也带签名。每次更新成功后客户端把最新版本号和对应的服务器签名一起存下来。下次启动时先读取本地版本锚点再比对远程清单只有远程版本大于本地锚点才允许更新。代码示意public class VersionKeeper { private const string VersionKey hotupdate_latest_version; private const string SignatureKey hotupdate_latest_version_sig; public static void CommitVersion(string version, byte[] signature) { PlayerPrefs.SetString(VersionKey, version); PlayerPrefs.SetString(SignatureKey, Convert.ToBase64String(signature)); PlayerPrefs.Save(); } public static string GetLocalVersion() { return PlayerPrefs.GetString(VersionKey, 0.0.0); } }这里有一个容易被忽略的细节PlayerPrefs 在部分安卓渠道包上可以被备份工具直接修改。所以版本锚点不能只存在 PlayerPrefs我的实践是同时在沙盒内写一个带签名的版本记录文件每次校验时两者取较高值。这样攻击者改任意一个都无法把版本号整体拉低。4. 实操从 CDN 到本地的完整排查流程4.1 第一步抓包确认下载链路排查永远从确认线上客户端实际下载了什么开始。我用的是 Charles 或者 Fiddler 这类抓包工具配合 Android 模拟器或者真机代理设置。抓包重点看三样东西版本描述文件的请求 URL 是否走了 HTTPS响应内容是否和 CDN 源站一致。AssetBundle 下载请求的 URL 规则是否可预测。如果 URL 里只有 bundle 名没有版本号说明 CDN 缓存命中混乱的风险较高。响应头里的 Cache-Control 和 ETag。ETag 不变化的话说明 CDN 节点没有按预期刷新。这一步我发现过很多灵异现象比如线上用户一直更新失败但公司内网一切正常。抓包后确认某些地区 CDN 节点缓存了过期清单导致用户拿到的版本描述文件一直是旧的。解决方式就是在 URL 上强制带版本号参数绕开 CDN 缓存。4.2 第二步客户端校验逻辑落地抓包确认链路没大问题后客户端校验逻辑要排在更新流程的最前面。我的标准流程是下载版本描述文件。做签名验签失败则中止更新并上报。解析版本号和本地锚点比较小于本地版本则中止并告警。按清单下载差异 bundle每个文件下载完成后立即算哈希和服务端清单里的哈希比对。全部下载完成后再对关键 bundle登录、支付、核心玩法做一次加载前抽样校验。这里要特别提醒不要把哈希计算放在主线程。我在一个项目里见过团队在主线程用 SHA256 校验一个 200MB 的 bundle结果 Android 低端机直接卡到系统弹出 ANR。改成子线程或异步任务之后问题立刻消失。4.3 第三步灰度验证与监控校验逻辑上线后灰度验证和监控必须跟上。灰度阶段我会在日志系统里增加三个自定义事件更新成功、更新失败、校验失败。校验失败要单独统计因为正常用户的校验失败率应该是 0一旦出现非零数据基本可以判定有人在上传篡改包。监控指标上除了常规的版本分布我还建议看一个容易被忽略的数据热更新下载重试率。如果重试率突然升高不一定是网络问题可能是 CDN 清单被改动导致客户端反复下载同一批文件却始终校验不过。灰度阶段发现异常第一时间先回滚 CDN 清单版本再查客户端日志。顺序不能反否则用户群体里的异常流量会把正常日志淹没排查效率特别低。注意内网环境调试通过不代表线上安全。CDN 边缘节点的行为与源站差异很大所以热更新安全排查里的压测和灰度一定在真实 CDN 网络上进行不能只在打包机上自测。5. 常见问题与排查实录5.1 版本号没变但本地一直更新失败现象客户端日志显示版本描述文件下载成功但没有触发更新。排查路径先看本地版本锚点是否被写入了异常的高版本号。常见原因是测试阶段直接修改了 PlayerPrefs 的版本值发布时没清理干净导致线上客户端认为我已经是最新版。处理方式是发布工具链里增加一步清理测试端本地存储的流程并且在版本锚点写入时增加一个环境标记字段区分开发环境与生产环境。5.2 校验通过但加载 bundle 时崩溃现象哈希校验全部通过AssetBundle.LoadFromFile 却抛异常或直接崩溃。排查路径这种情况多数不是安全问题而是构建产物本身有问题。最常见的是 lz4 和 lzma 压缩格式混用导致的加载方式不一致或者依赖 bundle 没下载完整。因为单个文件的哈希只证明这个文件本身完整不代表它的依赖链条完整。排查时要对照 manifest 里的依赖列表逐一确认。另一个隐蔽原因是跨平台构建产物混用。Windows 上构建的 AssetBundle 传到 Android CDN 上哈希校验没问题但加载时表现诡异。这个属于构建流程问题排查时需要核查 CI 的构建矩阵。5.3 不同平台的缓存目录行为差异Android 和 iOS 在缓存目录上的表现差异很大。iOS 的沙盒规则严格应用杀掉重新安装后缓存目录会被清空版本锚点也会丢失。这会导致一个现象用户更新到新版本后因为系统清理机制导致本地锚点丢失客户端不得不重新下载全部资源。Android 这边应用升级覆盖安装后 persistentDataPath 一般保留但部分厂商 ROM 会把应用数据挪位置或者清理部分缓存。处理方式是在启动时先检测版本锚点文件是否存在不存在就视为首次启动走完整资源校验流程不要盲目依赖本地缓存。5.4 常见问题速查现象可能原因排查优先级更新失败但日志无报错版本号被回滚或锚点异常高下载反复重试CDN 缓存未刷新或哈希不匹配高校验通过但加载崩溃依赖缺失或压缩格式混用中部分用户始终收到旧版本本地版本锚点被写入异常值中公网正常但某些地区异常CDN 节点缓存差异低整体来说热更新安全排查不是一次性工作而是一条需要反复打磨的链路。我个人的体会是签名、哈希、防回滚这些机制越早接入成本越低。等项目上线后再补牵涉的老版本兼容会让人非常头疼。最后再分享一个小技巧每轮热更新发布前安排一个人专门模拟攻击者尝试从 CDN 下载清单和 bundle 并篡改重放。这个红队动作 每次都能发现一些意外问题比任何代码 review 都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑