ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity AssetBundle热更新链路安全排查:CDN缓存、版本回退与验签实践

Unity AssetBundle热更新链路安全排查:CDN缓存、版本回退与验签实践 1. 热更新安全排查的起点先弄清AssetBundle链路里谁在“信任”谁1.1 一个让我熬夜到凌晨三点的“版本回退”现场先讲个真实经历。当时项目在做AssetBundle热更新灰度CDN配置刚切完第二天就收到运营反馈有部分玩家更新到新版后进入游戏却发现角色模型、UI贴图还是老版本的。一开始我以为是CDN刷新没生效让运维强制刷新了一轮结果问题依旧。更诡异的是同一台测试机清掉App重装就正常不重装就稳定复现。这个现象把所有人都带偏了大家第一反应都是“本地缓存污染了”可我把Unity的Caching目录清空之后问题还在。后来一路追到CDN清单和本地缓存的交叉点才真正定位到原因CDN上同一路径的旧清单文件没有被清理客户端拉取清单时命中了旧缓存于是按老清单里的hash逐个下载AB包本地缓存又因为hash对不上而反复回退。那次之后我养成了一个习惯——凡是热更新出问题先不要急着查某一个环节而是把从CDN清单到本地缓存的整条链路安全假设全部列出来逐个验证。这篇内容就是把当时排查的思路和方法完整梳理一遍涉及AssetBundle热更新中最容易被忽略、也最容易翻车的几个节点。1.2 AssetBundle热更新的完整链路里有哪些信任依赖要理解安全排查为什么必须“从头查到尾”得先看一条热更新数据到底经过哪些环节客户端启动后请求远程版本号接口拿到最新的版本号根据版本号拼接CDN地址下载对应版本的清单文件manifest / version.json解析清单比对本地已下载的AB包版本与hash对缺失或过期的AB包向CDN发起下载请求下载完成后写入本地缓存目录运行时通过AssetBundle.LoadFromFile / LoadFromMemory加载对应Bundle。这条链上每一跳都有“信任假设”。比如你以为CDN返回的清单一定是最新的你以为清单里的hash一定是对的你以为写进本地缓存的AB包一定没被动过。安全排查的本质就是把每一个“以为”变成“验证过”。1.3 排查前需要准备好的三样“参照物”在实际动手前我建议先把下面几样东西准备好不然排查过程会变成盲人摸象版本信息文件线上最新版本号、历史版本号列表、AB包总数与总大小。没这个你没法判断客户端到底该更新到什么状态。依赖关系表每个AB包依赖哪些其他AB包父级AB变化会不会导致子级AB被连带更新。依赖关系不梳理清楚很多“缓存异常”其实是加载顺序问题。CDN配置记录包括缓存策略、刷新记录、源站地址、鉴权方式。排查CDN环节时这些配置就是判断依据。这三样东西齐了之后排查才有的放矢。接下来我会把CDN清单侧、本地缓存侧、以及两者交界处的暗坑逐个拆开讲。2. CDN清单侧的安全排查旧版本回退、缓存穿透和目录遍历2.1 版本回退攻击为什么防不胜防版本回退Downgrade Attack是热更新系统里最常见、也最容易被忽视的问题。攻击者不需要破解你的客户端只需要让客户端拿到一个旧版本的清单然后配合旧AB包的下载地址就能让玩家“稳定地”停留在老版本甚至回滚到有漏洞的旧版本。触发版本回退的原因常见有三种触发场景说明CDN缓存未清理旧清单文件仍有缓存客户端请求命中旧版本版本号判断逻辑弱客户端只比较版本号大小不校验清单签名攻击者可构造低版本号回源策略异常CDN回源时拉到源站的临时错误页或旧文件并缓存了错误结果我在那个项目里遇到的就是第一种。当时CDN刷新未做URL批量清理只刷新了新版目录旧目录里残留的清单文件原封不动客户端一旦在弱网环境下走缓存命中就直接拿到了旧清单。排查版本回退问题的推荐做法是在客户端代码里把版本号不是简单当成一个数字来比较而是把“当前清单hash”和“上次成功加载的清单hash”也一并参与校验。如果远程清单的hash和客户端期望的hash不一致即使版本号更大也应当拒绝加载并重新拉取。2.2 CDN缓存策略设计不合理会把安全排查逼入死角CDN缓存策略和热更新链路是强耦合的。如果CDN配置了“目录级缓存规则”而不是按文件类型区分那么清单文件和AB包会被套上相同的缓存时长。清单文件一旦被CDN边缘节点缓存了TTL还没到客户端就永远拿不到新版本于是热更新形同虚设。我在项目里采用的CDN缓存策略如下清单文件.json / .manifest设置较短缓存时间通常为0到60秒或者直接配置为不缓存、强制回源AB包资源.bundle / .ab按文件名hash做永久缓存。因为AB包文件名本身带有hash内容变化后文件名必然变化CDN缓存过期与否都不影响正确性download请求开启CDN Range回源支持避免大包下载中断后重新下载整个文件。这个策略的核心逻辑是可变内容的文件走短缓存不可变内容的文件走长缓存。清单文件内容是会变的所以不能长缓存AB包文件一旦生成就不变了文件名即内容hash所以可以放心长缓存。2.3 源站信息泄露和目录遍历CDN上的隐形后门很多项目的源站地址通过报错信息就泄露了。比如AB包下载请求超时后客户端打印的异常信息里把源站IP直接带了出来攻击者拿到源站IP后就可以绕过CDN直接打源站或者探测源站目录结构。我在实际加固时做的几件事很值得参考关闭CDN源站错误透传让CDN对源站返回的404、500等错误统一包装不把源站地址暴露给客户端为AB包目录配置访问鉴权至少做防盗链校验正规的做法是给CDN配置URL鉴权比如阿里云CDN的A/B/C类型鉴权让下载链接带上时间戳和签名对源站目录做最小权限暴露源站上只保留当前热更新版本所需的AB包目录历史版本包定期归档到冷存储不对外提供下载。目录遍历这一点特别容易漏。如果源站目录结构是“版本号/平台/AB包”而CDN又没有禁用目录列举那么攻击者可以通过遍历目录拿到你所有历史版本的AB包清单甚至分析出版本演进过程中修复过的漏洞再配合版本回退攻击精准利用。2.4 排查CDN侧问题时我怎么用几个请求定位根因排查CDN侧问题时我通常会用curl直接模拟客户端的请求链路# 测试清单文件是否被CDN缓存 curl -I https://your-cdn.example.com/assetbundles/1.0.5/version.json # 关注响应头中的Cache-Control和Age字段 # 测试旧版本路径是否仍然可以访问 curl -I https://your-cdn.example.com/assetbundles/1.0.3/version.json # 测试是否开启目录遍历对路径做末尾斜杠请求 curl -I https://your-cdn.example.com/assetbundles/如果version.json的响应头里Cache-Control为max-age3600且Age较大说明客户端很可能会命中缓存这就是清单回退的根源。此时应当去CDN控制台刷新该URL并把清单文件的缓存策略改短。不过需要说明的是curl模拟的是“CDN视角”客户端真实行为还要考虑本地缓存的干扰。这就是下面要说的第二个排查维度。3. 清单文件是整个热更新链路的“信任根”但大多数项目的验签形同虚设3.1 清单文件里到底放了什么被篡改会造成什么后果一份典型的Unity AssetBundle热更新清单文件内容大致是这样的{ version: 1.0.5, list: [ { path: assets/characters/hero_001.ab, hash: a1b2c3d4e5f6a7b8c9d0e1f2, size: 342187, dependencies: [assets/common/shader.ab] }, { path: assets/effects/explosion_001.ab, hash: 9f8e7d6c5b4a39281706f5e4, size: 51230, dependencies: [] } ] }这个文件一旦被篡改后果不是“某个AB包加载失败”这么简单。攻击者可以把任意AB包的hash替换成恶意包体的hash客户端校验时只会发现“本地文件hash与清单一致”于是毫无防备地加载了被篡改的Bundle。也就是说清单文件的完整性就是整条热更新链路的“信任根”信任根坏了下面全崩。3.2 常见的“假校验”方式为什么拦不住攻击者不少项目的清单校验只做了两件事MD5比对和版本号比对。这两件事在不考虑恶意攻击时勉强够用但一旦遇到有针对性的攻击防护能力约等于零。先说MD5比对。这个方案依赖的是“清单文件里自带MD5字段”或者“客户端硬编码了一个MD5值”。前者的问题是攻击者改清单时把MD5字段一起改了就行后者的问题是客户端硬编码的MD5一旦被破解后面所有版本都失去了防护。本质问题是MD5只是完整性校验不是真实性校验。它只能检测文件在传输过程中是否损坏无法确认文件是否来源于可信方。再说版本号比对。如果客户端只是把远程版本号与本地版本号做大小比较攻击者完全可以构造一个更高的版本号和一个配套的恶意清单。版本号在安全语境里只是“期望值”不是“信任凭证”。3.3 真正能落地的验签方案非对称签名 客户端白盒目前项目里验证有效的方案是采用非对称签名对清单做校验具体步骤是这样的打包服务器使用私钥对清单文件做签名生成sign字段随清单文件一起发布到CDN客户端内置公钥下载清单后先验签验签通过才解析内容公钥库做成可更新的密钥轮换时通过强校验通道下发新公钥私钥严格保存在打包机内网永不外泄。下面是核心验签逻辑的C#示例public static bool VerifyManifest(byte[] manifestData, byte[] signature, RSAPublicKey publicKey) { using (var rsa new RSACryptoServiceProvider()) { rsa.ImportParameters(publicKey.ToRSAParameters()); var sha256 new SHA256Managed(); byte[] hash sha256.ComputeHash(manifestData); return rsa.VerifyHash(hash, CryptoConfig.MapNameToOID(SHA256), signature); } }这里有一个很容易忽略的坑签名算法必须选择SHA256不要用SHA1或MD5。我见过有项目为了“性能”用了MD5签名后来发现可伪造性太强回炉重改。另外公钥本身不能明文写死在代码里至少要拆成多段拼接或稍做混淆否则攻击者直接替换客户端公钥就绕过了签名校验。3.4 清单校验失败的边界场景怎么处理验签失败的情况在真实项目中并不罕见。弱网导致下载的清单不完整、打包机签名环境配置错误、CDN缓存了未签名的历史文件这些都会导致客户端验签失败。处理原则就一条验签失败时绝不允许静默通过但也不建议直接抛致命错误让玩家无法登录。我的做法是分两级处理。第一级取消本次更新继续使用本地现有版本并记录日志上报第二级如果连续三次验签失败弹出更新失败提示引导玩家清理缓存或重新下载完整包。这种方案既能兜住脏数据又不会让正常玩家因为一次网络抖动就被卡死在登录页。4. 本地缓存侧的暗坑被替换、被降级、被错误命中4.1 UnityWebRequest的缓存命中逻辑比你想的更“粗糙”Unity的AssetBundle下载缓存机制核心依赖两个东西缓存路径和缓存键CacheKey。很多开发者以为缓存键会绑定文件内容hash所以内容变了缓存就会自动失效。但实际上UnityWebRequestAssetBundle的缓存命中逻辑默认是基于下载地址的而非基于内容。也就是说如果两次下载的URL相同而服务器上的资源已经更新了客户端拿到缓存后不会重新下载。Unity引擎本身在2017.2之后提供了Hash128缓存键机制但如果你的代码里没有传缓存键或者传的hash值不变就会命中错误版本。这带来两个安全风险URL不变、内容更新时客户端可能一直加载旧Bundle同时因为清单里面的内容hash已经变化加载后出现资源错乱、贴图丢失甚至Shader报错的现象攻击者如果能够读取本地缓存目录完全可以替换缓存文件然后触发一次URL相同的下载让客户端误以为“缓存未命中”或“缓存已更新”。解决方法是下载AB包时必须传入清单中的hash作为缓存键并且要确认每次版本更新后远端AB包文件名或URL会跟着hash一起变化。两条规则缺一条缓存命中都会成为安全隐患。4.2 本地缓存目录的写权限问题谁都能往里塞文件这是最容易被当成“不是问题”的问题。Unity默认的缓存目录在Android上是/data/data/包名/cache在PC上是C:\Users\用户名\AppData\LocalLow\公司名\项目名。看起来挺安全但在以下场景下这个目录完全可能被外部写入Android root设备root权限下任何进程都能读写其他应用的缓存目录PC端游戏本地进程和用户自己的权限没有严格隔离用户完全可以手动改动缓存文件目的可能是“魔改”“解包”“作弊”iOS越狱设备同样存在缓存目录被篡改的可能。即便不考虑root/越狱这类极端场景还有一个更容易被人忽略的点缓存目录里的文件如果来源于一次不完整的下载Unity的Caching系统可能认为文件存在且直接命中导致运行时加载到半个Bundle。这种问题没有恶意攻击者也会发生属于低概率高破坏的脏数据问题。所以在客户端侧每次从缓存加载AB包之前我都建议和清单里的hash做一次比对不一致就当缓存失效处理重新下载。这条校验放到下载逻辑里不要放到加载逻辑里避免每次加载都产生额外的IO。4.3 缓存键冲突和AB残留部分更新场景下的隐形炸弹当你的项目做“部分更新”比如只更新某个活动资源包但整体版本没有升级时缓存键冲突的概率会明显提高。因为部分更新通常沿用原有URL路径只替换了AB包内容而缓存键如果还是原来的就会加载出旧内容。另外一个相关坑是AB包卸载后的内存残留。AssetBundle.Unload(false)只卸载AB包资源对象不卸载从该AB包实例化的GameObject如果加载新版本AB包时这些残留引用还在就会出现“看起来更新了实际画面上还是老资源”的诡异现象。这个问题在排查时容易被误判成“安全回退”白白浪费时间。我自己在项目里的做法是切换版本时对所有AB包执行一次Unload(true)再通过GarbageCollectAssets强制回收一次确保DBL依赖引用链全部清掉然后再加载新清单和新包。代码层面的操作细节这里需要额外说明的是Unload(true)会造成所有从该AB加载的资源全部失效因此必须保证调用时机在场景切换或资源卸载的完整间隙中不要穿插在运行中。5. 一次完整的安全排查实操从现象定位到根因闭合5.1 现象复现与初步定位先区分“网络更新问题”还是“本地加载问题”上文提到的那次灰度事故按照排查流程重新走了一遍第一步就是把现象拆成了三种可能客户端拉取的清单是不是旧版本客户端拉取的清单是最新的但AB包下载是不是被本地缓存污染清单和AB包都正确但运行时加载的是不是内存残留的旧资源区分这三种情况用的方法很简单在客户端打点日志里同时打印“清单版本号”“清单hash”“每个AB包加载时的URL、缓存键、加载耗时”。通过日志能快速判断问题出在“更新链路”还是“加载链路”。那次的日志结果显示清单版本号是新的1.0.5但清单文件本身的hash和服务器上1.0.5的原始hash不一致进一步对比后发现这个“新版本”的hash与1.0.4一模一样——也就是说客户端以为自己更新了实际拉到的是1.0.4的旧清单内容。这就是典型的CDN旧目录缓存回退。5.2 CDN侧验证与本地缓存侧验证要同时做定位到这里后我又拉了两条线同时验证避免单角度误判。CDN侧验证用curl请求旧版本清单地址确认返回内容是否正常、响应头缓存参数是否异常到CDN控制台查刷新历史确认之前在哪个时间点对哪些URL执行过刷新直接请求源站文件的hash与CDN节点上的文件做比对确认CDN缓存是否已经过期。本地缓存侧验证客户端沙盒目录里找到version.json用脚本计算hash和服务器源文件hash比对对本地缓存的AB文件抽样做hash校验确认它们和清单记录的是否一致用清理缓存的方式重跑一次更新流程看问题是否还在。这两条线拉完基本就把责任边界划清楚了CDN源站文件正确CDN边缘缓存了错误文件本地客户端没有安全校验清单hash故直接采用了CDN返回的旧清单。两边都有问题只是触发的主导因素在CDN。5.3 应急处置与根因修复的优先级处置动作按优先级排了个顺序这里也推荐给你作为参考立即摘除故障CDN域名下的热更新目录回源让客户端走源站直连恢复线上正常更新止血全量刷新CDN缓存目录尤其是旧版本缓存残留的URL确认刷新后的文件hash与源站一致消除缓存污染客户端发布热更修复版本清单文件增加签名校验下载AB包时缓存键绑定内容hash根因修复对本地缓存文件增加启动时完整性抽查避免旧缓存文件被再次信任长效兜底。这里要特别强调一点应急处置阶段很多人的第一反应是“在客户端代码里加逻辑跳过旧缓存”但我当时选择先恢复CDN回源是因为客户端发版有审核周期而CDN配置生效只需要几分钟。安全排查不只是找问题还要按“最快恢复线上”的顺序来安排处置动作否则排查清楚了事故也已经扩大了。5.4 排查过程中踩到的两个容易误判的细节第一个误判是“清缓存大法”。最初我让人清掉本地缓存来复现问题但因为缓存清的是Caching目录而我实际裁定的问题点CDN清缓存并不能消除CDN侧的错误响应。所以后来我做了一个约定排查热更新问题时先抓一次完整日志再动本地缓存动完缓存再抓一次日志对比两次行为差异。第二个误判是“版本号越大越新”。当时线上存在多个历史版本分支并行版本号1.0.4和1.0.5之间存在并行分支某个分支上1.0.4其实比1.0.5还新。如果不看版本分支只看数字就会被误导。因此后期我把版本号改成了“主版本号构建号Commit号”的组合从根本杜绝了这种数字比较盲区。6. 加固方案与长期基线把能想到的风险点全部管线化6.1 清单签名 双层校验客户端启动时和加载前各验一次经过那轮事故后我把热更新的校验机制重新设计了一遍。最核心的变化是“一次下载、两处校验”。下载前校验客户端在下载清单文件后立即用公钥验签同时校验清单hash是否与当前期望版本一致不一致则不走更新流程加载前校验从本地缓存加载AB包之前用清单里的hash校验本地文件不一致则删除缓存并重新下载。双层校验的代码逻辑在AB包下载入口处会体现得像这样public IEnumerator DownloadAssetBundle(string url, Hash128 hash, string localPath) { // 1. 检查本地缓存文件hash是否和清单一致 if (File.Exists(localPath)) { string localHash CalculateMD5(localPath); if (localHash ! expectedHash) { File.Delete(localPath); } } // 2. 用UnityWebRequest下载传入cacheKey绑定内容hash UnityWebRequest request UnityWebRequestAssetBundle.GetAssetBundle(url, hash, 0); yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { AssetBundle bundle DownloadHandlerAssetBundle.GetContent(request); bundle.name Path.GetFileNameWithoutExtension(localPath); // 3. 加载前再校验一次内存中的bundle是否正常 if (bundle null) { Debug.LogError(AssetBundle加载失败hash校验不通过); } } }注意这里我额外使用了Hash128作为缓存键。如果项目运行在Unity 2017.2以下不支持Hash128缓存键仍然强烈建议至少把本地文件的MD5/SHA1校验做进去。6.2 防降级机制旧版本清单和AB包“不可信原则”版本回退攻击的根因本质上是对“旧版本内容”的信任。所以我加固时立了一条规矩除当前线上版本外其余历史版本的清单和AB包全部视为不可信来源。具体落地到系统里就是客户端在拿到远程清单时除了验签还会检查版本号是否在“允许更新版本列表”中。这个列表由服务端下推包含当前最新版本号和最低可更新版本号。如果远程清单版本号低于最低可更新版本直接拒绝更新并引导用户重新下载整包。这条规则有效规避了“攻击者故意把版本号改低来诱导客户端回退”的攻击路径。6.3 上线前安全自查表每次热更新发布前过一遍最后整理一份自查清单项目每次热点更新发布前我都会让研发、QA、运维三方各自对照执行一遍。这里直接放出来供你直接抄作业检查项检查方法通过标准CDN清单缓存策略控制台查看list文件的缓存配置缓存时间不超过60秒建议为0旧版本文件清理curl请求上一版本的root文件和主清单返回404或跳转到最新版本清单签名有效性独立脚本验签验签通过且hash与源站一致AB包文件名是否含hash抽查5个以上AB包URL文件名包含内容hash禁止纯数字命名客户端缓存键绑定版本hash检查下载代码参数UnityWebRequest缓存键传入Hash128本地缓存完整性校验启动日志中抽样hash比对无“本地hash与清单不一致”告警源站目录访问权限尝试列举版本目录返回403或禁用列表CDN错误响应构造一个404请求看响应体响应体不包含源站IP或内网地址这份清单执行了半年多热更新类线上事故基本清零也大大缩短了新同事接手热更新项目时的排查成本。6.4 排查工具与日常监控的建议除了在代码层面加固我还建议把热更新链路的监控做成“可观测”的。比如在客户端上报以下事件到日志系统清单下载耗时与验签结果每个AB包的下载耗时、命中缓存还是远程下载本地缓存目录空间与文件数量每次启动时本地缓存文件hash校验失败的明细。有了这些数据下一次安全排查不再需要靠“抓日志猜原因”而是直接在监控看板上看到异常趋势。我个人的经验是热更新事故不可怕可怕的是每次排查都要重新把链路摸一遍。把链路监控数字化、管线化之后排查效率至少翻一倍。
返回列表