ARTICLE DETAIL

资讯详情

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

Unity AssetBundle热更新安全排查:下载、校验、加载链路解析

Unity AssetBundle热更新安全排查:下载、校验、加载链路解析 1. 先摸清 AssetBundle 热更新整条链路1.1 下载、校验、加载的三段式流程做 Unity 客户端的人几乎没有不碰 AssetBundle 热更新的。项目一上线Art 资源包、配置表、Lua 脚本全走 AB 包下发稍不注意线上就炸。排查热更新安全隐患前我建议你先别急着看代码先把链路在地图上画出来。我这里用的排查思路是把整个热更新拆成“下载 — 校验 — 加载”三段再逐段找问题。正常流程是这样客户端启动后先拿一个远程版本信息文件version.json 或 version.txt这个文件里记录了当前线上每个 AB 包的版本号或者文件哈希。客户端拿到后把它跟本地缓存的版本信息做对比发现某个包需要更新就拼出 CDN 的完整 URL开始下载 AB 包。下载完先算一次哈希确认没问题再写入本地缓存目录更新本地版本信息最后通过 AssetBundle.LoadFromFile 加载。问题就出在很多人把三段串成了一条线以为只要下载成功就等于更新成功。实际上下载、校验、加载三条链路各自有各自的安全边界任何一个环节被绕过热更新都会被钻空子。比如我在一个外包项目里看到过一种做法下载完 AB 包后直接用 AssetBundle.LoadFromFile 加载中间完全没做哈希比对配置表被换掉都没察觉。1.2 安全排查的五个关键角色排查热更新安全问题时我习惯先把参与方列全。热更新链路里至少有这么几个角色远程版本清单记录资源名、版本号、文件哈希、URL 路径是热更新的“总指挥”AB 包文件本体 .bundle、.unity3d、.ab包含场景、Prefab、Shader、图集、配置表本地缓存区persistentDataPath 或 temporaryCachePath 下存储下载完成的 AB 包本地版本记录记录上一次更新完成时的版本号与哈希列表常摆在本地文件或 PlayerPrefs 里CDN 边缘节点负责把资源返回给客户端的中间分发层可能缓存旧数据或返回错误内容。每一步排查都要先问一句这段数据的信任基础是什么比如远程清单可信是因为它来自 HTTPS 域名本地缓存可信是因为它写入前做过哈希校验。如果信任基础断了后面全是白搭。1.3 热更新常见的四大风险面把四个风险面列出来排查时就能快速定位清单被篡改或伪造版本清单本身没做签名校验CDN 被劫持时恶意清单可以把客户端指向任意资源路径AB 包被替换文件哈希没比对或者 CDN 边缘节点缓存了被污染的旧包加载后表现为贴图错乱、模型丢失、严重的直接闪退本地缓存脏数据旧版本残留文件、写入中断产生的半截文件、多线程并发写同一文件导致加载失败版本回滚攻击攻击者拿旧版清单和旧 AB 包覆盖本地把客户端“打回”存在已知漏洞的版本。所以热更新安全排查的核心就一句话把信任链从远端到本地完整走一遍确认每一个数据源的来源和完整性都可验证。2. 清单链路排查从 CDN 清单到本地缓存的信任传递2.1 远程清单的加载顺序与校验点版本清单是最先到达客户端的数据它决定了后续所有下载行为所以第一个排查点就是它。我见过至少三种错误做法大家可以对照自查第一种直接加载远程清单但从不校验。这种做法的风险是只要 CDN 被劫持或者中间人解包攻击者就能让客户端下载任意地址的 AB 包之后的所有安全策略全部失效。第二种把清单哈希写死在本地代码里。表面上做了校验但热更新本身就是“动态的”清单内容每次发版都会变写死哈希等于每次发版都要发客户端热更新失去意义。如果你必须做静态校验只能校验“清单的签名”不能校验清单内容本身。第三种只校验收到的清单文件大小不校内容。有些团队会用“文件大小是否大于 100KB”来判断清单是否合法这种在弱网时下载了半截文件很容易误判但在恶意攻击面前毫无防御力。正确的校验顺序应该是这样先通过 HTTPS 下载远程清单 → 校验签名非对称签名比如 RSA 或 ECDSA→ 解析出每个 AB 包的哈希列表 → 再用这个哈希列表去校验单个 AB 包。注意顺序不能反如果先解析再校验签名就存在解析逻辑被注入的风险。2.2 本地缓存在清单对比里的位置远程清单校验通过后客户端会拿它跟本地版本记录对比决定哪些 AB 包需要重新下载。这里有个隐蔽的坑本地版本记录本身也可能被篡改或者因为上次更新中途崩溃记录文件停留在“半更新”状态。我排查过一个线上问题玩家反馈“每次打开都是重新下载全部资源”。查了一圈发现本地版本记录文件根本没写成功每次启动远程清单都判定所有资源需要更新。这就是典型的“本地缓存状态不可信”问题。解决思路是不要直接信任本地记录文件把它当成“乐观缓存”启动后完整拉一次远程清单用清单里的哈希列表重新核算每个本地已存在 AB 包的哈希不一致的才重新下载。虽然会多花一点时间做本地哈希计算但换来的可靠性很值。如果你有几千个包每次全量哈希计算太慢可以退一步对每个 AB 包维护一个 meta 文件里面记录它对应的远程版本号启动时按 meta 决定哪些包需要重新校验。2.3 清单哈希校验的代码实现示例下面这个示例是我在实际项目里用的简化版重点看校验逻辑的顺序不要直接抄代码。public IEnumerator DownloadAndVerifyAB(string bundleName, string remoteUrl, string expectedHash) { // 第 1 步下载到临时文件 string tempPath Path.Combine(Application.temporaryCachePath, bundleName .tmp); yield return DownloadToFile(remoteUrl, tempPath); // 第 2 步计算文件 SHA256 string fileHash ComputeSHA256(tempPath); if (fileHash ! expectedHash) { Debug.LogError($AB 包校验失败{bundleName}, 期望 {expectedHash}, 实际 {fileHash}); File.Delete(tempPath); yield break; } // 第 3 步把校验收到的文件移入正式缓存目录 string storePath Path.Combine(Application.persistentDataPath, bundleName); if (File.Exists(storePath)) File.Delete(storePath); File.Move(tempPath, storePath); // 第 4 步更新本地记录 UpdateLocalVersionRecord(bundleName, expectedHash); }这段代码的核心都在第 2 步下载下来的文件必须先经过哈希校验才能进入缓存区。如果哈希不匹配直接删除临时文件不要留任何半截数据。注意点有两个一是临时文件目录和正式缓存目录要分开避免下载过程中客户端崩溃把半截文件写进缓存二是哈希算法要统一打包端用什么生成哈希客户端就用什么算否则永远校验失败。3. CDN 侧安全排查边缘节点、回源策略与版本回滚3.1 边缘节点缓存导致的脏清单问题CDN 边缘节点缓存是热更新排查里最让人头痛的一块。很多团队在本地测试一切正常一上线就出现“部分玩家更新失败”原因就是 CDN 边缘节点的缓存策略不对。举个例子你把新版本的 version.json 和 AB 包传到源站但 version.json 的 URL 路径没有变化CDN 边缘节点上还存着旧版本的内容玩家请求时直接被边缘节点命中旧文件客户端拿到的还是旧清单。更麻烦的是AB 包文件名经常带版本号如 character_1001.bundle如果你升级后改了包名旧路径的缓存不会影响新请求但如果你的 AB 包文件名固定比如 common_ui.bundle 每次覆盖边缘节点的旧缓存就会持续命中。排查 CDN 侧问题时我一般这么做先用 curl 模拟客户端请求看返回的响应头Cache-Control、ETag、Last-Modified 是最重要的三个字段对比源站文件头信息与 CDN 返回的头信息确认边缘节点是否回源手动在 CDN 控制台刷新 version.json 和关键 AB 包的缓存再让测试机重新下载验证。curl -I https://your-cdn-domain.com/version.json # 重点看 # HTTP/2 200 # cache-control: max-age600 # etag: abc123 # last-modified: Wed, 21 Aug 2024 10:00:00 GMT如果 cache-control 的 max-age 太大version.json 这类高频变化文件就会被缓存很久。建议对清单文件设置较短的 max-age甚至设置为 no-cacheAB 包本身因为文件名带哈希或版本号Cache-Control 可以相对宽松但也要设置一个合理上限。实际项目里我习惯把所有跟“版本判断”相关的配置文件和清单文件强制 no-cache让客户端每次都回源拿最新内容。3.2 文件压缩与二进制完整性一个很容易踩的坑是 CDN 对 AB 包做了内容压缩。AB 包本身就是压缩过的二进制CDN 再套一层 gzip 或者 br 压缩客户端下载时如果继续走标准下载流程解码后会拿到损坏文件。我在排查“AB 包加载失败”问题时遇到过这种情况客户端下载普通文本类文件一切正常只有 AB 包加载时一直报 “Unable to read bundle”后来用 curl 看了响应头才发现CDN 对 .bundle 文件自动做了 Content-Encoding: gzip 处理Unity 的 AssetBundle.LoadFromFile 拿到的是双重压缩的垃圾数据。解决办法有几种配置 CDN 时对 .bundle、.unity3d、.ab 这类后缀的响应禁用压缩客户端下载器在收到流式数据时如果响应头有 Content-Encoding 字段改为用对应解压方式解码后再写入文件更稳妥的方案AB 包上传时改名带一个随机后缀比如 .bytesCDN 默认不压缩未知后缀能绕开自动压缩问题。后两种实际上都用过第一种需要 CDN 服务商配合改配置第二种改造下载器第三种治标但不影响实际加载逻辑Unity 加载 AB 包时可以指定任意扩展名。我个人建议优先级是配置 CDN 改造下载器 改名。3.3 版本回滚与请求路径的坑CDN 还有一个常见问题线上版本回滚后旧客户端请求新资源路径。比如线上发了 1.2.0玩家都已经更新到 1.2.0 的本地缓存此时你发现严重 BUG 回滚到 1.1.0 的清单但 1.2.0 玩家本地缓存的 AB 包哈希是新的1.1.0 清单里的哈希是旧的客户端本地文件明明完好却因为哈希对不上反复触发“重新下载”。这其实不是 CDN 的错而是版本策略问题。排查时要特别注意回滚不能只回滚远端清单要考虑客户端本地缓存与远端清单不一致时是否主动清除受影响模块的缓存AB 包 URL 尽量带版本号目录比如 /ab/1.2.0/common_ui.bundle回滚后旧客户端不需要访问新版本路径也不会命中错误缓存客户端本地版本记录文件写入要遵循“先写临时文件再原子替换”策略避免中途被杀进程导致记录损坏。这个坑我踩过一次之后每次设计热更新策略都会把“回滚路径”单独列一节而不是只考虑发新版。4. 本地缓存排查目录结构、脏数据与防篡改4.1 各平台的缓存目录差异本地缓存排查很多人一上来就找代码问题忽略了一个很基础的事AB 包到底被写到了哪个目录。Unity 提供几个 API用途完全不同Application.persistentDataPath 是持久化目录系统不会随意清理Application.temporaryCachePath 是临时目录系统在存储空间不足时会清StreamingAssets 目录是只读的只能放首包资源不能做热更新缓存写入。很多团队图省事把热更新包直接写到 temporaryCachePath玩到一半资源就被系统清了下一启动全部重新下载。另外注意部分引擎适配的微信小游戏等平台持久化路径不是 persistentDataPath而是小游戏环境特有的存储目录。排查时如果发现某些玩家缓存总是丢先确认你的路径 API 有没有做平台差异化处理。我在项目里统一封装了一个 CachePathManager启动时检查各目录是否存在不存在或不可写就降级处理并上报日志避免在未初始化目录就执行写入。4.2 本地缓存损坏的典型表现AB 包写入本地缓存后即使校验通过后续也可能损坏。我总结出本地缓存异常的四类典型表现资源加载闪退往往不是逻辑 BUG而是缓存 AB 包被截断加载到后半段抛异常特定机型白屏低内存机器写入 AB 包时系统触发清缓存文件写了一半被杀进程热更新反复下载本地文件存在但哈希对不上每次都触发重新下载新包加载旧资源本地缓存里有同名旧包新包写入失败加载时仍然命中旧文件。造成这些现象的根本原因集中在三个点断电/杀进程导致文件写入中断、多线程并发写同一文件、设备存储空间不足。4.3 本地缓存完整性校验的注意点本地缓存的校验逻辑不应该只是下载完那次校验。我建议在每次加载 AB 包之前对关键模块做一次增量校验。这里有个性能取舍加载前全量哈希计算每个 AB 包都算一次小包没问题大包几百 MB 会有明显卡顿只校验关键资源配置表、脚本、热更新代码每帧或每次进入特定玩法前校验影响小按“模块版本号”校验AB 包打包时把版本号嵌入文件名或 meta 文件加载时先看版本号再决定是否需要完整校验。防篡改方面单纯用 SHA256 校验只能防传输过程中被改动不能防“恶意攻击者故意替换本地缓存文件”。因为你本地存了哈希攻击者可以把哈希一起改掉。要真正防篡改建议对关键 AB 包做加密或把哈希列表单独加密后存储。最常见的做法是本地只存加密后的哈希列表启动时用密钥解密再参与校验密钥不硬编码在客户端而是从远端获取并做 RSA 签名验证。这样攻击者即使替换了 AB 包和本地哈希也无法生成合法的加密哈希。5. 实战问题排查四个真实案例5.1 案例一清单校验失败但日志没有异常现象部分玩家热更新时下载进度到 100%日志没有报错但版本号一直停在旧版本。排查过程先看 CDN 返回的 version.json 内容发现 max-age 设置为 86400玩家第一次请求后边缘节点缓存了一整天第二天启动时客户端拿到的还是昨天的清单。而 AB 包下载实际上已经用新清单把包拉下来了本地版本记录却没有更新因为清单本身就错了。处理CDN 上把 version.json 的缓存策略改成 no-cache客户端侧对远程清单请求强制带时间戳参数比如 version.json?t当前时间戳绕过边缘缓存。5.2 案例二更新后旧资源仍被加载现象热更新提示成功但玩家看到的模型还是旧版本只有清缓存才能恢复。排查过程本地缓存目录里有同名旧 AB 包新包下载后写入失败原因可能是文件被占用或者写入时杀进程导致半截文件残留。加载逻辑读的是固定路径旧文件还在就优先加载了旧文件。处理下载完成后先写入临时文件校验完整再替换目标文件替换前确认没有其他协程在加载同一个包保留一份“已加载文件句柄”清单避免加载与更新同一个包时产生竞态。5.3 案例三下载 100% 但加载白屏现象AB 包下载成功、哈希校验也通过但加载后场景丢失UI 全面白屏。排查过程哈希校验用的是打包机上的 MD5客户端下载器算的也是 MD5本地测试一致线上却失败。后来发现 CDN 对 .bundle 文件自动做了 gzip 压缩客户端把压缩数据写入缓存后算出的 MD5 与源站不一致。在下载器层面对比请求与响应头才发现问题。处理CDN 配置中对 AB 包后缀关闭压缩同时下载器增加对 Content-Encoding 的解码双保险。5.4 案例四包体被修改导致校验通过率下降现象玩家反馈更新成功率骤降部分包一直提示校验失败。排查过程AB 包从 CDN 下载后哈希与清单不一致最初怀疑 CDN 缓存或源站文件被污染后来发现是包体上传时没有做二进制校验上传工具在弱网中断后重传源站被放了一个半个文件上去。客户端下载回来内容不同自然校验失败。处理上传工具增加“上传后回读 MD5”步骤CDN 源站给 AB 包目录开启刷新验证客户端下载失败超过三次自动上报文件名方便定位是哪些资源持续异常。6. 常见问题速查表与巡检脚本6.1 快速定位排查表下面这个表是按我自己的排查经验整理的遇到热更新异常时先对号入座异常现象优先怀疑点排查手段常见解决版本总停留在旧版version.json 被边缘节点缓存curl -I 看响应头、控制台手动刷新配置 no-cache请求带时间戳AB 包下载失败率极高CDN 对二进制自动压缩看响应头是否有 content-encoding配置关闭压缩下载器解码支持更新成功但加载旧资源本地缓存文件残留、写入替换失败检查 persistentDataPath 文件时间戳临时文件替换加载互斥启动反复下载全部资源本地版本记录损坏或未被写入检查本地记录文件是否存在写临时文件后原子替换部分机型白屏闪退下载半截文件被写入正式缓存加载前校验哈希临时目录隔离失败自动删除临时文件重试更新成功率骤降源站文件损坏或上传中断回读源站文件哈希上传工具增加回读校验玩家修改本地资源绕过校验哈希列表被一并篡改检查本地哈希是否加密存储哈希列表加密RSA签名6.2 一键巡检脚本核对 CDN 与本地缓存一致性排查多个环境时人工比对太慢。我写了一套很简单的巡检脚本部署在任何一台开发机上就能用。第一步是用 Python 模拟客户端请求 CDN 的 version.json并校验响应头第二步是遍历本地缓存目录计算所有 AB 包的哈希。import hashlib import os import requests def check_remote_version(url): resp requests.get(url, timeout10) print(状态码:, resp.status_code) print(Cache-Control:, resp.headers.get(Cache-Control)) print(ETag:, resp.headers.get(ETag)) print(Content-Encoding:, resp.headers.get(Content-Encoding)) print(Body前64字节:, resp.content[:64]) # 期望 content-encoding 为空如果出现 gzip 需排查 if resp.headers.get(Content-Encoding): print(警告响应被压缩检查 CDN 是否对 AB 包误开压缩) def hash_file(path): h hashlib.sha256() with open(path, rb) as f: for chunk in iter(lambda: f.read(64 * 1024), b): h.update(chunk) return h.hexdigest() def scan_local_cache(root): for dirpath, _, filenames in os.walk(root): for name in filenames: if not name.endswith(.tmp): full os.path.join(dirpath, name) print(f{full} SHA256:{hash_file(full)}) if __name__ __main__: check_remote_version(https://your-cdn-domain.com/version.json) scan_local_cache(./cache_root)这套脚本没办法直接在生产环境跑但排查问题时会非常高效。我先跑远端响应头检查再对比本地哈希基本能筛掉 80% 的“更新异常是不是网络/缓存问题”的日常工单。6.3 巡检时机的经验巡检不是出事才跑我习惯把它挂在持续集成流水线里每天凌晨跑一次远端清单与测试机缓存的对比一旦发现不一致当天上班就能看到报告而不是等玩家闹翻了才排查。7. 热更新安全加固建议7.1 HTTPS 不是万能药很多人觉得上了 HTTPS 就安全了这个是误解。HTTPS 解决的是传输过程中的窃听和篡改但解决不了这几个问题CDN 源站被拖库后文件被替换、客户端本地缓存被恶意修改、版本回滚攻击、AB 包被脱壳后提取资源。所以 HTTPS 只是地基不是全部。哪怕是 HTTPS 通道也要对关键清单做签名校验原因很简单CDN 账号泄露或被内网渗透的情况下直接放到源站上的假文件也可以正常通过 HTTPS 传输给玩家。7.2 签名验证与关键资源加密热更新安全加固里我建议按这个优先级做清单文件做 RSA 签名客户端内置 RSA 公钥每次更新前验签关键 AB 包做 AES 加密密钥通过验签后的清单下发清单里的 AB 包哈希强制使用 SHA256不用 MD5本地哈希列表单独加密存储密钥不要写在主包代码里。AB 包全量加密会有性能损耗。经验做法是只加密配置表、Lua/IL 脚本、本地逻辑相关资源图片、模型、音频这类纯表现资源不加密只做哈希校验性价比最高。如果项目有反外挂需求再考虑对表现资源也做加密但加载性能要做好测试。7.3 版本回滚保护与更新策略优化版本回滚保护是容易被忽略的一环。攻击者用旧版本清单覆盖本地客户端就退回到有已知漏洞的版本。防止这种攻击最简单的办法是客户端本地维护一个“最低允许版本号”低于这个版本的清单直接拒绝更新。更新策略上我建议把资源分成“基础资源区”和“玩法资源区”。基础资源区是进入游戏必需的更新策略保守玩法资源区按入口触发更新比如进入某玩法前才去拉对应 AB 包。这样做的好处是单次更新包体变小弱网环境成功率大幅提升排查问题时也能更快定位是哪个模块出问题。最后再分享一个经验热更新安全排查要有完整日志链路。客户端对每次下载请求、校验结果、缓存写入、加载失败都要打日志并且日志要定期上报到服务端。不然线上出了事故你手里只有玩家一句“加载失败”连是不是 CDN 问题、是哪一步失败都无从判断排查就成了猜谜。我个人的习惯是把“热更新链路日志”当成和崩溃日志同等重要的数据来对待任何一次异常都不能只留在玩家设备上。
返回列表