1. 项目概述一次热更新安全隐患排查的完整复盘做 Unity 客户端开发的朋友应该都有体会AssetBundle 热更新方案上线容易但真正让它长期稳定跑起来靠的是细节。尤其是当你的游戏量级上来、CDN 节点分叉、本地缓存策略多样化之后隐患往往藏在链路深处——可能是一个未校验的清单文件也可能是一次被遗漏的缓存校验。这篇文章写的是我最近完整排查一遍 AssetBundle 热更新安全链路的过程从 CDN 的 manifest 清单一路查到本地持久化缓存把整条链路上可能被绕过、被篡改、被异常打断的环节全部梳理了一遍。适合谁看如果你正在用手写或半自动的 AssetBundle 热更新方案并且关心包体完整性、版本回退、缓存命中率、异常恢复这类问题那这篇内容可以直接作为排查手册参考。即便你用的是商业化热更新框架文章里的排查思路和测试手段同样适用——框架能帮你省事但没法帮你免除安全意识。整个项目排查下来核心目标就三个第一确认 CDN 下发的 manifest 清单和 AssetBundle 是否可信任第二确认本地缓存是否会被篡改或误判导致老版本被错误加载第三确认热更过程在各种弱网、断点、进程被杀的情况下能不能自洽。三个目标串起来就是一条从远端到本地的完整信任链。2. 热更新链路全貌先把风险面画出来再动手2.1 一条典型的热更新链路长什么样先把我排查的这条链路画出来大家对照一下自己的项目大概率八九不离十。客户端启动先从本地读取磁盘里记录的当前版本号。向游戏服务器请求版本信息拿到最新的版本号和 CDN 地址列表。逐一下载 CDN 上的版本清单文件manifest里面记录了每个 AssetBundle 的文件名、哈希值、大小、依赖关系。对比本地已有 AssetBundle 的哈希找出需要新增和更新的文件。从 CDN 下载这些 AssetBundle 到本地缓存目录。加载时通过 manifest 做依赖解析AssetBundle 加载进内存资源即可用。这条链路里最容易出问题的节点恰恰不是下载本身而是版本仲裁和信任验证。下载一个文件失败了可以重试但如果客户端错误地信任了一个被篡改的清单那整个热更流程就建立在错误地基上了。我在排查时画了张风险表格列了每个环节最可能出问题的点环节核心风险影响范围版本请求返回被劫持、回包被篡改误判最新版本下载异常包CDN 清单下载清单被替换、哈希不一致资源解析错乱加载崩溃AssetBundle 下载传输中断、文件损坏、大小不符加载失败黑屏闪退本地缓存存储目录被篡改、写入不完整加载错版本老资源被误删内存加载依赖缺失、重复加载资源丢失材质/模型异常2.2 为什么说 CDN 清单是整个信任链的锚点很多团队把精力都放在 AssetBundle 本身的加密和校验上却忽略了 manifest 清单才是那个锚。AssetBundle 本身是二进制的 Unity 资源包你可以对它做加密、做哈希校验、做分包处理但客户端第一步拿到的那个清单文件如果不可信后面做的所有校验都是白搭。打个比方AssetBundle 是快递包裹manifest 是快递单。快递单告诉你包裹里装的是什么、重量多少、应该发到哪个门牌号。如果快递单被篡改了包裹本身验得再仔细也没用——因为客户端会照着错误单据去解析错误包裹。具体到热更新场景manifest 至少要有三重能力第一哈希锚定。每个 AssetBundle 条目必须带独立哈希且清单整体也要有签名或哈希防止条目被增删改。第二依赖关系表达。Unity 的 AssetBundle 有显式依赖和隐式依赖清单必须能表达这些关系否则加载时会出现材质是好的但贴图是错的这种诡异问题。第三版本回滚容忍。当 CDN 上存在多个历史版本文件清单要能告诉客户端哪些文件是可用的哪些已经废弃避免客户端在断点续传时把一个过期文件当成最新文件用。这部分排查下来我的建议是manifest 的哈希校验不能只做一次下载前要做加载前也要做。哪怕多花几毫秒的 CPU换来的安全收益是实打实的。3. CDN 清单安全排查校验、字段解析与链路加固3.1 清单文件下载之后的校验顺序我在项目里对清单文件做了三个阶段的校验这里按执行顺序列出来。第一个阶段是传输层校验。CDN 一般会返回文件的 ETag 或 Content-MD5客户端在 HttpWebRequest 或 UnityWebRequest 请求完成后先把服务端返回的 MD5 和实际接收字节流算出来的 MD5 做一次比对。这个阶段能挡掉的是一类低级错误CDN 节点缓存了损坏文件、中间网络设备对文件做了截断、多节点回源时文件不一致。第二阶段是版本层校验。下载下来的清单文件内部会声明自己的版本号和所对应的资源版本号。客户端要把这份声明和服务器下发的期望版本号做交叉验证。这里有个很隐蔽的坑有些团队把版本号放在文件名里比如 version_1.0.3_manifest.bytes但 CDN 某些边缘节点会把同名的旧文件缓存很长时间。文件名版本相同但内容已经被静默改掉的情况我是真遇到过。第三阶段才是内容层校验。反序列化出清单数据后逐条校验 AssetBundle 条目的哈希是否和本地已下载文件匹配。这一步的意义在于CDN 上有一套文件本地也有一套文件两者交集之外的部分才是真正需要下载的。如果这里不做校验客户端会反复下载同样的文件导致热更新流量飙升、启动时间变长。提醒一点这三层校验不是串行执行就完了任何一层失败都应该有明确的处理分支。我的做法是失败之后降级到旧版本清单并告警而不是直接中断更新流程。毕竟 CDN 抖动是常态为了一个清单临时错误就把整个热更流程卡死用户体验损失太大了。3.2 字段解析的反序列化安全Unity 项目里manifest 文件常用 JSON、MessagePack 或二进制自描述格式。JSON 最直观但有一个容易被忽略的问题反序列化时如果字段匹配不上容易抛异常或者拿到默认值。我建议对 manifest 的关键字段做严格校验而不是直接信任反序列化结果。比如bundle 文件名如果出现../或者以/号开头的绝对路径必须直接拒绝。原因是 AssetBundle 加载接口一旦被传入非法路径在某些平台上可能造成越界读取或者非预期文件访问。同时对数值字段也要设上下限。bundle 的 size 如果为 0 或者超过 5GB基本可以断定这个清单是伪造的。依赖列表为空看起来正常但如果一个场景 AssetBundle 的所有依赖突然消失了加载时必然炸清单层面提前发现这些矛盾能省不少线上排查时间。清单加密这块我的态度是如果项目对资源保护有硬性要求那 manifest 必须启用加密或签名。网上的教程多半教你用一个固定密钥做 AES 加密实际项目中这个密钥至少要按渠道分包并且要支持远程下发替换。否则密钥一旦泄露加密形同虚设。3.3 CDN 回源与边缘节点的一致性排查CDN 本身也存在配置层面的安全盲区。我这次排查中发现部分边缘节点的缓存策略过于激进把 manifest 文件缓存了 24 小时之久。这在日常发行没问题一旦遇到紧急禁服或者灰度回滚就麻烦了——客户端拿到的还是旧清单用户无法感知版本回退。排查方法很简单在不同地区的网络环境里请求 manifest 文件对比响应头里的 x-cache、age 字段再手动在源站更新文件后观察边缘节点的缓存刷新耗时。如果一个节点超过 15 分钟还在回源旧数据就该检查 CDN 控制台的缓存规则配置了。此外我还做了一次 CDN 回源链路测试。用 curl 模拟带某固定 query 参数的请求在源站 Nginx 日志里观察是否每类请求都打到了源站特定路径。如果源站路径被人遍历过或者 query 参数没有参与缓存键计算恶意用户可以直接构造请求绕过 CDN 直击源站。源站一旦暴露热更新包的任何安全措施都白做。一个小细节值得分享在 CDN 的缓存键里加入版本号或者哈希参数比如 ?v20240512_sha1能很大程度上避免边缘节点缓存错乱。这个办法简单到不起眼但实际救过我一次——有次 CDN 节点故障导致部分用户拿到旧包加了这个参数之后缓存命中率恢复正常。4. 本地缓存安全排查存储结构、写盘完整性与加载顺序4.1 本地缓存目录结构设计与权限控制AssetBundle 的本地缓存不能简单地在 persistentDataPath 下建一个目录就完事。我在项目里使用的是三层目录结构第一层是版本目录存放当前激活版本和待激活版本的 manifest。第二层是 bundle 存储目录按版本号分子目录避免不同版本的文件互相覆盖。第三层是临时下载目录下载过程先写临时文件校验通过后再 mv 到正式目录。为什么要分版本目录因为热更新不允许把老版本文件就地覆盖。Unity 的 AssetBundle 加载有缓存机制如果运行时加载了一个正在被写入的文件轻则资源错乱重则直接崩溃。版本目录隔离后新版本下载期间老版本照常运行下载完成冷启动或者热切换时才切到新版本文件。权限控制上iOS 和 Android 的沙盒目录天然隔离基本不用担心外部直接写入但越狱和 root 设备做不到完全防护。我的建议是核心的 manifest 文件做一层异或校验或者追加一个自定义 header简单但有效。不要依赖文件扩展名隐藏游戏资源文件命名成 .bytes 或者 .dat 这种压根拦不住有经验的人。4.2 写盘完整性临时文件、rename 与持久化标记本地缓存最容易被忽略的安全问题不是黑客攻击而是写盘不完整。热更新下载到一半进程被杀、磁盘空间不足、文件系统 sync 异常都会导致缓存目录里躺着一个坏文件。我的写盘流程是全部字节流接收完成后再写临时文件临时文件写完做哈希比对比对通过用 File.Move 或者 File.Replace 替换正式文件最后在 meta 文件里记录该文件的版本号、哈希和写入时间。注意 File.Move 在同分区内是原子操作但如果正式目录和临时目录不在同一个文件系统上Move 就可能变成复制加删除中途崩溃时旧文件已被删新文件还没到位就变成文件丢失。务必保证两块目录在同一个父目录下。持久化标记这块很多项目只搞了一个内存字典重启之后全部丢失被迫重新下载一遍所有 bundle。我的做法是每次写盘成功后写一条轻量的本地数据库或者追加日志式的记录记录内容包含文件完整路径、大小、哈希、所属版本。下次启动时可以快速核对已经完整的文件直接跳过下载。4.3 加载顺序校验本地清单优先还是服务端优先很多人默认是服务端清单优先其实这么做踩坑概率不小。CDN 抖动时服务端清单拿不到客户端就把本地资源全部判死体验非常差。我的策略是本地清单优先服务端清单兜底。启动时先读取本地已激活版本清单直接加载本地资源进入主界面。在后台异步请求服务端最新清单如果版本号有更新再进入热更流程如果服务端请求失败保持本地版本继续游戏只是下次启动时重试。这样做牺牲了一点热更新的实时性但换来了启动稳定性和资源可玩性整体效果明显更好。不少商业框架也采用类似策略只是会把服务端优先作为可配置项打开。加载顺序上必须遵守依赖优先的原则。比如一个角色预制体依赖一个材质球材质球又依赖一张贴图。如果先加载角色预制体、再加载材质球在切场景一瞬间可能出现材质丢失表现为角色发灰或者模型闪一下。清单里把每个 bundle 的依赖列表排序加载时做拓扑排序这是基线要求。4.4 Manifest 的本地二次校验与防篡改措施本地缓存目录既然可能被篡改那本地 manifest 也要做校验。最简单的方案是在写盘时同时写入一份 HMAC 签名签名密钥存在于客户端代码中。重启之后读 manifest 先验签名签名不对就丢弃改用服务端清单。有朋友会问客户端代码里的密钥不是也能被提取吗确实能。所以密钥的价值是提高攻击门槛不是绝对防御。对资源安全性要求极高的项目建议把 manifest 签名校验放在 native 层用 C 做校验函数并做混淆难度会高很多但性价比一般。这个取舍根据项目的竞争强度和资源敏感度来定。我这次还专门验证了一个场景本地缓存里的 AssetBundle 被二进制修改之后Unity 加载时会怎样。实测结果是大多数情况下会报 AssetBundle xxx cannot be loaded because another AssetBundle with the same files are already loaded 或者解压时报错但有少量贴图资源能正常加载但显示花屏。这说明哈希校验不能只依赖 Unity 内部检查自己在加载之前把本地文件哈希和本地清单哈希做一次比对成本很低收益明显。5. 实际排查过程记录从复现到验证5.1 排查环境与工具准备我这次排查是在一个模拟小规模玩家的沙盒环境里做的。Unity 版本是 2021 LTSAssetBundle 用的 LZ4 压缩CDN 用的国内主流厂商客户端框架是自研热更 部分商业插件混合。工具方面主要用了三样东西自研的 manifest 解析工具用来查看本地缓存目录里的 manifest 字段和哈希值。一个简单的代理抓包工具用来模拟弱网和请求篡改。一个 PC 端路径扫描脚本用来发现正式的存储目录和临时目录之间的竞态问题。这套组合工具没有特别高深的但能把问题暴露得很彻底。5.2 典型问题一CDN 清单被边缘节点缓存污染复现路径是我先在源站发布了一个新版本的 manifest 文件但故意不做 CDN 缓存刷新。然后模拟一个位于边缘节点区域的新用户客户端发起的 manifest 请求命中了这个边缘节点拿到了旧版本文件。现象就是客户端反复请求同一个新版本的 AssetBundle 列表但下载清单之后对比发现版本号从来不变更新流程静默失败。排查过程是这样推的先看客户端日志确认热更请求确实发出了再看服务器访问日志确认源站没收到这些请求最终在边缘节点上发现命中缓存。解决方式是给 manifest 请求的 URL 加上版本号参数从根上绕开缓存混淆。5.3 典型问题二本地缓存写入过程中的进程被杀这个问题的发现是测试同学反馈的启动游戏时偶发性资源缺失且删除缓存重进就恢复。听起来像是缓存被清掉了实际上不是。复现方式下载 AssetBundle 到临时文件的过程中用 adb 命令直接 kill 应用进程。再次启动后临时文件没来得及 move 到正式目录meta 文件也没写入但本地 manifest 里已经登记了这个文件的存在。于是客户端校验本地文件时发现 manifest 说文件存在但磁盘上根本找不到文件直接把整个缓存判为不可信触发了大面积重新下载。用户看到的网络流量消耗暴增加载变慢。修复方式是调整顺序先写完整临时文件再写 meta 标记最后更新本地 manifest。这个顺序是硬性的不能反。5.4 典型问题三AssetBundle 依赖关系错误导致的加载崩溃还有一个在测试环境里搞了很久的问题更新之后模型加载正常但场景里的特效全部是紫红色的 Missing Shader。查了很久最终发现是 manifest 里特效 shader 的依赖列表是旧的新版本特效 bundle 引用了新 shader bundle但清单里还写着旧 shader 的依赖。复现时是清缓存后新下载问题必现。原因很明确本地缓存的数据没有被正确清理新下载的特效 bundle 和本地残留的旧 shader bundle 组合在一起Unity 加载 shader 时找到了旧版本但特效的材质引用了新版本的属性两边对不上。排查中用到了专门的依赖可视化小工具把 bundle 的依赖树完整打出来一眼就看出 shader 节点指向了错误版本。修复方式是在构建工具里增加了依赖变更检测检测到 shader 依赖发生变化时强制把涉及该 shader 的所有上层 bundle 版本号升级。6. 热更新安全整改清单照着抄就能用排查完成之后我把整改事项按优先级整理成了一张清单可以直接对标自己项目。6.1 高优先级项Manifest 必须带版本号参数请求URL 上加上版本号或者哈希阻止 CDN 缓存错乱。下载后的 manifest 校验必须串行做传输层 MD5 和内容层哈希校验任何一层失败都进入降级分支。本地缓存写盘顺序统一为临时文件 - 哈希校验 - move 到正式目录 - 写 meta 标记 - 更新本地 manifest。本地 manifest 必须存 HMAC 签名启动时校验校验失败引导去服务端重新下载。AssetBundle 加载前和本地清单里的哈希比对一次防止磁盘数据被篡改后加载进内存。临时目录和正式目录必须在同一个文件系统分区避免 File.Move 变成非原子操作。6.2 中优先级项加载 AssetBundle 时按依赖拓扑排序避免依赖资源未就位就加载上层资源。清单字段做严格反序列化校验文件路径非法直接拒绝size 超出合理范围直接拒绝。版本回滚时主动清理旧版本 bundle 文件避免新旧版本文件残余混用。CDN 缓存规则设置合理过期时间热更包发布时主动刷新 manifest 相关路径。对本地 manifest 里记录的文件启动时逐条做存在性校验缺失的文件自动进入待下载队列。6.3 低优先级项对高敏感资源开启加密存储密钥渠道化下发。在 native 层增加热更核心校验逻辑提高逆向门槛。统计热更下载失败率、缓存校验失败率、哈希不匹配次数作为后台监控指标。7. 常见问题速查表把排查中反复出现的几个问题做成速查表以后遇到可以直接定位。问题现象可能原因排查手段热更后无变化CDN 缓存了旧 manifest对比源站与边缘节点哈希随机黑屏或闪退本地缓存写盘不完整检查临时文件是否残留贴图花屏但加载成功本地文件被篡改加载前比对哈希角色发灰/材质丢失依赖加载顺序错误打印依赖树核对更新后老资源被清本地 manifest 与磁盘不一致检查 meta 文件与 manifest 记录启动流量消耗暴增缓存误判不可信核对 meta 标记与写盘顺序灰度回滚不生效CDN 缓存旧版本文件版本号参数强制刷新8. 用户端与后台协同安全排查不止是客户端的事这篇稿子主要篇幅都在讲客户端排查但实际项目中热更新安全是个前后端协同的活。服务器要有版本发布记录和灰度策略CDN 要有缓存刷新和日志查询能力客户端要有校验和上报逻辑。我在这次排查过程中重新梳理了一遍客户端上报字段建议各位至少上报以下内容当前版本号和目标版本号用于判断版本回跳。热更请求耗时、失败阶段清单/下载/校验/加载。本地缓存命中文件数和重新下载文件数用于判断缓存效率。哈希校验失败时的 source 字段区分是 CDN 问题还是本地篡改。后台看板展示这些指标异常的苗头就能在形成大范围线上事故之前被发现。我见过不少团队是线上爆了才去翻日志几乎每次都能翻出几周前就能发现的异常只是没人盯着看。最后再分享一个小技巧算是我在这次排查里收获最大的一条热更安全排查别只盯着能不能用要盯着能不能被误用。一个文件损坏了系统要不要崩溃一个清单被篡改了系统要不要静默接受这些边缘逻辑才是隐患藏身的地方。把这些问题一个个写清楚、测明白热更新这条链路的可信度才能真正建立起来。
阅读完成 · 觉得有帮助?