做 Unity 客户端的同学,基本没人能绕开 AssetBundle(AB 包)热更新这个话题。项目大了以后,热更链路就不再是“写个下载器、拉个 Bundle、加载就完事”这么简单:版本文件放哪、清单怎么校验、CDN 上的资源怎么防止被枚举、Bundle 落到本地之后会不会被篡改,每一个环节都可能被攻击者钻空子。我最近刚对一套线上项目做了从 CDN 清单到本地缓存的完整安全排查,踩了不少坑,也补了不少洞,这篇文章就把整条链路的排查思路、关键检查点和加固方案整理出来,给同样在做 Unity 热更新的朋友做个参考。
这篇内容适合谁看?适合手里已经有一版能跑通的热更系统、但还没认真做过安全评估的团队;也适合刚接手项目、被要求“查一下热更安全”但不知道从哪下手的同学。排查思路和代码片段都可以直接拿去复用,不需要你有很深的安全背景,只要能看懂 C#、用过 UnityWebRequest,就能跟着走一遍。
1. 热更新链路全貌:先从整体看安全边界
1.1 一次热更新请求的完整生命周期
要排查安全问题,第一步得把整条热更链路画出来,不能上来就埋头看代码。一次常规的 AssetBundle 热更新,从客户端启动到最终加载资源,大致会经历这么几个环节:
- 客户端启动,向 CDN 请求版本文件(比如 Version.txt、ResVersion.json)。
- 比较本地版本号与远端版本号,判断是否需要热更。
- 如果需要更新,请求资源清单文件(AssetBundleManifest 或自定义的 JSON)。
- 根据清单里的文件列表、大小、哈希值,对比本地已有缓存,生成待下载列表。
- 逐个或分批下载 AssetBundle 文件。
- 下载完成后写入本地缓存目录。
- 运行时通过 AssetBundle.LoadFromFile 或 UnityWebRequest 从缓存加载 Bundle。
我之前排查过一个外包项目,它的“热更系统”做得很简陋:版本文件只存了一个 int 版本号,清单是一份没有签名的 JSON,Bundle 文件名直接就是资源名加数字编号,CDN 上连防盗链都没开,客户端下载完 Bundle 之后也不做任何校验。这种系统不是“有没有安全问题”的问题,而是“已经被人摸过多少次”的问题。
如果看不清楚链路,排查就容易漏环节。比如只检查了下载流程,却忽略了版本文件本身可以被篡改;或者只校验了 Bundle 的文件大小,却忽略了清单文件里藏着的哈希值才是真正的信任锚点。所以我强烈建议:排查前先花半天时间,把从“请求版本”到“加载 Bundle”的每一条代码路径走读一遍,把涉及网络请求、文件读写、路径拼接、版本比较的地方全部列出来,再开始逐项验证。
1.2 攻击面分布与信任边界
整条热更链路上,信任边界其实可以分成三段,每一段都有独立的攻击面:
- CDN 分发段(服务器 → 客户端):攻击者可能通过枚举 URL 下载未公开的资源,也可能通过伪造 HTTP 响应,把假的版本文件或假清单下发给你。
- 客户端存储段(本地文件):用户设备上的缓存文件可能被 root / 越狱后的高权限工具篡改,也可能被备份工具拖出来分析。
- 客户端运行段(内存与逻辑):攻击者通过注入代码、修改 Assembly-CSharp、或用 hook 方式绕过校验逻辑。
这三段里,CDN 段的安全最容易被人忽略,因为很多客户端开发觉得“我上了 HTTPS 就够了”,但 HTTPS 只解决了传输加密问题,解决不了 URL 枚举、鉴权缺失、证书校验被跳过这些问题。而本地存储段的问题,本质上是“客户端不可信”的问题——代码和设备都不在你的控制范围内,你能做的不是让它绝对安全,而是让篡改成本高到对方觉得不划算。
打个比方,整条热更链路就像一套物流系统:CDN 是快递公司,清单文件是发货单,本地缓存是自家仓库,Bundle 是货物。如果你只检查货车有没有被掉包(HTTPS),却不管发货单是不是伪造的,也不管仓库门锁结不结实,那货物早晚要出事。下面几个章节,我就按这条链路,从 CDN 清单一直排查到本地缓存。
2. CDN 清单侧排查:从 URL 构造到鉴权配置
2.1 URL 可预测性与资源枚举风险
排查 CDN 安全,第一个检查点就是资源 URL 是否可预测。打个比方,如果你的 Bundle 地址长这样:
https://cdn.example.com/ab/10001/charactor_10001 https://cdn.example.com/ab/10002/charactor_10002那这个 URL 就完全可枚举。攻击者只要把数字从 1 加到 99999,就能把 CDN 上所有的 AB 包列表拿一遍。更严重的是,很多团队会把测试包、审核包、甚至内部开发版本都传到同一个 CDN 目录下,枚举之后等于把你项目的全部资源底裤都看光了。
我当时排查的那个项目就是典型例子:Bundle 文件名用的是“资源 ID + 版本号”,版本号是递增整数,目录结构就是 /ab/{version}/{bundleName}。攻击者只要抓一次包,看到 URL 规律,然后写个脚本遍历 version 从 1 到当前值,就能把历史所有版本的资源全部下载下来。这些历史资源里面往往藏着已修复的漏洞、未上线的功能、甚至是带调试信息的包体。
这里有一个取舍:热更 URL 要做到“不可枚举”,常见的做法有两种。第一种是路径中加入内容哈希或随机串,比如把 URL 改成/ab/{fileHash}/bundle_xxx,fileHash 是清单文件里登记的字段,攻击者没有清单,就无法构造出有效 URL。第二种是加入时间戳签名参数,比如/ab/bundle_xxx?auth_key=xxxx×tamp=xxxx,签名过期自动失效。
我自己的项目之后改用了一种组合方案:版本目录从纯数字改成 Git 提交哈希的前 12 位,Bundle 文件名改成内容 SHA-256 的前 16 位,同时 CDN 上开启鉴权。这样即使别人拿到了一个 Bundle 的 URL,也无法推导出其他 Bundle 的地址,因为文件名本身就是内容的哈希,没有对应内容的人根本算不出来。
2.2 CDN 鉴权与防盗链的落地配置
URL 不可枚举只能防“主动探测”,但防不了“有权限的人泄露链接”或“被抓包后重放”。所以 CDN 侧还要配鉴权。这里说的鉴权不是 HTTPS 那个证书,而是“这个 URL 是否允许被当前用户访问”的机制。我经常看到有些团队在 CDN 控制台里只看了一眼“免费 HTTPS 证书已开启”就觉得安全了,完全没看“鉴权配置”那一栏。
常见的 CDN 鉴权方案大概分三类:
| 方案 | 原理 | 强度 | 适合场景 |
|---|---|---|---|
| Referer 防盗链 | 校验 HTTP Header 里的 Referer 域名 | 低,Header 可伪造 | 临时应急,防外站盗链 |
| 时间戳签名鉴权 | URL 带 auth_key + 时间戳,CDN 节点校验签名 | 中,防重放,但密钥泄露就失效 | 常规热更资源 |
| 私有 Bucket 签名 URL | 由服务端生成临时有效 URL,客户端直接访问 | 高,配合最小权限策略 | 敏感资源、小范围测试包 |
如果你还在用 Referer 防盗链,我劝你赶紧换掉。Referer 是 HTTP Header 里的一个字段,客户端完全可以不发或者伪造,连 curl 都能轻松绕过。时间戳签名才是 CDN 防刷的基础配置,一般就是你在服务端把“URI + 时间戳 + 密钥”算一个 MD5 或 SHA-256 拼到 URL 后面,CDN 边缘节点收到请求后用同样的算法验算一遍,对不上就返回 403。这个方案配置成本很低,各大 CDN 厂商控制台都有对应的功能开关,只是很多 Unity 团队根本不知道有这回事。
还有一点容易被忽略:渠道隔离。如果你一个游戏发了国内安卓、iOS、海外几个版本,所有渠道都公用同一个 CDN 路径,那一旦某个渠道的包体被逆向拿到 URL 规则,其他所有渠道的资源都会暴露。更稳妥的做法是按渠道分目录或者分 Bucket,比如/ab/ios/、/ab/android/、/ab/tw/,然后针对每个渠道单独配置鉴权和流量限制。就算某个渠道爆了,也不至于一锅端。
2.3 证书校验与抓包视角下的链路核查
接下来是 HTTPS 证书校验。我排查过不少项目,UnityWebRequest 请求时certificateHandler直接返回true,注释写着“为了开发方便先跳过证书校验,上线前会改”。结果上线两年了都没改。更离谱的写法是写一个公共的 CertificateHandler,不管请求哪个域名都直接放行。
跳过证书校验意味着什么?意味着只要攻击者能让你手机连上他的 Wi-Fi,或者在你和 CDN 之间插入一个中间设备,他就能伪造一个 HTTPS 证书,你的客户端会因为“校验通过”而毫无防备地接收他伪造的版本文件和清单,然后下载他指向的恶意资源。这比 URL 枚举更危险,因为伪造版本文件和清单可以直接控制你的客户端行为。
正确的做法是:不要设置 certificateHandler,让 UnityWebRequest 走系统默认的证书校验;如果确实有些内部测试域名没有配置合法证书,那就只针对那几个特定域名做校验,其他域名一律走默认逻辑。代码上大概是这个感觉:
public class CustomCertificateHandler : CertificateHandler { private readonly string[] allowedHosts = { "internal-test.example.com" }; protected override bool ValidateCertificate(byte[] certificateData) { // 只对内部测试域名放行,其他域名必须走系统校验 return allowedHosts.Contains(currentHost); } }另外,排查链路时我喜欢用 Charles 或 Fiddler 抓一遍包,主要看三样东西:版本文件请求是否走 HTTPS、响应头里的 Cache-Control 是不是被 CDN 缓存了太久、URL 上是否带了可枚举的规律参数。这里多说一句:抓包工具看的是“你的设备当前流量”,它不能代表攻击者的全部能力,所以抓包结果只能作为链路梳理的辅助,不能当作“我看不到问题就等于没问题”的依据。
3. 清单文件设计:签名、哈希与版本回退
3.1 清单文件里到底该放什么
清单文件是整个热更系统的“信任根”,它决定了客户端该下载什么、哪个文件该用什么哈希校验。但很多项目的清单文件设计得极其随意:有的就放一个 Bundle 名和文件大小,有的干脆直接用 Unity 自带的 AssetBundleManifest。先别急着改签名,首先要搞清楚清单里到底需要什么字段。
一份合格的资源清单,至少应该包含这几项:
- 版本号:当前资源版本,用于和本地版本比较。
- 文件列表:每个 Bundle 的相对路径。
- 文件哈希:每个 Bundle 的 SHA-256 值,下载后校验用。
- 文件大小:下载前判断是否需要断点续传。
- 依赖关系:Bundle 之间的依赖索引,用于加载时自动加载依赖。
- 最低客户端版本:防止老客户端拉取新资源后因为代码不兼容而崩溃。
我看到很多项目的清单文件只有文件名 + 大小,没有哈希。下载完 Bundle 之后只判断“文件存在且大小对得上”就算校验通过。这等于告诉攻击者:你只要把同名文件填够大小,客户端就能加载你的东西。真实世界里,我就见过有人把一个 DLL 或一段二进制伪装成同名 Bundle 塞进缓存目录,客户端照样 Start 了流程。
所以清单文件的核心不是“这个文件长什么样”,而是“这个文件是不是我预期的那个”。哈希值就是那个“预期”。哈希算法要用 SHA-256,别再用 MD5 了,MD5 的碰撞成本在 2017 年之后已经被压得非常低,虽然 AssetBundle 场景下构造碰撞没那么容易,但既然 SHA-256 实现成本几乎为零,没必要给自己留个把柄。
3.2 从 MD5 到 HMAC-SHA256 的校验演进
我见过一个项目,清单文件里有哈希,但是用的 MD5,而且哈希值是明文写在 JSON 里的。攻击者抓包拿到清单之后,先下载一份正版资源算好 MD5,再替换清单里的 hash 字段为自己的恶意文件的 MD5,客户端校验自然通过。这就是典型的“清单本身没有防篡改能力”导致的问题。
单靠清单里的哈希值不够,因为清单文件本身也可以被篡改。解决方案是给清单文件加一层防伪标记,常用的有三种强度:
| 方案 | 做法 | 安全性 |
|---|---|---|
| 明文清单 + 哈希字段 | 清单内每个文件带 SHA-256 | 只能校验单个文件,清单本身可篡改 |
| 清单文件带 HMAC | 密钥放客户端,用 HMAC-SHA256 计算清单校验值 | 能防静默篡改,但密钥可从客户端提取 |
| 清单文件带非对称签名 | 服务端用 RSA 私钥签名,客户端内置公钥验签 | 防篡改能力强,密钥不落客户端 |
真正线上项目,我建议直接上非对称签名,也就是第三种。原理很简单:服务端生成一份清单,用 RSA 私钥做签名,客户端内置 RSA 公钥,收到清单后先用公钥验签,验签通过才相信清单内容。因为私钥只存在于服务端,攻击者就算逆向客户端拿到了全部代码和公钥,也无法伪造一份能通过验签的清单。
具体实现不复杂。服务端用 OpenSSL 生成密钥对,私钥用于签名,公钥以 XML 字符串形式打进客户端。客户端验签的代码大致长这样:
public static bool VerifyManifestSignature(byte[] manifestBytes, byte[] signature, string publicKeyXml) { try { using (var rsa = new RSACryptoServiceProvider()) { rsa.FromXmlString(publicKeyXml); var hash = SHA256.Create().ComputeHash(manifestBytes); return rsa.VerifyHash(hash, CryptoConfig.MapNameToOID("SHA256"), signature); } } catch (Exception e) { Debug.LogError("清单验签异常: " + e.Message); return false; } }这里有一个细节容易踩坑:签名是对“原始清单字节”做哈希再签名,所以客户端拿到清单后,验签的数据必须和签名时用到的数据完全一致。如果服务端签名前做过 JSON 序列化,客户端解析之后再重新序列化一次,得到的字节数组很可能不一致。正确做法是客户端收到字节数组后先验签,验签通过后再反序列化成对象,不要让“反序列化再序列化”这一步介入验签过程。
3.3 版本回退与增量更新中的漏洞面
清单安全问题还有一个容易被忽略的点:版本回退。假设你线上版本是 1.0.1,后来发了一个 1.0.2 修复了一个严重的安全漏洞。攻击者抓包之后发现服务端返回的版本号是 102,他直接在本地把版本号改成 101,再伪造一份指向 101 资源的清单,客户端就会乖乖回退到旧版本,把已经修复的漏洞重新暴露出来。
为什么会有这种问题?因为很多客户端的版本比较逻辑写的是“如果远端版本号 != 本地版本号,就更新”,或者“如果远端版本号 > 本地版本号,就更新,否则忽略”。前一种写法会把“版本号回退”当作一次合法更新来执行;后一种写法虽然不会主动回退,但如果你把版本比较逻辑写在客户端,攻击者完全可以 patch 掉你的比较逻辑,强制走回退分支。
反制思路有这么几个:第一,版本比较逻辑必须由服务端统一控制,客户端只拉取“目标版本号”,由服务端决定当前客户端应该升到哪个版本;在服务端加一个“最小允许版本”字段,低于这个版本的客户端必须强制升级 App,不能走热更。第二,清单验签之后,检查清单里的版本号是不是比本地版本号新,如果发现版本号在回退,直接拒绝加载并把异常上报。第三,对增量更新要特别小心:如果你用“差异包”方式更新,攻击者只要篡改基础版本号,就能诱导客户端基于错误版本的本地资源去应用差异,大概率导致资源损坏,严重的可能被利用来做文件覆盖。
我之前就在增量清单上吃过亏。当时项目用的是 bsdiff 方案,清单里记录了 baseVersion(代表这个增量包基于哪个版本)和 targetVersion。攻击者把 baseVersion 改成一个不存在的版本号,客户端对比本地版本发现不一致,就重新全量下载。这虽然不算是严重安全问题,但这种异常流量会导致 CDN 成本暴涨。后来我们把 baseVersion 也放进签名内容里,一旦验签失败就直接不让客户端发起增量请求。
4. 本地缓存安全:沙盒、加密与篡改防护
4.1 缓存目录选型与权限边界
热更资源下载到本地之后,存放在哪里直接决定了本地篡改的难易程度。很多 Unity 项目喜欢用 Application.persistentDataPath,这个路径在 Android 和 iOS 上都是应用沙盒内的私有目录,正常情况下只有你的应用自己能读写,其他应用访问不到。方向是对的,但还有几个细节要检查。
第一个是别把缓存写到临时目录。有人图方便,直接把热更资源写到 Application.temporaryCachePath,这个目录的优点是读写快,缺点在于操作系统在存储空间不足时可能会自动清理它,而热更资源一旦被清理,客户端下次启动又得重新下载。更重要的是,临时目录的权限控制在某些定制 Android 系统上不如 persistentDataPath 严格,更容易被清理工具和高权限应用扫描到。
第二个是别把 AssetBundle 资源直接放到外部存储卡目录。国内安卓生态这个情况比较特殊,很多老项目为了兼容“安装包被移动到 SD 卡”的场景,会检测外置存储路径并写入。但外部存储目录的读写权限是开放的,任何应用都能读,攻击者想改简直不要太简单。如果你发现项目里用了/sdcard/Android/data/xxx这类路径,赶紧迁移到 persistentDataPath 里来。还有一个技巧:在 persistentDataPath 下在按“版本号哈希”建子目录,比如hotupdate/{versionHash}/,这样每次版本更新都会落到新目录,旧版本资源直接忽略,省去合法性判断的麻烦。
第三个问题是越狱和 root 设备。越狱设备上沙盒和文件权限基本形同虚设,用户可以直接读取和修改 persistentDataPath 下的所有文件。你没法完全阻止这类设备上的篡改,能做的只有两点:检测到 root 或越狱环境时禁用热更功能,或者强制走完整的签名校验流程,不信任任何本地文件。
4.2 Bundle 加密的收益与代价
第二个大问题是:AssetBundle 文件要不要加密?很多团队一听到“热更安全”就想着给 Bundle 加个 AES 加密,好像加密了就能万事大吉。但从实际效果看,加密是一把双刃剑:它能提高资源被提取和分析的门槛,但也会显著影响加载性能和开发流程。
先讲收益。AssetBundle 本质上是一个有特定格式的文件容器,工具链(比如 AssetStudio、UABE)可以一键解析出里面的模型、贴图、音频和文本。如果你的 Bundle 里存了尚未公开的游戏内容、或者带有敏感数值的配置表,不加密就等于在 CDN 上裸奔,谁下载谁看。加密之后,攻击者必须先在你的客户端里找到解密逻辑和密钥,才能还原出 Bundle,逆向成本明显提高。
再讲代价。AssetBundle 的 LoadFromFile 支持 LZ4 压缩格式的随机读取,可以直接从文件偏移加载某个子资源,不需要全量解压。但一旦你把整个 Bundle 文件加密了,Unity 的加载器就读不懂文件头了,你必须把文件全部解密到内存或临时文件,然后才能加载,这等于直接放弃掉了 LZ4 随机读取的优势。大 Bundle 的解密耗时在低端安卓机上能明显感知,一两个大型场景 Bundle 可能卡几百毫秒甚至一秒以上。
所以加密要分场景:对配置文件、文本资源这类小体量且含有敏感内容的 Bundle 做加密,性价比最高;对贴图、模型这类动辄几十上百 MB 的美术资源,加密带来的性能损耗很不划算,更推荐用 URL 鉴权 + 哈希校验保证完整性,而不是机密性。如果一定要加密大 Bundle,我建议用 AES-128-CTR 模式,配合加密后只按顺序读取的场景,性能损失会小一些,但依然避免不了首包解密耗时。密钥存储也是个坑,写到 PlayerPrefs 等于没加密,放到代码里也容易被静态分析挖出来,比较可行的思路是用 Android Keystore 和 iOS Keychain 存密钥,或者接入白盒加密方案。
4.3 缓存读写的完整性自检
本地缓存最后一道防线是完整性自检,也就是说每次加载 Bundle 之前,最好都确认这个文件没被人调包过。有人觉得下载时校验过一次哈希就够了,后面一直用同一个文件,没必要再校验。但用户的手机不是你的测试机,文件可能被存储工具清理、被杀毒软件误删、被 root 工具改动,更别说攻击者故意替换的可能性了。
我自己常用的方案是“下载时写哈希 + 加载时验 CRC”。下载完成后,用 SHA-256 对比清单里的哈希值,不一致直接删掉重下。加载时用 AssetBundle.LoadFromFile 的带参版本:
var ab = AssetBundle.LoadFromFile(cachePath, 0, crc);这里的 crc 不是实时算的,通常是打包时记录下来的 Bundle 内置 CRC 值,Unity 加载时会自动核对文件内容,对不上会返回空引用。我实测下来这个方法对“文件损坏”非常敏感,而且几乎不额外耗时,因为 CRC 校验是在文件层做的,Unity 内部已经优化过。唯一需要注意的是,Unity 不同版本对 CRC 的校验逻辑有差异,升级引擎版本之后最好跑一遍所有 Bundle 的 CRC 回归,不然可能出现“本地 CRC 能过,但线上失败率飙升”的尴尬情况。
还有一个埋点技巧:每次加载 Bundle 前,把 Bundle 名、文件大小、最近修改时间上报一次,建立基线数据。如果某个 Bundle 的加载失败率突然飙升,或者某个设备上文件修改时间异常频繁,这些数据能帮你快速定位是 CDN 资源损坏、客户端缓存被清、还是有人在故意刷你接口。
5. 实操复盘:一次完整的热更安全排查过程
5.1 排查前的信息收集与链路梳理
理论说了一大堆,下面用一个实战案例把整个排查过程串一遍。假设我现在接手了一个 Unity 项目,项目用 AssetBundle 热更新,资源放在 CDN 上,客户端逻辑用的是 UnityWebRequest 下载,本地缓存用的是 persistentDataPath。我要在一个月内把热更链路的安全底排查清楚。
第一步不是看代码,而是把链路“问清楚”。我会找后端要 CDN 控制台权限,看域名解析、鉴权开关、缓存配置;找客户端要热更模块的代码清单,把 DownloadManager、ManifestManager、BundleLoader 这几个核心类的代码路径列出来;再让运维导一份最近一周的 CDN 访问日志,重点看没有带版本号或带了异常版本号的请求。这一步的目的不是发现问题,而是确认“当前的信任模型是什么”——谁会触达 CDN、客户端信任哪些来源、哪些请求是合法流量。
第二步是画一张数据流草图,不用画多漂亮,但要把每个请求的 URL 模板、每个文件落盘的位置、每个校验逻辑的触发时机写清楚。我当时画完之后发现,这个项目的 Bundle 下载路径是/ab/{versionId}/{resId}.unity3d,versionId 是自增整数,resId 也是自增整数,两个都是可枚举的。还没开始抓包,安全隐患已经肉眼可见了。
5.2 逐环节验证与问题定位
链路梳理完之后,我按“CDN → 清单 → 下载 → 缓存 → 加载”逐环节验证,每个环节都做一次主动“破坏性测试”。
CDN 环节,我直接在浏览器里手动构造了几个 URL,把 versionId 从 100 遍历到 200,发现全部能正常下载,说明 CDN 没有配置任何鉴权措施。然后又用 curl 改了一个不存在的 Referer 头去访问,依然能下,说明防盗链也没开。这一步确认了第一个问题:CDN 资源裸奔,任何人知道 URL 规律就能拉全量资源。
清单环节,我找到客户端解析清单的代码,发现清单是纯 JSON,没有任何签名和哈希字段。文件列表里每个 Bundle 只记录了路径和长度。这意味着一旦中间人伪造清单,客户端完全无法识别。确认第二个问题:清单无签名,信任根缺失。
下载环节,我查了 DownloadManager,发现下载完成后没有做哈希校验,只有“文件大小一致就认为成功”。而且断点续传用的是 WWW 的持久化缓存机制,断点续传后连大小都不校验。确认第三个问题:下载完整性无保障。
缓存和加载环节,我看了缓存写入逻辑,发现 Bundle 文件直接明文写在 persistentDataPath 下,文件名和 CDN 上的文件名一致。加载时虽然传了 CRC,但 CRC 值是打包时随口填的 0,Unity 收到 0 就等于跳过 CRC 校验。确认第四个问题:本地加载不验完整性。
这一轮下来,基本上全链路每个点都有问题。说实话,这种“全裸”状态在中小型 Unity 项目里不算罕见,因为热更系统通常是在项目后期赶工时加的,开发周期一压,没人有精力做安全设计。
5.3 加固后的验证与回归
定位到问题之后,我做了一轮分优先级的加固:
第一优先:清单签名。服务端用 RSA 私钥对清单文件签名,客户端内置公钥验签。清单里每个 Bundle 增加 SHA-256 值,下载完成后强制校验。
第二优先:CDN 鉴权。在 CDN 控制台开启时间戳鉴权,同时把 URL 改成带路径哈希的格式,让攻击者无法通过枚举 URL 拿到资源。渠道隔离也顺手做了,不同渠道用不同目录前缀。
第三优先:加载校验。AssetBundle.LoadFromFile 的 CRC 参数改为打包时记录的真实 CRC 值。我还在加载前加了一道文件大小 + SHA-256 快检,因为纯 CRC 是 Unity 内部实现的,对主动篡改的检测能力终究是有限的。
加固完成后,我重新跑了一遍破坏性测试:伪造清单、篡改 Bundle 文件、把版本号改成旧版本、用 curl 裸下 CDN 资源。结果是:伪造清单直接验签失败,下载阶段直接拒绝加载篡改文件,回退被服务端的最小版本号拦截,CDN 裸下返回 403 鉴权失败。整个回归测试耗时一个下午,全部通过。
这里有一个经验:加固热更系统千万别一口气全上,不然出了问题都不知道是哪一环引入的。我当时的节奏是一周内先上清单签名,跑两天线上观察热更成功率,再上 CDN 鉴权和 URL 改造,再过一周才上本地加载校验。每一步都有灰度开关,可以通过服务端配置临时关闭,避免新逻辑出问题时阻塞热更。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
日常帮朋友和同行排查热更安全问题,我总结了一张高频问题速查表,直接照着查就行:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 版本文件被篡改后客户端照常更新 | 版本文件无签名、无哈希校验 | 给版本文件加 HMAC 或并入清单签名体系 |
| 抓包看到 Bundle 请求是明文 HTTP | 客户端 URL 写死了 http,或重定向到 http | 全局搜索http://,强制 HTTPS 并开启证书校验 |
| Bundle 偶尔加载失败,重进游戏又好了 | 下载不校验哈希,文件损坏但大小一致 | 下载完成后用 SHA-256 对比清单值 |
| 修改本地缓存文件后游戏内容被替换 | 缓存文件明文可读写,加载不验 CRC | Bundle 加密(分场景),加载前做完整性校验 |
| 新版本发布后老客户端报资源错误 | 新 Bundle 格式与老客户端代码不兼容 | 在清单里加最低客户端版本字段,做版本拦截 |
| CDN 流量突然暴涨 | URL 可枚举被人刷量,或客户端反复拉取同一个失败文件 | 开启 CDN 鉴权、限流,检查下载失败重试机制 |
这里面最容易被忽视的是最后一条。很多团队只知道看“热更失败率”,却不看 CDN 的访问日志,结果被刷了几 T 流量才发现问题。热更模块里所有请求都要有合理的重试策略,重试次数、退避间隔、失败上报都要有,不然攻击者制造一个永远失败的请求,就能让你的 CDN 账单发疯。
6.2 几个容易忽略的细节
最后分享几个我踩过的坑,都属于不亲自跑一遍线上很难发现的那种。
第一个坑:Unity 引擎升级之后,老 AssetBundle 的兼容性会变。很多团队打包管道的习惯是“引擎升级后重新打一次所有 Bundle”,但线上热更资源并不是你想重打就能重打的。已经发布到旧版本客户端的资源,如果新引擎打出来的 Bundle 加载方式变了,老客户端就会大面积加载失败。我见过一个项目升级 Unity 版本后,热更成功率从 99% 掉到 70%,就是因为新打出来的 Bundle 和老客户端的加载代码不兼容。这块要靠清单里的最低客户端版本字段来兜底,服务端发现客户端版本过旧时,直接提示升级 App,而不是强行发热更资源。
第二个坑:清单验签过了,不代表清单内容不会被业务层篡改。我在一个项目里看到,签名校验通过之后,开发同学为了“方便调试”,又写了一句“如果本地打开 Debug 模式,跳过签名校验”。这句话看起来无害,但只要测试包流到外部,就等于给攻击者开了一扇后门。调试后门在游戏热更代码里太常见了,排查时记得全局搜索Debug.isDebugBuild、#if DEVELOPMENT_BUILD、#if UNITY_EDITOR这些关键字的出现位置,确认它们没有被错误地用于安全校验分支。
第三个坑:CDN 缓存没刷新。你更新了 CDN 上的版本文件和清单,但由于 HTTP 响应头里的 Cache-Control 设置了 max-age=86400,CDN 边缘节点在一天之内还在给客户端返回旧版本文件。客户端看到版本没变,自然不会触发热更,运营就会来质问“为什么我发了新资源用户没反应”。这个虽然是“事故”不是“安全”,但如果版本文件被 CDN 缓存得够久,攻击者就有机会制造“永久旧版本”的中间人响应。所以版本文件一定要设置较短的缓存时间,建议 max-age 控制在 60 秒以内,或者直接在 URL 上加时间戳参数强制绕过缓存。
第四个坑:热更资源的加密和压缩组合,容易让性能回退得很隐蔽。我在 4.2 节提过加密会破坏 LZ4 随机读取,但第一次实际切换时,我还是被一个 80MB 的场景 Bundle 吓到了:加解密耗时加上临时文件写入,低端机加载直接卡了将近两秒。后来改用分块解密 + 按需加载子资源,才把加载时间压回到可接受范围。加密方案一定要在真机上用中低端设备做性能回归,别只在编辑器里测,编辑器里的 IO 速度完全不能代表真机。
我个人在实际排查中的体会是:热更新安全问题很少是“单点问题”,更多是整条链路都缺乏信任校验。与其纠结某一个环节用什么算法,不如先把“我能信任什么、我不能信任什么”划分清楚。CDN 上的内容可以被枚举和篡改,本地文件可以被修改,客户端的逻辑可以被逆向,这是三个绕不开的前提。接受这三个前提之后,再去看签名、鉴权、校验这些手段,你会发现每一样都是在“不信任”的基础上重建信任,而不是在“我已经很安全了”的幻觉上自我感动。这篇内容后续如果你要继续深耕,可以直接往“客户端注入防护”和“Unity IL2CPP 加固”方向延伸,但前提是先把 CDN 到缓存的链路扎扎实实补完——地基稳了,上面盖什么都安心。