☰
Unity热更新安全排查:CDN清单签名校验与本地缓存防篡改
2026/10/3 5:16:04 网站建设 项目流程

AssetBundle 热更新做过的人都知道,最怕的不是功能写不出来,而是"整条更新链路被别人摸透了"。我这两年排查过不少热更新事故,真正出问题的地方往往不在业务代码,而在 CDN 清单和本地缓存这两段看起来没人管的环节。这篇就把我做 Unity AssetBundle 热更新安全排查的完整思路整理出来,从 CDN 下发的 manifest 清单文件,讲到客户端落地的本地缓存目录,每一步该查什么、该验什么、有哪些坑,全部摊开讲。

适合客户端主程、热更新负责人、运维同学一起看。就算你是刚接触热更新的 Unity 开发者,照这篇文章的检查清单走一遍,也能避开绝大多数低级事故。

1. 热更新链路的安全盲区到底在哪

1.1 AssetBundle 热更新的标准链路

先对齐基本认知。Unity AssetBundle 热更新的完整链路通常是这样:

  1. 构建机打包 AssetBundle,同时生成总的 manifest 文件,里面记录每个 bundle 的名称、哈希、大小和依赖关系。
  2. 版本发布工具把 bundle 文件和 manifest 一起上传到 CDN。
  3. 客户端启动后先拉取远程版本描述文件(常见命名是 version.txt、update_config.json 之类),拿它和本地记录的版本号比较。
  4. 如果远程版本比本地新,客户端按差异清单下载新增或发生变化的 AssetBundle。
  5. 下载完成之后做完整性校验,写入本地缓存目录(一般是 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 第二步:客户端校验逻辑落地

抓包确认链路没大问题后,客户端校验逻辑要排在更新流程的最前面。我的标准流程是:

  1. 下载版本描述文件。
  2. 做签名验签,失败则中止更新并上报。
  3. 解析版本号,和本地锚点比较,小于本地版本则中止并告警。
  4. 按清单下载差异 bundle,每个文件下载完成后立即算哈希,和服务端清单里的哈希比对。
  5. 全部下载完成后,再对关键 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 都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询