ARTICLE DETAIL

资讯详情

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

Unity热更新安全排查:从CDN清单到本地缓存的完整路径

Unity热更新安全排查:从CDN清单到本地缓存的完整路径 搞 Unity 热更新排查有个绕不开的惯例一旦线上出问题你的排查路径几乎总是从 CDN 上的 manifest 清单文件一路追到玩家手机里的本地缓存。这不是一两台机器的事而是整条 AssetBundle 分发链路的信任问题。我这次要分享的就是一次典型的 AssetBundle 热更新安全排查——从 CDN 清单开始逐步往下层查最后在本地缓存里挖出了真正的问题。整个过程可能不炫酷但特别能暴露团队在安全设计上的侥幸心态。适合正在搭热更新体系、或者已经被线上异常砸过一遍的开发者尤其是那些用官方 AssetBundle 做分包、走 CDN 分发的项目。读完你至少能按同样的路径做一次自查并且知道哪些环节不能省。1. 排查任务的完整背景为什么热更新需要从 CDN 一路查到本地1.1 一条热更新链路的基本形态AssetBundle 热更新链路可以抽象成四段客户端先从配置中心拿到当前版本号向 CDN 请求该版本对应的 manifest 清单根据清单里的文件列表和哈希下载差异 AssetBundle最后把下载好的资源写入本地缓存并加载。无论你用的是官方 BuildPipeline 还是第三方热更插件最终都会收敛到这条链路。把它想象成一场快递运输manifest 是发货单AssetBundle 是货物CDN 是发货点本地缓存是仓库。发货单错了后续全错仓库被塞了旧货收货人拿到的也会是错的东西。排查热更新安全问题时很多人习惯把目光只放在“下载”和“加载”这两个动作上忽略了整条链路的每个跳板。我遇到的绝大多数线上事故并不发生在代码逻辑写错而发生在某个中间环节的文件被替换、被缓存、或被误判为有效。这就解释了为什么排查路径必须是全程的从 CDN 清单开始看入口信任是否可靠再到本地缓存看最终落盘是否被污染。1.2 这次排查面对的热更新体系结构为了让这次排查有讨论基础先交代我当时的项目背景Unity 2020 LTS客户端用官方 AssetBundle 构建按功能拆了十几个包CDN 用 HTTPS 分发但没有做严格的证书锁定客户端在启动时依次请求version.json和manifest.json然后逐个下载缺失或过期的 AssetBundle下载的包直接写进Application.persistentDataPath/AB目录下次启动如果文件存在就直接加载。这套结构非常常见同时也集齐了典型隐患。我们需要回答四个问题CDN 响应是否可信清单文件是否不可篡改下载下来的 AssetBundle 是否完整本地缓存是否会干扰版本判断四个问题分别对应四个安全节点哪一个回答不上来排查就不算结束。尤其是最后一个很多人直到清理了缓存还是加载到旧资源才意识到本地持久化层也是攻击面。1.3 排查边界与目标先说边界。我这里不讨论设备被 root、游戏被反编译之后攻击者直接改内存这种极端对抗那是另一套对抗体系。我们要堵的是三个现实场景普通恶意用户使用文件管理器或第三方工具修改缓存渠道包或二次打包者注入了伪造资源CDN 缓存异常导致旧清单、旧包被当成新版本返回。这三类问题正是热更新项目上线后最容易踩的坑也是修复成本最低的部分。排查目标因而是可落地的保证任何从 CDN 获取或写入本地的文件都能验证来源、验证内容、可回滚、可审计。一旦做到这四点热更新链路的安全性就有了基本盘接下来再谈性能优化和带宽控制才有意义。2. 链路安全模型每个节点为什么会出问题2.1 CDN 节点最容易被忽略的“可信根”很多团队的认知停留在“我用了 HTTPS所以 CDN 分发是安全的”。这个想法不能说全错但离“排查过”还有很大距离。HTTPS 保护的是传输通道它不能阻止 CDN 边缘节点本身返回一份错误缓存也不能阻止客户端因为证书校验不够严格而接受伪造源。更实际的问题是 CDN 缓存规则Cache-Control设置得过于激进或者文件更新后没有改变 URL边缘节点就可能把旧文件当成新文件返回。客户端拿到旧清单以为自己是最新版本实际已经落后了好几个补丁。排查 CDN 节点时我一般先看三样东西是否强制 HTTPS 并且客户端不放过自签名证书CDN 的缓存键是否包含版本参数比如manifest.json?v20250215回源策略是否设置了合理的缓存 TTL避免文件更新后边缘节点还抱着旧缓存不放。这三项只要有一项偷懒后面所有安全校验都可能白做。因为一旦入口的清单是坏的你在下游再怎么校验文件哈希也只是在验证一份错误基准上的文件。2.2 清单文件版本信任的锚点manifest 清单是整个热更新体系的信任锚点它通常包含版本号、文件列表、每个文件的哈希和依赖关系。客户端的下载逻辑完全听它指挥哪些包要下从哪里下下完应该是什么样子。所以清单一旦被篡改攻击者等于拿到了整条链路的指挥权他可以让玩家回退版本也可以用伪造的哈希把恶意资源包装成合法文件。这里要特别区分“完整性校验”和“防伪校验”。完整性校验用哈希能发现文件意外损坏但对恶意篡改没有防御力攻击者改掉文件后顺手重算一个哈希完整性校验照样通过。防伪校验要用签名也就是客户端内置一份公钥服务端持有私钥对清单签名客户端验证签名后才信任清单。哈希解决“文件坏了”签名解决“文件是不是官方发的”两者缺一不可。2.3 AssetBundle加载阶段才爆发的隐患AssetBundle 的问题通常不在下载时暴露而在加载时集中爆发。下载完的包如果没有校验就写盘遇到网络抖动导致文件截断、字节错位Unity 在加载时可能报Corrupt bundle或者干脆静默加载出错误资源。更隐蔽的是依赖关系你更新了包 A但包 A 依赖的包 B 在清单里没被标记为需要下载玩家本地又没有 B结果 A 加载到一半缺资源表现就是贴图丢失、UI 错乱。依赖关系的校验不能只靠客户端启动时的一次清单对比还要在加载 AssetBundle 时确认它依赖的对象都已经就绪。实践中我见过太多项目把资源完整性检查做成了“文件存在性检查”文件在缓存目录里就把加载放行完全不看这个文件属于哪个版本、哈希对不对。这等于仓库里堆着过期货物系统还一次次把它们往货架上摆。2.4 本地缓存旧版本和篡改文件的温床本地缓存往往是整条链路上最被低估的一环。Application.persistentDataPath在 Android 上通常指向外部存储或应用专属目录普通用户用文件管理器就能看到iOS 虽然没有开放目录但越狱设备或备份提取也可能访问到缓存文件。如果一个项目if (File.Exists(...)) 直接加载那就相当于把版本判断权交给了本地文件系统——旧文件存在客户端就永远以为自己是最新不管 CDN 上已经发布了新包。更危险的是注入攻击。攻击者把本地缓存的某个 AssetBundle 替换成伪造包再把文件名改成原文件名客户端如果只检查文件存在性就会加载到这个伪造包。这种攻击不依赖网络也不依赖 CDN你甚至无法通过修服务端解决。唯一的办法是在本地缓存层建立同样的校验机制每个缓存文件必须关联版本号和哈希发现不匹配就删除并重新下载而不是盲信磁盘上的文件名。3. 逐环节实操排查从 CDN 清单到本地缓存的落地方法3.1 第一步核查 CDN 分发配置与 HTTPS 证书链路开始排查时我习惯先把 CDN 侧的配置完完整整过一遍这一步不需要写代码但非常关键。打开 CDN 控制台或者找运维拿到域名配置检查以下几个点是否全链路强制 HTTPS有没有允许 HTTP 回源缓存键是否包含文件版本参数CDN 的默认缓存 TTL 与文件的真实更新频率是否匹配是否开启了 URL 规范化和压缩部分压缩中间件会破坏二进制文件的字节内容导致哈希校验失败。我建议在热更新请求 URL 上统一加版本号后缀例如https://cdn.example.com/hotupdate/{version}/ab1.unity3d。版本号一变URL 就变CDN 边缘节点必须回源拉取新文件从根上避免旧缓存污染。这比依赖Cache-Control清理缓存可靠得多。同时客户端侧要对证书做合理校验Unity 的CertificateHandler可以用来固定证书指纹或校验证书链至少不能无条件允许所有证书通过。3.2 第二步校验清单的签名与完整性这一步是整条链路的信任地基。服务端维护一对 RSA 密钥私钥用于对 manifest 内容签名公钥随客户端打包。客户端拿到 manifest 后先做签名验证再读取内容。如果签名失败无论清单内容看起来多正常都不能信任。实现起来并不复杂关键是很多人没意识到“要先把签名验证放在任何业务逻辑之前”。一个可用的验证片段大致如下public static bool VerifyManifestSignature( string manifestJson, string signatureBase64, string publicKeyXml) { try { using var rsa new RSACryptoServiceProvider(); rsa.FromXmlString(publicKeyXml); var data Encoding.UTF8.GetBytes(manifestJson); var signature Convert.FromBase64String(signatureBase64); return rsa.VerifyData(data, CryptoConfig.MapNameToOID(SHA256), signature); } catch (System.Exception e) { Debug.LogError($清单签名校验异常: {e}); return false; } }生产环境可以对manifestJson做规范化处理比如字段固定排序、去掉多余空白确保服务端和客户端对同一段字节签名避免因为序列化顺序不一致导致签名验证失败。公钥要打进客户端资源私钥只存在于服务端任何情况下都不能外泄。需要注意的是公钥可以在客户端被逆向出来但攻击者只有公钥无法伪造签名因此这并不影响方案安全。3.3 第三步AssetBundle 下载时的哈希校验与依赖校验清单信任建立起来后AssetBundle 下载阶段要做的是哈希校验。先读取清单中该文件的预期哈希下载完成后先比对哈希再决定是否写盘。这里我的习惯是“先不入正式缓存”把下载内容先写到临时文件哈希验证通过之后再移动到正式缓存目录并更新索引。原因很简单如果边下边写遇到断电或进程被杀半截文件会留在缓存目录里下次启动它就会被当成合法文件反而造成新的污染。示例代码IEnumerator DownloadAndVerifyBundle( string url, string bundleName, string expectedHash) { using var request UnityWebRequest.Get(url); request.timeout 30; yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($下载失败: {bundleName}, {request.error}); yield break; } var bundleBytes request.downloadHandler.data; var actualHash CalculateMD5(bundleBytes); if (actualHash ! expectedHash) { Debug.LogError($哈希校验失败: {bundleName}, 期望 {expectedHash}, 实际 {actualHash}); yield break; } var tempPath GetBundleTempPath(bundleName); File.WriteAllBytes(tempPath, bundleBytes); SaveToCacheFromTemp(tempPath, bundleName, expectedHash); }依赖校验则是另一道关卡。AssetBundle 的.manifest文件会记录每个包依赖的 AssetBundle 列表下载时要把依赖包一起拉齐。如果只更新了主包而依赖包没更新Unity 加载时会在依赖目录里找旧版本表现出来就是资源对不上。这一步最稳妥的做法是在下载逻辑里解析依赖图先下载所有依赖再下载目标包下载完成后统一做一次加载前的完整性检查。3.4 第四步本地缓存目录的安全策略与版本管理本地缓存必须从“一个文件夹”升级为“一套状态管理系统”。我推荐在缓存目录里存一份索引文件例如cache_index.json里面记录当前版本号、每个 AssetBundle 的文件名、哈希、大小和最后访问时间。目录结构类似persistentDataPath/ HotUpdateCache/ v20250215/ ab1.unity3d ab2.unity3d cache_index.json加载资源前先读索引从索引里取到该文件对应的哈希和版本再和当前期望版本比对。如果版本不一致、哈希不匹配、或者索引里根本没有这个文件就删除旧文件并重新下载。这一步相当于给“本地仓库”也装了一道门禁不再盲信文件名。实际写索引时可以用 JSON 序列化结构简单[Serializable] public class CacheIndex { public string version; public ListCacheEntry entries new ListCacheEntry(); } [Serializable] public class CacheEntry { public string name; public string hash; public long size; }每次版本切换时把整个vXXX目录清理掉再重建比逐文件判断新旧要省心得多。这里有个坑AssetBundle 加载后会被 Unity 持有直接删除文件会因为文件被占用而失败所以清理流程必须放在所有 AssetBundle 卸载之后否则缓存目录里会残留一堆看似干净但无法删除的旧文件。3.5 第五步日志与监控埋点安全排查不能是一次性的需要把关键路径上的事件埋点做好。我至少要记录四类日志清单签名验证失败或成功AssetBundle 哈希校验失败的具体包名和期望/实际哈希缓存清理事件删除哪些文件、释放多少空间回滚或强制更新触发时的版本上下文。日志字段要带上客户端版本、平台、时间戳和 CDN URL方便后端聚合分析。这些日志平时看起来没用一旦线上出现批量异常它们就是定位第一现场的唯一线索。埋点上报要注意频率控制校验失败不能无限重传否则 CDN 异常期间可能把日志服务打爆。我在实际项目中会把同类事件做聚合例如五分钟内同一包名哈希失败只上报三次并带上累计次数。这样既能发现异常又不至于被突发事件冲垮。4. 一次实战复盘从“CDN 清单被篡改”到“本地缓存被锁定”4.1 事故现象版本异常回退那次事故的典型现象是线上玩家反馈游戏登录后资源回退到了旧版本部分 UI 显示错乱个别玩家甚至无限提示“重新下载资源包”。错误监控平台上的 AssetBundle 加载失败率一度冲到 8%。按照惯性团队先怀疑服务端版本配置出了问题但查了后端版本号是最新的资源文件也都齐全。于是排查重点转向客户端热更新链路。我们把一台测试机的本地缓存整个删掉重新启动后问题消失这说明问题大概率出在两个地方要么 CDN 返回的清单内容不正确要么本地缓存里有东西干扰了版本判断。顺着这个思路我开始对 CDN 返回的 manifest 做逐字节检查。4.2 定位过程CDN 缓存污染导致清单错乱使用抓包工具查看 CDN 返回的完整响应发现一个奇怪现象请求的 URL 带的是最新版本号但返回的 manifest 内容里文件哈希和老版本一致而且响应头里出现了Age: 86400说明边缘节点直接命中了一个老旧缓存。根本原因是 CDN 缓存键没有包含版本参数manifest.json的 URL 一直没变边缘节点以为文件没更新就把十几天前的旧清单继续发给客户端。客户端拿到旧清单认为本地需要大范围更新但又没有旧版本对应的包于是陷入反复下载的循环。修复 CDN 侧相对简单把缓存键改为含版本号同时给 manifest 和 AssetBundle 的 URL 都加上版本目录让任何一次发布都对应一个全新的 URL。这一步改完CDN 边缘节点不再返回旧清单。但接下来的问题才是真正磨人的由于客户端之前完全没有签名校验攻击者只要让 CDN 域名解析到伪造服务器就能返回任意清单所以我们必须把签名机制补上。4.3 修复一加入清单签名校验后的新问题加了签名校验后客户端确实能识别出伪造清单了但也带来了两个新坑。第一个坑是旧版本客户端不认识新格式线上存量玩家启动后请求新清单验签逻辑直接报错错误码积压。第二个坑是签名校验对 CDN 文件路径不能做“灵活匹配”因为签名是对整个 JSON 字节做的一旦服务端 JSON 字段顺序变了客户端老版本的验签代码就解不出同样的字节。我们用一个双清单方案绕开了兼容问题第一份是极简的version.json里面只有版本号和一个短签名客户端用它来判断是否需要强制更新第二份是详细资源清单带完整签名客户端验签通过后才开始下载。老版本客户端没有验签代码继续走老逻辑但服务端保留旧接口一段时间新版本客户端则强制走验签流程。这样既兼容了存量又把新版本的安全基线拉起来了。4.4 修复二本地缓存中残留的旧资源包引发混乱正当我们以为问题解决了测试团队又报出一个新场景全新安装的客户端正常但升级安装的客户端偶尔会加载到旧资源。这次问题出在本地缓存。旧版本客户端把资源包写进了persistentDataPath/AB新版本启动后看到文件名存在直接跳过下载根本没有检查这个文件是否属于当前版本。即使 CDN 清单是正确的本地缓存依然在捣乱。解决方案就是 3.4 节那套缓存索引机制给每个缓存文件挂上版本号和哈希。升级安装后客户端读取cache_index.json发现版本号不匹配就把整个旧版本目录删掉重新下载。这里特别补了一个时序细节清理动作必须在加载任何 AssetBundle 之前执行否则被 Unity 持有的文件删不掉旧资源仍然会被加载。那次之后我把这条时序写成了团队代码评审里的必查项。5. 排查过程中的常见问题与速查表5.1 高频问题对照表现象可能原因处理建议每次启动都重新下载全部 AB清单版本号没写入本地索引CDN 缓存键不正确检查 cache_index 的版本URL 加版本目录下载完成的 AB 加载后资源错乱依赖包未下载或本地下到了旧包按依赖图先下依赖加载前校验版本哈希签名校验偶发失败服务端 JSON 字段顺序不稳定多字节空白差异对签名内容做规范化日志记录参与签名的原始字符串缓存清理后仍然加载旧资源AssetBundle 未卸载导致文件被占用先AssetBundle.Unload(true)再删文件CDN 回源慢导致下载超时边缘节点缓存命中率低首包太大按版本预缓存合理扩大 TTL老版本客户端无法更新新签名格式不兼容使用双清单过渡保留旧接口这张表基本覆盖了热更新排查中八成的现场问题。如果你遇到的症状不在表里优先去翻安全日志看校验失败发生在哪个节点就能迅速缩小范围。5.2 排查工具与手段实际排查时我会准备四样工具抓包工具确认 CDN 返回内容和响应头重点看Age、Cache-Control、ETag等字段本地文件哈希计算器比如在终端里用md5sum或certutil计算缓存文件哈希与清单期望值做比对Unity 自带的AssetBundle Browser查看资源包依赖关系确认打包时依赖链是否正确以及一份客户端详细日志开启Debug.Log记录所有下载和校验事件。工具不在多在于能把“理论怀疑”快速变成“实锤证据”。5.3 怎么快速区分网络问题还是客户端问题这里有个很实用的鉴别流程先用无缓存模式启动客户端也就是删掉本地缓存目录后冷启动如果问题消失说明缓存有嫌疑如果问题仍在再把抓包得到的 CDN 响应哈希和发布平台上的原始文件做比对不一致就是 CDN 或中间链路有问题一致则是客户端逻辑有问题。这套流程可以避免在错误方向上一路排查到底我每次做热更新安全排查都先跑一遍。6. 几条值得长期遵守的安全铁律6.1 永远不要信任本地缓存中的任何文件不管文件是怎么出现在缓存目录里的加载前必须验证版本和哈希。把缓存当成“不可信输入”而不是“可信资产”客户端只负责校验后使用不负责判断文件是否合法。这一点是本地缓存安全的核心也是最容易被忽视的。6.2 安全密钥必须分开管理私钥只能放在服务端公钥打进客户端。客户端出现安全问题不能影响服务端签名体系所以密钥要有独立的更换流程和版本兼容策略。换密钥时不要立刻淘汰旧公钥要留一个过渡期否则还会遇到老版本客户端验签失败的坑。6.3 安全日志要能支撑回滚决策日志不是只用来事后分析的它还要帮你在上/下线之间做决策。如果一份日志能清晰地告诉你“某个 CDN 节点从几点开始返回错误哈希”那你就可以立刻摘掉那个节点或者回滚版本。日志字段里务必包含 CDN 节点标识、文件 URL、期望哈希、实际哈希缺一个都会让决策变得缓慢。6.4 安全排查要嵌入发布流程我在这次排查里最大的感受是安全机制不能全上线后补它应该是发布流程的一部分。每次发版前把“清单签名能否验证、缓存索引是否兼容、CDN 缓存键是否更换”这三个检查项放进发布清单线上事故率会明显下降。安全排查并不是一次性的工程它就是日常迭代的一个普通环节但很多团队要到被线上问题砸到才醒悟希望读完这篇的你能少走这段弯路。
返回列表