☰
Unity AssetBundle热更新安全排查:从CDN清单到本地缓存全链路加固
2026/10/1 3:15:31 网站建设 项目流程

干 Unity 热更的兄弟,估计都遇到过这种情况:游戏发版后玩家群里开始有人反馈"更新下载完就闪退""资源加载不出来",你排查了 CDN 配置、查了 AssetBundle 打包流程、确认了版本号,最后发现是客户端本地缓存里残留了一份旧清单,加载逻辑还优先读取了它。更要命的是,有些"更新包异常"根本不是网络问题,而是 CDN 到本地这一路的安全校验存在漏洞——清单被串改、哈希被绕过、缓存文件被替换,任何一个环节失守,热更就变成了一场事故。

这篇内容不跟你聊大而全的框架设计,专门围绕Unity AssetBundle 热更新安全排查,从 CDN 清单到本地缓存,把这条链路拆开揉碎,讲清楚风险到底出在哪、怎么定位、怎么加固。适合正在做热更系统、被线上资源问题反复折磨的 Unity 客户端开发者,也适合后端同学了解 CDN 与端侧协同校验的逻辑。

1. 一条热更请求走完整链路后,安全风险藏在哪三个环节

1.1 常规热更流程里"清单"扮演的角色

AssetsBundle 热更新的常规做法是:客户端启动时先请求一个版本清单文件,里面记录了当前热更版本号、每个 AssetBundle 的文件名、MD5/CRC 哈希、下载地址或者相对路径。客户端解析清单后,和本地已有的资源清单做对比,找出需要新增、更新、删除的 Bundle,再逐个下载。

这个清单文件在工程里有各种叫法,常见的是version.json、filelist.json、assetbundlemanifest,Unity 官方打包时也会生成一个.manifest文件。无论叫什么,它的本质都是一张"资源档案表",告诉客户端:服务器端当前有哪些资源、每个资源的指纹是什么。

很多人觉得清单只是个"对比用的配置文件",安全意义不大。这个认知是错的。清单一旦被篡改,后面所有的哈希校验、版本判断都会建立在错误的前提上。攻击者只需要把清单里的hash字段替换成恶意包的哈希,再把你引导到恶意 CDN 地址,客户端就会老老实实下载并加载被篡改的 AssetBundle。所以排查热更安全问题,第一步永远是审视清单的获取与校验链路。

1.2 本地缓存不是你想的"存个文件"那么简单

AssetBundle 下载完成后,客户端需要把文件持久化到本地,避免每次启动都重新下载。这里的"本地缓存"至少有三层概念:

  • Unity 自带 Caching API:UnityWebRequestAssetBundle配合Caching.AddCache等方式,会把下载的 AB 存到 Unity 管理的缓存目录中,并自动关联版本号。
  • 自建缓存目录:很多团队为了可控性,不用 Unity 的 Caching,而是自己把 AB 文件写入Application.persistentDataPath下的某个目录,通过自定义清单记录哪些文件已经存在。
  • 系统级缓存:某些平台上,CDN 响应本身可能被系统级 HTTP 缓存拦截,尤其是 WebGL 平台上的浏览器缓存、Android 的 OkHttp 缓存。

排查时必须先搞清楚:你用的到底是哪一层缓存?每一层的命中逻辑和校验时机都不同。我自己接手过的一个项目就是"UnityWebRequest + 自建缓存目录"混用,热点文件用Caching缓存、普通文件手动落盘,结果两边各存了一份差异版本,排查时差点怀疑人生。

1.3 三环节风险地图:CDN 下发、清单解析、缓存落盘

把热更链路摊开看,安全风险的集中点无非三个大环节:

环节风险类型表现
CDN 下发清单与 AB 文件中间人篡改、CDN 源站被入侵、缓存配置错误导致旧文件拿到错误的清单或损坏的 Bundle
客户端清单解析校验缺失、弱校验、解析器容忍异常字段用错误的哈希对照、遗漏文件、接受伪造版本号
本地缓存落盘与读取缓存被替换、残留旧文件、多版本混放加载到旧资源或恶意替换资源

后面几个部分,我会沿着这三个环节逐个展开,并给出具体的排查命令、代码片段和加固配置。

2. 清单校验的几层防线:版本号、Hash、CRC 和签名

2.1 版本号比对:整数代差为什么挡不住回滚攻击

最朴素的清单校验就是版本号。服务器返回{"version": 1001, "files": [...]},客户端对比当前版本,如果服务器版本更高就增量更新。这个逻辑够用吗?只能防"手滑发错包",防不了恶意回滚。

举个例子:攻击者抓包拿到你历史版本 1000 的清单文件,修改代理返回给客户端。客户端的漏洞比较逻辑是"服务器版本 > 本地版本才更新",如果本地版本正好是 999,那么回滚到 1000 反而会被当成"正常更新"。更隐蔽的做法是:攻击者把清单里的版本号改成 99999,客户端以为有大更新,把一堆无效地址和错误哈希拉下来,轻则资源下载失败,重则逻辑被诱导加载异常包。

所以版本号比对只能作为"更新决策"的依据,不能作为"安全校验"的依据。我见过不少项目只判断serverVersion > localVersion,完全没有对版本号本身做合法性约束。合理的做法有两层:

  1. 客户端内置或预置一个"最低可信版本号"作为红线,低于该值一律拒绝;
  2. 版本号字段参与整体鉴权,而不是单独信任。
// 错误的版本比对姿势 if (remoteVersion > localVersion) { DoUpdate(); } // 带红线和完整校验的姿势 const int MIN_TRUST_VERSION = 1000; if (remote.BlockVersion < MIN_TRUST_VERSION) { Reject("version too low"); } if (!VerifyManifestSignature(remote)) { Reject("manifest invalid"); } if (remote.Version > localVersion) { DoUpdate(); }

2.2 Hash 校验:UnityWebRequest 到底帮你做了什么

Unity 的UnityWebRequestAssetBundle有个Hash参数。很多人以为传了Hash就完成了校验,其实要看代码路径。

var uwr = UnityWebRequestAssetBundle.GetAssetBundle(url, Hash128.Parse(remoteHash), 0); yield return uwr.SendWebRequest(); var ab = DownloadHandlerAssetBundle.GetContent(uwr);

这里的Hash主要作用于Unity Caching 系统:它生成本地缓存的键,并参与缓存有效期判断。也就是说,它更偏向于"缓存索引",而不是严格意义上的"完整性校验"。你传入的hash怎么来的?如果它来自未签名、可被篡改的清单,那么攻击者连哈希字段一起替换,这个校验就形同虚设。

真正可靠的完整性校验,应该用Hash128.Parse(remoteHash)只是起点。在下载完成后,需要独立地对最终文件内容计算摘要,比如把文件读出来跑一遍 MD5 或 SHA256,再与与清单独立的可信摘要比对。注意"独立可信来源"这几个字:如果摘要本身和清单一起被篡改,那比对就毫无意义。所以摘要要么来自签名校验后的清单字段,要么来自内置在客户端里的公钥验签结果。

2.3 CRC 与自定义摘要:双保险的取舍

有些团队在打包阶段会额外生成每个 Bundle 的 CRC 值,打包进一个由单独渠道下发的配置表中。客户端下载完文件后跑一遍 CRC32,校验失败就重下或上报。

CRC32 的优点是快、实现简单,适合做传输损坏检测;缺点是抗恶意篡改能力弱,攻击者可以轻易构造碰撞数据。如果你的威胁模型只是"用户网络差、文件传输出错",CRC 就够用。如果威胁模型包含"有人恶意替换资源文件",CRC 不够,必须上 SHA256 级别的强摘要,并且配合签名机制。

实操中我建议这样组合:

  • 传输过程:依赖 HTTPS + CDN 完整性头(如阿里云的 Content-MD5、S3 的 ETag)做第一层保护;
  • 端侧下载后:用 SHA256 对文件做独立校验,防止 CDN 边缘节点返回损坏或串改内容;
  • 校验指纹来源:从签名验证通过的清单中读取,而不是从裸 JSON 中读取。
public static bool VerifyFileSHA256(string filePath, string expectedHash) { using var stream = File.OpenRead(filePath); using var sha = SHA256.Create(); var hash = sha.ComputeHash(stream); var sb = new StringBuilder(); foreach (var b in hash) sb.Append(b.ToString("x2")); return sb.ToString().Equals(expectedHash, StringComparison.OrdinalIgnoreCase); }

2.4 清单签名:端侧验签的工程化姿势

真正能挡住"清单被篡改"这一刀的手段,是对清单文件本身做签名。做法是:服务器端用私钥对清单内容(版本号 + 文件列表 + 摘要等)做签名,客户端内置公钥,在解析任何字段之前先验签。

这里有一个工程化细节:公钥不能直接硬编码在代码里且不做任何保护。Unity 打包后的 IL2CPP 代码虽然比 Mono 难逆向,但公钥仍然可能被提取替换。更稳妥的做法是把公钥或公钥哈希再做一次白名单校验,或者分片存放在不同位置,关键项目的做法还可以配合加固服务。当然,绝大多数项目到"内置公钥 + RSA 验签"这一层就已能拦截 99% 的脚本攻击者。

验签代码要放在解析逻辑的最前端,并且要覆盖清单所有字段,包括版本号、文件路径、哈希值。有些项目只签了哈希列表,版本号裸奔,攻击者仍然可以回滚版本号来干扰更新逻辑。

public static bool VerifyManifest(byte[] manifestData, byte[] signature, string publicKeyPem) { using var rsa = RSA.Create(); rsa.ImportFromPem(publicKeyPem); return rsa.VerifyData(manifestData, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }

3. 本地缓存排查:从 Caching API 到自建缓存目录

3.1 Unity Caching 的默认行为:版本、路径、失效

Unity 的 Caching 系统行为有几个关键点,排查本地缓存问题时必须清楚:

  • 缓存路径由 Unity 管理,Application.persistentDataPath或 Unity 内部目录,不同平台不一样;
  • Caching.IsVersionCached(url, hash)可以判断某资源是否已缓存;
  • 同一个 URL 配合不同Hash128,会被视为不同的缓存条目;
  • 如果缓存空间不足,Unity 可能淘汰旧缓存;
  • 在 Android 上,缓存目录可能被系统清理,尤其是在应用被卸载重装后。

常见误区是:以为Hash128传错或没传只会影响缓存命中,不会有严重后果。实际上,如果清单里哈希来自一个被篡改的字段,Unity 会用这个哈希建立索引,后续版本更新时可能因为"哈希不匹配"无限重下,或者因为"哈希碰撞"误加载到错误缓存。

排查 Caching 问题时,可以用下面的方式确认当前缓存状态:

var hash = Hash128.Parse(remoteHash); Debug.Log($"Cached? {Caching.IsVersionCached(uri, hash)}"); foreach (var cache in Caching.GetAllCachePaths()) { Debug.Log($"cache path: {cache}"); }

3.2 自定义缓存目录:为什么很多人放弃了 Caching API

说实话,Unity 自带 Caching 在早年版本表现不太稳定,跨平台目录不可控、清理策略黑盒、部分平台(尤其 WebGL)行为差异大,所以很多项目选择自建缓存目录。自建缓存目录的核心是自己管理两件事:

  1. 文件存储结构:通常是persistentDataPath + "/bundles/" + version + "/" + bundleName;
  2. 本地清单:记录已下载文件的名称、大小、哈希、下载时间。

自建缓存最常见的坑是本地清单和实际文件不一致。比如下载写了一半进程被杀,文件损坏但清单里已经标记完成;或者用户清理了文件目录,但本地清单还残留记录;又或者多版本混合更新时,新版本的 Bundle 下载成功,旧版本文件没有被及时清理,导致同名 Bundle 被加载成旧资源。

我建议自定义缓存目录一定要做三步校验:

  • 启动时扫描目录文件列表,与本地清单比对,多出的文件清理、缺失的文件重新下载;
  • 每次加载 AssetBundle 前,先比对本地记录的文件哈希与实际文件哈希,不一致则删除重下;
  • 写入文件时采用"临时文件 + 原子改名"的方式,避免写一半崩溃留下半截文件。
string tmpPath = filePath + ".tmp"; File.WriteAllBytes(tmpPath, data); if (VerifyFileSHA256(tmpPath, expectedHash)) { File.Move(tmpPath, filePath, true); // 原子替换 } else { File.Delete(tmpPath); // 标记失败,进入重试 }

3.3 缓存残留与命名冲突:加载到"旧资源"的常见原因

做热更排查时,我最常遇到的一类问题是:服务器清单明明是新版本,下载也成功,但加载出的资源还是旧的。第一反应怀疑 AssetBundle 加载接口,但根因往往在缓存目录。

举一个典型场景:项目早期用 Bundle 名做文件名直接存缓存目录,比如role_avatar。后来某次打包,同一个 Bundle 的内容发生了结构性变化,但文件名没变。客户端增量更新时,清单认为该文件名需要更新,下载了新文件;但如果你用的是AssetBundle.LoadFromFile(path),而 path 指向的是缓存目录,那就应该加载到新文件。问题出在有些团队用AssetBundle.LoadFromFile(Application.streamingAssetsPath + "/" + name)加载原始包资源,没有切换到更新后的缓存路径——旧文件一直躺在安装包内没有被覆盖。

另一种常见情况是多进程或多线程同时写缓存。Unity 手游在进入战斗场景时会预下载资源,如果同一个 Bundle 有两个下载任务并发执行,后写完成的文件可能覆盖先写完成的,但哈希校验的顺序和写入顺序不一致,就会造成"文件内容是 A 版本、本地清单记录的是 B 版本"的错位。这种问题在低端 Android 机上更容易出现,因为文件写入慢、线程调度不稳定。

3.4 平台差异:Android 与 iOS 缓存目录权限的坑

聊本地缓存离不开平台的差异,这里专门提醒几个我实际踩过的权限坑:

  • Android 公有目录 vs 应用私有目录:Application.persistentDataPath在 Android 上通常是/storage/emulated/0/Android/data/{packageName}/files。应用卸载后目录会被清理,但在某些国产 ROM 上,这个目录可能被"清理 App"误删。如果你在代码里用Environment.ExternalStorageDirectory拼接路径,会拿到公有目录,Android 11 以上的分区存储还需要额外适配,读写权限更麻烦。
  • iOS 的备份与缓存策略:Application.persistentDataPath在 iOS 上对应Documents目录,会被 iCloud 备份;而Application.temporaryCachePath对应Caches目录,不备份但会被系统清理。热更缓存文件如果放在Documents,超大 Bundle 会让 App 包体备份膨胀,审核时有风险;如果放在Caches,可能被系统在存储紧张时清空,导致下次启动大量重下。
  • 只读文件系统:某些平台(如 WebGL)根本没有真正的本地持久化大文件能力,IndexedDB 的大小和清理策略不完全受你控制,做热更缓存时要有独立的配额判断。

4. 复盘一次真实事故:"清单校验通过、文件下载完成、加载却异常"

4.1 事故现象和第一轮排查

去年我参与维护的一个上线项目,玩家反馈在弱网环境下更新后频繁出现"资源贴图缺失 + 特定关卡崩溃"。后台查看下载成功率 99% 以上,CDN 请求成功率正常,第一轮排查根本没头绪。

我们做了三件事:

  1. 在崩溃上报里加入了AssetBundle加载路径、清单版本号、本地缓存哈希等字段;
  2. 在下载完成后增加了一行日志,输出服务器哈希与实际文件哈希的比对结果;
  3. 让出问题玩家上传了本地的缓存目录结构。

结果发现:崩溃集中在同一个 Bundle 上,而且这个 Bundle 的"服务器哈希"和"本地文件哈希"完全对不上。但奇怪的是,下载成功日志里记录的比对结果是一致的。

4.2 用抓包和本地比对定位根因

排查到这里,我们怀疑是"日志撒谎",于是抓了线上玩家的下载流量。抓包发现客户端确实从 CDN 拿到了正确的文件,但紧接着有一段时间网络抖动,某个请求重试后返回了 206 Partial Content。我们的下载逻辑用的是UnityWebRequest直接下载整个文件,照理说不应该出现 206。问题出在 CDN 侧开启了断点续传,客户端的缓存库(我们用的第三方 HTTP 库)也支持断点续传,两者配合下,一个文件被分两段下载,第二段写入的 offset 出现了偏差——文件拼接后长度正确,但中间一段内容错位。

更隐蔽的是,我们下载完成后计算的 SHA256 和服务器哈希是一致的,因为计算的是拼接后的错位文件,而服务器哈希对应的其实是完整的正确文件。这怎么可能?我们再深入打印了分块偏移和实际写入大小,才发现某次重试请求的Range头被重复添加,导致下载段重复覆盖了同一区域,文件长度不变、内容却错位了,哈希自然对不上。日志显示一致是因为我们只打了一条"总文件哈希一致"的结论,而那个结论是另一个校验函数错误地取了缓存索引里的哈希,没有真正重新读文件。

这里给所有做热更的团队一个硬建议:下载完成后的完整性校验必须重新打开文件计算,绝不能信任下载器内部报告的缓存键或状态。

4.3 根因机制与修复方案

最终根因锁定在两条:

  • CDN 与客户端 HTTP 库的Range重试机制冲突,造成断点续传拼接错位;
  • 客户端校验逻辑存在"形式主义"漏洞,取的是内存中的下载状态而不是落盘文件。

修复方案分两层:

  1. 下载层:关闭对该 AB 下载请求的断点续传,或者在重试时强制重新下载整个文件。我们最终选择"弱网重试时丢弃已有断点,全量重下",代价是少部分慢速用户流量略增,但不再出现拼接错位;
  2. 校验层:重写校验函数,下载完成并落盘后,重新打开文件计算 SHA256,与清单中的可信摘要比对;不一致则删除本地文件、从更新队列重试。
// 修复后的下载流程伪代码 IEnumerator DownloadWithVerify(string url, string filePath, string expectedHash) { // 1. 临时文件下载, 不使用断点续传 yield return DownloadToTemp(url, filePath + ".tmp"); // 2. 必须重新打开文件计算哈希 bool ok = VerifyFileSHA256(filePath + ".tmp", expectedHash); if (!ok) { File.Delete(filePath + ".tmp"); // 上报并重试 yield break; } // 3. 原子改名 File.Move(filePath + ".tmp", filePath, true); }

这次事故给我最大的触动是:"校验逻辑存在"不等于"校验逻辑有效"。排查时要追问每一步校验的数据源到底来自哪里,是来自网络响应、内存缓存,还是真实文件。

5. 热更安全加固清单:端侧、服务端、运维侧各管一段

5.1 端侧必须做的事

基于上面这些经验,我整理了端侧热更安全加固时必须覆盖的检查项:

  • 清单获取走 HTTPS,域名做好证书校验,有条件的项目可以上证书锁定(Certificate Pinning),防止中间人替换证书后下发伪造清单。
  • 清单验签 + 字段完整性校验:签名覆盖版本号、文件列表、哈希值所有字段,不接受"部分字段参与签名"的偷懒实现。
  • 下载完成后重新读文件做强校验:不要信任下载器的状态,必须File.OpenRead后重新计算 SHA256。
  • 加载前二次校验:从缓存目录加载 AssetBundle 前,比对实际文件哈希,哈希不匹配直接重下。这段校验的开销对于 Bundle 文件来说通常可以接受。
  • 异常上报:把校验失败时的 URL、版本号、期望哈希、实际哈希、缓存路径、平台等关键字段上报到后台,方便复现。
> 注意:哈希校验的密钥点不是"校验了哈希",而是"哈希比对双方来自独立可信源"。

5.2 服务端与 CDN 侧的配合

光改客户端不够,服务端和 CDN 侧必须同步调整,否则有些漏洞封不上:

  • CDN 缓存刷新策略:更新资源时,确保 CDN 上旧版本被主动刷新或配置版本化路径。如果同一个 URL 长期指向旧内容,客户端做再多校验也没用;
  • 推送完整度检测:AB 文件上传后,由后台任务对全量文件计算哈希并入库,发布分支与客户端清单引用同一份指纹,避免人工填写哈希出现错误;
  • 多环境隔离:测试环境、预发布环境、生产环境的 CDN 域名和清单地址严格隔离,防止测试包连上生产 CDN 或反过来;
  • 日志与告警:对清单请求、AB 下载请求的 4xx/5xx、以及下载后校验失败的行为做告警,这类指标突然上升往往意味着攻击或故障。

5.3 踩过坑之后的经验纪要

最后写几条纯个人经验,不一定写在规范文档里,但排查时很管用:

  • 排查顺序固定下来:先确认"拿到的清单是不是可信" → 再确认"下载的文件是不是期望内容" → 最后确认"加载的是不是落盘的那份文件"。我见过太多人一上来就怀疑AssetBundle.LoadFromFile,结果问题出在更上游的清单或下载阶段。
  • 复现问题别只看日志,抓包和文件级比对永远是最直接的证据。尤其是弱网问题,本地回放请求能帮你省掉大量和 CDN 厂商反复拉扯的时间。
  • 把缓存视为"可丢弃资源",而不是"可靠资产"。缓存损坏后自动重建的路径设计好,玩家的损失只是一个加载等待,而不是一次闪退。
  • 热更安全不是一次性的,每次打包流程调整、每次 CDN 切换、每次第三方 HTTP 库升级,都应该回测一遍校验链路。我们这次断点续传事故,就是一次 HTTP 库升级引入的回归。

AssetBundle 热更新做到稳定不难,做到安全稳定就需要把"清单、下载、落盘、加载"每一环都当作独立的信任边界来设计。希望这篇排查复盘能给你自己的热更系统带来一点启发。

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

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

立即咨询