最近我排查了几个用 AssetBundle 更新资源的 Unity 项目。APK 里的代码保护做得很重,符号、字符串、反调试一个不少;到了热更目录,新角色、新皮肤和活动配置却还是标准 Bundle。代码层守得很严,更新资源沿用的还是默认分发方式。
抓一次更新请求,资源清单、版本号和 CDN 路径基本齐了。首包花了不少成本防逆向,版本一更新,最有价值的新内容又从另一条链路漏了出来。

顺手说明一下,AssetBundle 是 Unity 的资源打包和加载格式,版本检查、下载、缓存和回滚通常由自研框架或 Addressables 负责。开发中习惯把整套流程叫“AB 热更新”,排查时我还是会把构建、分发和本地加载分开。
先从清单和 CDN 看起
我一般先看版本检查。自研框架可能拉 AssetBundleManifest、JSON 或二进制版本表;Addressables 会检查远程 content catalog。格式不同,里面通常都会留下 Bundle 名称、版本、依赖或下载位置。
有些项目的路径也很规整:
https://cdn.xxxgame.com/hotfix/{channel}/{version}/{bundleName}.ab
channel 和 version 在请求里,bundleName 在清单里。URL 长期有效、没有鉴权和限速时,遍历清单就能批量下载。甚至不用先拆 APK,代理里拿到一次完整更新流程就够了。
看见 CDN 地址没什么,客户端本来就要用它。要紧的是文件拿回来仍是标准 UnityFS,可以直接放进 AssetStudio 一类工具。文件名改成 hash,只是没那么好认;签名 URL 能限制链接滥用和批量抓取,但分析者仍可在有效期内保存合法客户端拿到的文件。最后还是要处理 Bundle 本体。
新 Bundle 加了密,旧目录和缓存呢
堵住当前版本以后,我会接着翻历史目录。灰度、兼容和紧急回滚都需要保留旧资源,但早期 Bundle 未必补做过加密。路径规律没变,往前试几个版本号,可能就找到同一资源的明文版本。
然后看本地缓存。资源从 CDN 下载的是密文,客户端解密后又把标准 Bundle 写进 Application.persistentDataPath,前面的处理等于只管了传输。Android 11 以后应用专属目录的隔离更严格,但调试环境、运行时注入和已取得设备控制权的场景仍然要算。目录权限解决不了资源本身的可读性。

密钥也别全套 Bundle 共用一个固定值。我更倾向按 Bundle 和版本派生工作密钥,再用带认证的加密模式处理文件:
// 伪代码:每个 Bundle 使用独立上下文、salt 和 nonce
byte[] keyContext = UTF8($"{bundleName}:{version}");
byte[] salt = SecureRandomBytes(16);
byte[] resourceKey = HKDF(masterKey, salt, info: keyContext);
byte[] nonce = SecureRandomBytes(12);
byte[] cipherText = AESGCMEncrypt(
resourceKey,
nonce,
rawBundleBytes,
associatedData: keyContext
);
salt 和 nonce 可以跟密文一起保存,nonce 不能在同一密钥下重复。单个工作密钥泄露后,影响范围会小一些。主密钥仍得藏好,否则派生多少层都只是多绕几步。
缓存最好保留密文,加载时再解。大型 Bundle 如果整包塞进 byte[] 后调用 AssetBundle.LoadFromMemory,峰值内存很容易上去,后面还得看分块解密、流式读取或自定义 Provider 能不能接进现有加载层。
Bundle 加了密,清单还要验
AB 资源加密后,我还会试两件事:替换本地缓存,以及回退到旧版本。只做 SHA-256 对比不够,如果清单没有签名,文件和 hash 可以一起换。
// 先确认清单来自可信发布端
if (!VerifySignature(manifestBytes, manifestSignature, embeddedPublicKey))
RejectUpdate();
BundleEntry entry = signedManifest[bundleName];
if (SHA256Hex(cachedEncryptedBundle) != entry.sha256)
RejectAndRedownload(bundleName);
if (entry.version < signedManifest.minSupportedVersion)
ForceRedownload(bundleName);
这段代码先验清单签名,再检查加密文件和最低版本。AES-GCM 的认证标签也必须校验。Unity 自带 CRC 用来发现文件损坏,AssetBundle hash 用来区分缓存版本,都替代不了发布端签名。
还有一条边界要留清楚:战斗结算、掉落概率、购买结果这类直接影响收益的数据,即使放在加密 Bundle 里,客户端也只能拿来展示或预计算,最终结果仍应由服务端判定。
加密强度得和更新体验一起算

AB 加密做重了,体验账马上就来。LZ4 Bundle 原本可以按块读取,外面套一层整文件加密,可能要先解完整包才能加载。Bundle 拆得太碎,清单、请求和密钥管理又会膨胀。这些问题在开发机上不明显,放到中低端设备上一起测才看得出来。
我不会给所有资源套同一档强度。新角色、付费皮肤和活动配置优先保护,通用音效可以轻一些。大 Bundle 按场景拆分或分块处理,历史目录跟着版本策略清理;加密放进构建流水线,免得每次发版都靠人记着补一步。
接入这套流程,前期要改构建脚本、加载层和密钥管理,确实会多花时间。省掉这部分,后面每次更新都要重新确认有没有明文 Bundle,出了问题还得补目录、换资源、重新发版。我检查这类项目时,最后都会再跑一次增量更新。首包安全只是当时的状态,热更之后还能不能保持,才是这套资源保护的实际成本。