尧图精选

Unity AssetBundle热更新安全排查:从CDN清单到本地缓存的完整校验指南

🕒 发布时间:2026/10/2 13:43:15 📁 来源:尧图网络
1. 先说清楚热更新到底哪些环节容易出问题做Unity游戏客户端的人早晚都要碰AssetBundle热更新。我这次排查的起因其实很简单线上版本突然出现一例“更新到一半资源损坏”的反馈玩家重进游戏又能好可过一阵又复现。这类问题通常会被归咎于“网络差”但仔细看日志会发现拉下来的Bundle哈希和服务器端对不上甚至本地已经缓存的Bundle在二次加载时校验失败。顺着这条线索我把从CDN清单到下发的AssetBundle再到本地缓存的整个链路从头捋了一遍踩了不少坑也实测了几套排查方案在这里完整记录下来。先说清楚热更新的基本链路方便后面对号入座。常规做法是客户端启动后先向远端请求一个版本清单Manifest/Version File里面记录了当前资源版本的编号、每个AssetBundle的文件名、大小、MD5或CRC校验值然后客户端拿这份清单和本地已有的Bundle清单做比对找出需要新增、更新和删除的资源再从CDN拉取对应的Bundle文件。这套流程本身没什么问题但每一个环节都是潜在的安全和稳定性破绽。从我的经验来看真正值得排查的通常是四个层面清单文件本身的完整性。如果清单被篡改客户端拿到的就是一份伪造的资源对照表后续所有校验都失去意义。Bundle文件传输过程的完整性。哪怕清单没问题CDN回源或传输途中如果被污染拉下来的Bundle也可能和清单里的哈希对不上。本地缓存的完整性与权限。Bundle落地到本地之后是否被第三方篡改、被清理工具误删、或者写入时发生了半包残留都会影响后续加载。校验逻辑本身的覆盖范围。不少项目只校验了下载后的文件却没有校验“从本地缓存读取出来准备加载”的那一瞬间这个漏洞最容易被人忽略。如果你只是照着网上Demo写了一个“下载后比对MD5”的检查那恭喜你至少拦住了最粗的一类错误。但热更新的安全问题远不止“比对MD5”这么简单这也是我想把这次排查过程完整写出来的原因。2. CDN清单侧你以为校验了哈希就安全了2.1 清单文件里到底该有什么先看清单文件。很多项目的清单是Unity构建AssetBundle时自动生成的它包含的是每个Bundle的Asset信息、依赖关系、CRC等。但这个文件通常只用于编辑器构建期的校验不适合直接作为运行时热更新清单。运行时清单一般是自己维护的一份JSON或二进制文件至少包含以下字段字段作用是否必须version版本号用于判断是否要更新必须bundleNameBundle的相对路径或名称必须md5文件的MD5值必须size文件大小建议必须crc文件CRC32Unity加载时也会用可选dependListBundle依赖的其他Bundle依赖多时必须downloadUrl可覆盖的下载地址可选这里有一个很多人不知道的细节Unity的AssetBundle.LoadFromFile在加载时会做一次内部CRC校验但这个校验只在构建时指定了CRC的情况下才会启用而且它校验的不是文件内容是否等于清单里的值而是Bundle内部结构的完整性。换句话说哪怕你下载的文件被人整体替换成了另一个合法的BundleUnity也不会拒绝加载因为它在结构上是完好无损的。所以依赖Unity自身去防篡改基本等于没防。2.2 只校验MD5不够还要防“清单本身被换”我第一次排查时先检查了CDN返回的清单内容发现一个尴尬的问题清单里每个Bundle的MD5是对的但清单文件本身没有任何签名保护。也就是说攻击者完全可以拿一份旧的合法清单替换掉CDN上的新清单客户端比对之后会认为“本地已经是最新”直接跳过更新。这就是典型的降级攻击 (Downgrade Attack)。要怎么防我的做法是给清单加一层不可篡改的签名头。具体分两步第一步客户端内置一份公钥服务端用私钥对清单内容做签名签名结果作为一个字段附加在清单里。客户端拿到清单后先用公钥验签验签通过再解析内容。第二步如果项目阶段还不想引入非对称加密至少也要把清单放到一个独立域名或路径下并且开启CDN的HTTPS强制回源校验。但说实话HTTPS只能防传输被窃听和中间人篡改防不了CDN源站被入侵或者配置错误导致的源文件替换。所以在正式项目里签名验证是底线。下面这段C#代码是实测可用的RSA验签逻辑适合在客户端启动阶段调用using System.Security.Cryptography; using System.Text; public static bool VerifyManifestSignature(byte[] manifestData, byte[] signature, string publicKeyXml) { try { using (var rsa new RSACryptoServiceProvider()) { rsa.FromXmlString(publicKeyXml); var dataHash MD5.Create().ComputeHash(manifestData); return rsa.VerifyData(dataHash, CryptoConfig.MapNameToOID(MD5), signature); } } catch (Exception e) { Debug.LogError(Manifest signature verify failed: e.Message); return false; } }注意这里MD5虽然已经不算安全哈希但用于验签场景问题不大如果安全要求高换成SHA256更稳妥。私钥必须妥善保存在服务端绝不能出现在客户端工程里否则等于裸奔。2.3 拉取清单时的网络层坑点这一步是给SDK/引擎层做的容易被忽略。实测中UnityWebRequest在弱网环境下偶发“返回码200但数据不完整”的情况尤其是CDN开启分块传输chunked时。解决方式是拿到数据后判断dl.downloadHandler.data的长度是否和响应头里的Content-Length一致再配合清单本身的size字段做二次确认。另外很多项目会把清单和Bundle都放在同一个CDN域名下这其实会增加风险面。如果CDN配置了跨域CORS不当攻击者可能通过恶意页面拉取你的清单分析资源结构。我的建议是清单走独立路径或独立域名并禁止通过浏览器直接访问至少也要在服务端加一道Referer校验。3. 本地缓存最容易被忽视的安全重灾区3.1 缓存目录选择与权限隔离AssetBundle下载下来之后通常会写入Application.persistentDataPath下的某个子目录。iOS和Android对这个目录的权限管控不一样iOS的沙盒机制相对严格理论上只有你的App能访问Android在API 19以上也把外部存储权限收紧了但如果项目为了兼容老设备把targetSdkVersion调低或者把Bundle写到了公共存储区比如/sdcard/Android/data/包名/之外的位置那第三方应用就有机会读写你的缓存文件。我见过一个线上事故UnityWebRequest下载一半时App被系统杀死再次启动时下载任务续传结果本地残留了一个“半截Bundle”文件而清单里已经把这个文件标记为完成状态导致后续加载永远失败。这类问题的根源是下载状态和文件写入状态没有做到原子化。我的处理方案很简单先下载到临时文件bundleName.tmp全部写入完成并校验通过后再原子性重命名为正式文件。Unity的File.Move在Android上不一定保证原子性所以我用的是先删除旧文件再移动或者直接使用File.Replace.NET Standard 2.1支持。同时维护一个“下载状态记录”文件记录每个Bundle是否已经完成校验。string tempPath Path.Combine(cacheDir, bundleName .tmp); string finalPath Path.Combine(cacheDir, bundleName); // 下载到临时文件... // 校验通过后 if (File.Exists(finalPath)) { File.Delete(finalPath); } File.Move(tempPath, finalPath);3.2 本地缓存被篡改的识别手段本地缓存最大的威胁不是第三方恶意篡改毕竟移动端App沙盒相对封闭而是自己人改出问题。比如运营在真机上直接通过文件管理器替换了Bundle做测试或者测试包和多语言包混用导致缓存互相覆盖。这类问题一旦上线就是“千人千面”的稀烂表现。识别手段分三层第一层是启动时静态校验。本地缓存维护一个“已下载清单”的MD5表每次启动时对关键Bundle做一次全量或抽样MD5比对。全量比对在小包体时代还行包体几百兆之后就比较耗时所以建议抽样比对热更新频率最高的Bundle其余懒校验。第二层是加载前快速CRC校验。Unity的AssetBundle.LoadFromFile接口可以传入一个CRC参数不为0时会做一次内部校验。但注意CRC校验是Bundle结构校验不是内容比对所以防不了“合法替换”。要防合法替换只能在加载前自己算整个文件的MD5或者对Bundle内部的关键Asset做哈希。第三层是运行时校验。在AssetBundle.LoadFromFile返回的之后立刻读取一个埋点Asset并验证它的内容是否正常。这个方法比较土但能快速发现“Bundle结构合法但内容被替换”的情况。我一般会在每个Bundle里塞一个只有资产组知道的CheckAsset比如一张1x1的纯色Texture加载后读取它的像素颜色不对就标记异常。private void CheckAssetBundleIntegrity(AssetBundle ab, string expectedColorHex) { var checkAsset ab.LoadAssetTexture2D(check_asset); if (checkAsset null) { Debug.LogError(CheckAsset missing, bundle may be tampered); return; } var pixel checkAsset.GetPixel(0, 0); Color expected; ColorUtility.TryParseHtmlString(expectedColorHex, out expected); if (pixel ! expected) { Debug.LogError($Bundle content mismatch: expected {expectedColorHex}, got {pixel}); } }3.3 缓存清理策略与残留文件本地缓存是会膨胀的。不做清理的话几十个版本的Bundle堆积起来存储空间很容易被吃满。但清理策略做不好又会误删正在使用的Bundle或者把还没下载完的热更资源删了导致更新永远卡住。我目前的缓存清理策略是“版本分区 LRU淘汰”。每次热更新切换版本时把persistentDataPath/AssetBundles/版本号/作为一个独立分区。启动时先看当前版本对应的分区是否存在不存在就创建旧版本分区里的文件先不删等新版本稳定运行超过7天后再做清理。这样玩家覆盖安装老版本时还能继续用缓存不会被迫重新下载全部资源。清理时注意不要只按文件时间排序因为Bundle的时间戳在下载时会统一设置为CDN响应时间同一批下载的文件时间非常接近。我改用“最近未访问时间 文件大小”综合评分优先淘汰大小大且长期未访问的Bundle。4. 全链路排查实录从日志到修复的完整过程4.1 先抓日志再谈定位我排查这次问题第一步不是改代码而是把热更新链路上所有关键节点都打上日志。这里分享一个原则日志要打在决策点上而不是过程点上。比如“发起下载”“下载完成”“校验通过”“校验失败”“加载成功”“加载失败”这些是决策点“开始连接”“连接成功”“收到数据”这种是过程点日志太多反而干扰判断。我新增的日志结构大致是这样// 下载前 Log($RequestDownload: {bundleName}, md5{remoteMd5}, localMd5{localMd5}); // 校验时 Log($VerifyResult: {bundleName}, fileSize{fileSize}, remoteMd5{remoteMd5}, calcMd5{calcMd5}, pass{isPass}); // 加载时 Log($LoadBundle: {bundleName}, crc{crc}, loaded{success});日志要带上时间戳、网络类型Wi-Fi/4G/5G、当前版本号、CDN节点信息。加CDN节点信息的目的是为了排查“同一个版本号但不同地区资源不一致”的分发问题。4.2 模拟弱网和半包场景定位问题的第二步是复现。直接调线上CDN复现“半包”很困难我建议在本地起一个可控的HTTP文件服务用工具模拟两类场景随机断流下载到一半强制断开连接测试客户端是否会残留临时文件。内容污染下载完成后故意把文件末尾的200个字节替换成随机数据测试校验逻辑是否有效拦截。我实际用的是Charles的Map Local功能把CDN地址映射到本地目录然后在本地脚本里控制文件返回值。Charles的Rewrite功能也能用来做内容替换但我发现它对二进制文件的修改不太方便更推荐直接写一个Python脚本动态返回内容import http.server import hashlib import random class BundleHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): # 正常返回Bundle内容 data open(self.path.lstrip(/), rb).read() # 有概率注入损坏数据 if self.path.endswith(.bundle) and random.random() 0.3: pos len(data) // 2 data data[:pos] bytes([0xFF]) * 64 data[pos64:] self.send_response(200) self.send_header(Content-Length, str(len(data))) self.end_headers() self.wfile.write(data) http.server.HTTPServer((127.0.0.1, 8080), BundleHandler).serve_forever()实测下来有30%概率注入污染时原项目的“下载后校验”逻辑能拦截一部分但“加载时校验”因为完全没做导致崩溃率上升。这个测试让我确定了修复优先级先把加载时校验补上再去优化下载逻辑。4.3 检查CDN回源和缓存命中本地也排查确认了客户端逻辑没有问题之后还需要回头看网络链路。CDN的配置问题也会导致资源出不完整。重点看三个点第一CDN有没有配置压缩。早期有团队为了省流量给Bundle文件开了Gzip压缩。AssetBundle本身已经是压缩过的LZ4或LZMA数据再套一层Gzip不仅收益极小还在客户端解压时增加出错面。我通常要求CDN对Bundle类型文件强制关闭压缩或者用Cache-Control: no-transform告诉中间层“不要动内容”。第二CDN的缓存键配置是否正确。如果缓存键只包含URL路径不包含版本号参数那客户端更新版本后访问同一个URL可能命中CDN上的旧缓存。所以热更新URL必须带上版本参数或者使用不可变的文件名文件名里含哈希。第三回源超时策略。CDN回源时如果上游超时有些CDN会返回200但内容为空或者内容不完整。我遇到过一次CDN节点上某个文件源站被误删了但节点上还保留着旧缓存导致部分玩家永远更新不到新版资源。排查方式是让运维在CDN侧对比源站和节点的资源哈希。4.4 修复过程与验证修复按三个优先级推进优先级最高的是在Bundle加载前加MD5校验。这里有个性能考量如果每个Bundle加载前都全文件算MD5在大世界场景下每次进副本都要读几十兆甚至上百兆文件耗时不可接受。我的方案是“首次加载校验 后续内存缓存校验结果”也就是每个App会话内每个Bundle只校验一次校验通过后把结果放到Dictionarystring, bool里后面直接信任。优先级中等的是下载状态原子化也就是前面说的临时文件重命名方案。这个改动很小但能根治“半包残留”问题。优先级较低但必须做的是清单签名验证。这一步对现有架构侵入最大需要服务端配合改造所以我是放到重构窗口期去做的。不过即使不做非对称签名也至少要在服务端把清单放在HTTPS下面并且客户端校验清单里的version必须大于等于本地版本防止降级。验证阶段我在三台设备上做了测试一台Android真机走弱网一台iOS真机走Wi-Fi一台PC编辑器直接放CDN地址。每台设备循环更新50次统计“更新失败率”“残留文件数”“加载失败数”。修复前和修复后的对比数据是指标修复前修复后更新失败率弱网12.6%1.8%半包残留文件数7个/50次0个/50次加载失败数4次/50次0次/50次平均更新耗时正常网18.2秒18.5秒更新耗时有微增因为首次加载多了一次文件校验但在可接受范围。如果你对这个耗时特别敏感可以把校验从“加载前”挪到“加载完成后异步线程里做”这样玩家的等待时间不增加只是异常发现会延迟到下次启动。5. 热更新安全水位线的建立做完了这一轮排查我把热更新的安全体系梳理成了一个可以长期复用的检查清单。以后每次发版本之前我都会对着这个清单过一遍可以明显降低线上资源事故的概率。5.1 必须有的五道校验这五道校验少一道都不要说自己做了热更新安全清单签名验证防止清单被替换或降级。没有条件做非对称签名至少做HMAC-MD5但密钥必须藏在客户端安全存储区不能用明文字符串。下载完成后的文件MD5校验比对清单里的md5字段不一致则删除重下。这是最容易被实现也最基础的一道。加载前的快速校验至少做CRC或者抽样MD5防止缓存文件在两次启动之间被篡改或损坏。性能不足时做抽样内存缓存结果。加载后的内容埋点校验通过CheckAsset或特定Asset的内容特征验证Bundle是否被“合法替换”。这一步能拦下最隐蔽的攻击。版本回滚保护客户端要能识别“本地版本高于远端版本”这种异常情况避免被旧清单拉低版本。服务端也要能查看当前线上版本的CDN文件列表防止源站文件被误覆盖。5.2 工具链的沉淀排查过程中我把之前临时写的Python脚本整理成了一个小工具库功能包括模拟弱网、注入损坏、计算目录下所有文件的MD5、对比两份清单的差异。这套工具在后续每次热更新测试里都派上了用场。如果你们项目还没有类似的工具我建议优先实现两个一个是清单对比工具输入两份清单比如旧版本和测试版输出新增、删除、变更的Bundle列表。手工对比JSON太容易出错而且大项目清单几千个条目肉眼根本看不完。另一个是缓存体检工具在客户端加一个隐藏入口跑完后输出当前缓存目录下的文件数、总大小、异常文件数、各版本的缓存占用。这个工具对玩家反馈“更新后动画错乱”“贴图花掉”这类问题能帮助快速判断是不是缓存污染导致的。5.3 最后再分享一个被坑出来的经验Unity热更新的坑大多数不是出在“代码写错了”而是出在“流程有漏洞”。尤其是AssetBundle的校验如果你把清算逻辑放在业务层而不是放在资源加载层那后续写业务代码的同事很容易绕过。我现在的做法是不让业务层直接调用AssetBundle.LoadFromFile而是包一层ResourceManager.Load接口由它统一做校验和加载。这样安全逻辑才能形成闭环。另外Android的Application.persistentDataPath在不同厂商ROM上表现不完全一致有些系统清理工具会把它当缓存清理掉。遇到这个问题不要赌ROM不会清理一定要在加载Bundle时捕获FileNotFound异常触发自动重新下载。我见过的最严重的事故就是因为ROM清理了persistentDataPath下的Bundle而客户端直接闪退玩家以为游戏坏了留下了大量差评。我个人在实际排查中的体会是热更新安全是一个“持续对抗”的过程没有一劳永逸。这次修完了CDN清单和本地缓存下次可能就有人从插件市场引入一个不安全的第三方SDK把Bundle加载入口暴露出去。所以重要的不是每个版本都做一次大排查而是把安全检查变成日常开发流程的一部分。回归测试里加上缓存篡改、降级攻击、弱网半包这三类场景跑一次也就十几分钟但能拦住绝大多数线上资源事故。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →