热更新包与资源验签¶
旧清单配新文件¶
1.8.3 已经发布,CDN 的某个边缘节点还在返回 1.8.2 的清单,但新的 DLL 和 Bundle 已经上传。客户端按旧清单下载,文件名对得上,加载时却报类型缺失。也有相反的情况:清单是新的,文件还是旧缓存,哈希校验才会把它拦下来。
只用 HTTPS 不能解决这个问题。HTTPS 保护传输,不保证源站内容、CDN 缓存和本地文件一定是同一版,也不能防止已经拿到设备权限的人替换文件。1
热更校验要分别回答三个问题:
- 来源:这份清单是不是自己发布的?
- 内容:文件是不是清单里列出的那一份?
- 时效:这个版本现在是否还允许使用?
签名、哈希和服务器白名单分别处理这三件事。只做其中一项,都会留下空档。
签名对象是清单原始字节¶
清单描述版本、平台、依赖和文件哈希。签名必须覆盖清单的原始字节。
manifest.json 版本、平台、依赖、文件大小和 SHA-256
manifest.sig 对 manifest.json 原始字节的签名
payloads DLL、Bundle、配置等实际文件
常见错误是先把 JSON 解析成对象,再用另一套序列化器重新生成字节去验签。字段顺序、空白、数字格式和 Unicode 转义都可能变化,签名时和验签时已经不是同一段数据。正确做法是保存下载到的原始字节,直接验签,验签通过后再解析。
可以用 Ed25519,也可以使用 RSA。选择时看客户端运行时和工具链是否稳定支持:
- Ed25519 签名短,验签速度好,密钥管理简单。Unity 的旧 .NET 配置不一定自带实现,需要确认目标平台和包依赖。
- RSA 兼容性好,密钥和证书工具成熟,但签名和公钥更大。使用 SHA-256 和明确的填充方式,不要省略参数。
不要用 HMAC。HMAC 验签需要同一个密钥,把密钥放进客户端后,玩家可以拿到它并重新签名。客户端只能保存公钥。
下面是一个 RSA 验签例子:
using System.Security.Cryptography;
public static bool VerifyManifest(
byte[] manifestBytes,
byte[] signature,
RSAParameters publicKey)
{
using var rsa = RSA.Create();
rsa.ImportParameters(publicKey);
return rsa.VerifyData(
manifestBytes,
signature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
}
实际接入还要确认公钥格式。.NET 常用的 XML 参数、PKCS#1 和 SubjectPublicKeyInfo 不是同一种编码,平台之间也不能直接混用。
清单里要有什么¶
{
"schema": 1,
"sequence": 1842,
"buildId": "1.8.3.20261114.1",
"platform": "android",
"arch": "arm64",
"minClient": "1.8.0",
"maxClient": "1.8.9",
"keyId": "release-2026",
"payloadRoot": "builds/1.8.3.20261114.1/",
"createdAt": "2026-11-14T02:00:00Z",
"expiresAt": "2026-12-14T02:00:00Z",
"files": [
{
"path": "code/Game.Hotfix.dll",
"size": 842752,
"sha256": "a4f9..."
},
{
"path": "bundle/ui-activity.bundle",
"size": 1204832,
"sha256": "2c7e..."
}
]
}
sequence 用来表达发布顺序,buildId 用来定位构建,keyId 用来选公钥,payloadRoot 用来隔离不同构建的下载目录。不要把下载地址直接拼成 payloadRoot + path 后就信任结果,客户端仍要检查路径没有跳出允许目录。
清单里的文件路径使用固定的相对格式,例如 / 分隔、禁止 ..、禁止盘符和绝对路径。服务器生成清单时就拒绝异常路径,客户端解压和写盘时再检查一次。只在校验阶段检查一次不够,实际写文件的地方才是最后边界。
下载阶段先检查响应¶
验签发生在下载之后,但下载阶段就可能拿到错误内容。HTTP 200 不等于文件正确,CDN 可能返回登录页、错误页或压缩后的内容。
下载时要检查:
- HTTP 状态是否为预期状态。
- 响应是否被代理或 CDN 改写成 HTML。
Content-Length是否和实际写入字节数一致。- 文件大小是否超过清单声明和本地剩余空间。
- 下载是否在超时、断网或应用被切后台后正确取消。
- 临时文件是否只在完整写入后才改名。
如果希望边下载边计算哈希,读流和写文件要使用同一份字节,不要先把流读成字符串或经过文本转换。大文件建议直接写磁盘,避免整个文件进入内存。
Content-Type 可以作为辅助判断,但不能只靠它。很多 CDN 对二进制文件返回 application/octet-stream,也有的返回错误页时仍带 200。状态码、实际长度、清单大小和哈希要一起判断。
解压是文件写入边界¶
热更包常常是 ZIP 或自定义归档。除了文件内容,归档里的路径也要校验。
恶意或被篡改的包可能包含:
../../outside.dllC:\Windows\.../data/data/...- 超长路径、过多小文件、压缩比极高的文件
- 指向归档外部的符号链接
只检查字符串里有没有 .. 不够。路径分隔符、URL 编码、平台大小写和符号链接都可能绕过。
更稳的做法是把每个条目解析成绝对路径,再确认它位于目标目录之下:
private static string ResolveUnderRoot(
string root,
string entryName)
{
var fullRoot = Path.GetFullPath(root)
.TrimEnd(Path.DirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
var target = Path.GetFullPath(
Path.Combine(root, entryName));
if (!target.StartsWith(
fullRoot,
StringComparison.Ordinal))
{
throw new InvalidDataException("path_outside_root");
}
return target;
}
大小写比较在 Windows 和 Linux 上不同,不能一律用当前平台规则推断目标文件系统。对于压缩包里的符号链接,最好直接拒绝,不要尝试还原。还要限制条目数量、单文件解压后大小和总解压大小,避免一个包把磁盘写满。
原子切换¶
下载和校验都通过后,不要覆盖正在使用的目录。版本目录应当是不可变的:
新版本先写进临时目录,完整校验后再写指针。指针文件只保存 buildId 和必要的校验信息,不保存整个资源列表。
public static void CommitVersion(
string contentRoot,
string buildId)
{
var pointerDir = Path.Combine(contentRoot, "versions");
var tempPointer = Path.Combine(
contentRoot,
"current.json.tmp");
var finalPointer = Path.Combine(
contentRoot,
"current.json");
var json =
$"{{\"buildId\":\"{buildId}\"}}";
File.WriteAllText(tempPointer, json);
FlushToDisk(tempPointer);
AtomicReplace(tempPointer, finalPointer);
}
AtomicReplace 不是随便调用一个带 overwrite 的移动函数就能保证。Windows 上的 File.Replace、POSIX 文件系统上的 rename 在同一个文件系统内才有原子替换语义;Unity 的 File.Move(..., overwrite: true) 在不同平台和不同 .NET 配置下行为不完全一样,要在目标设备上实测。
切换前后都要保留至少一个可用版本。启动时:
- 读取
current.json,限制读取大小和字段格式。 - 确认版本目录存在,不是符号链接。
- 再次校验清单签名和关键文件。
- 校验失败时尝试
previous.json。 - 上一版也失败时回退内置版本。
- 记录失败阶段和原因,不要用同一个错误码覆盖所有情况。
不要在启动时删除所有旧版本。下载失败、解压失败或新版本加载失败时,旧版本就是唯一的恢复路径。清理应在确认新版本健康并保留回退窗口之后进行。
防回滚¶
旧清单也可能有合法签名。攻击者可以重放一个有漏洞的旧版本,或者让 CDN 返回旧目录。只检查签名,无法区分“旧但合法”和“现在允许”。
需要同时检查:
- 服务器下发的
minimumSequence或允许的buildId列表。 - 清单的
expiresAt和客户端版本范围。 - 平台、架构和渠道是否匹配。
- 本次下载会话是否有有效期和一次性 nonce。
- 已撤销的
keyId和buildId是否在拒绝列表里。
不要只依赖客户端时钟判断过期。玩家可以改系统时间,设备时间也可能不准。时间条件放在服务器判断,服务器签发的下载会话带过期时间,客户端只检查会话是否还在有效期内。
离线启动可以允许使用最后一次确认过的版本,但要有明确策略:
- 记录最后一次服务器确认的时间和版本。
- 设备时间只作为提示,不作为唯一依据。
- 离线时长超过阈值后,至少限制交易、排行榜和联机。
- 重新联网后立刻重新检查版本白名单。
客户端本地记录可以删除或修改,所以离线策略只能减少影响,不能证明版本绝对新鲜。
密钥轮换¶
私钥不要放进客户端、资源包或普通构建日志。签名机可以离线保存,构建系统只拿到签名后的产物。
密钥轮换至少要预留一个强制更新周期:
- 新客户端同时内置旧公钥和新公钥。
- 服务器开始使用新
keyId签清单。 - 观察旧版本的下载和升级数据。
- 等支持旧公钥的客户端都过期后,再移除旧公钥。
如果私钥泄露,除了更换公钥,还要在服务器拒绝旧 buildId。否则攻击者可以继续重放泄露前签过的包。
失败阶段¶
“热更失败”这个错误没有排查价值。至少要分开记录:
| 阶段 | 典型原因 | 客户端动作 |
|---|---|---|
| 清单下载 | 404、超时、HTML 错误页、缓存不一致 | 继续使用当前版本,后台重试 |
| 清单验签 | 公钥不匹配、签名字节不一致、字段被改 | 拒绝整份清单,不重试同一内容 |
| 版本检查 | sequence 太低、客户端范围不符、已撤销 | 请求服务器重新下发可用版本 |
| 文件下载 | 长度不符、断网、磁盘空间不足 | 删除临时文件,保留当前版本 |
| 文件校验 | 哈希不符、文件被替换 | 只重下失败文件,限制重试次数 |
| 解压 | 路径越界、压缩包损坏、文件过多 | 拒绝该包,记录原因 |
| 指针切换 | 文件锁、权限不足、掉电 | 读取旧指针,回退上一版 |
| 程序集加载 | 元数据版本不符、类型缺失 | 回退到兼容版本,上报哈希 |
| 入口初始化 | 配置缺失、网络初始化失败、UI 异常 | 健康检查失败,熔断该版本 |
日志至少带 buildId、platform、sequence、stage、reason、当前回退版本和程序集哈希。这样客服和开发才能判断问题是网络、发布,还是代码本身。
平台差异¶
- Android:安装包签名和热更资源签名分开处理。AAB 拆分后,不能假设所有资源都在一个 APK 里;分区存储、外部存储权限和低内存设备都要测。
- iOS:系统校验代码签名,但热更资源仍要自己的清单。注意商店审核对动态代码的边界,不要把“能下载”当成“能执行”。
- Windows:主程序、更新器和关键 DLL 都要签名。更新器替换正在运行的程序时要处理文件占用和回滚,签名不能只覆盖安装包。2
- macOS:除内容签名外,还要考虑公证、Hardened Runtime 和隔离属性。替换后是否还能启动,要在干净机器上测。
- 主机:优先遵守平台方的补丁、认证和内容审核流程,不要在游戏内绕开平台更新。
发布前检查¶
- 签名覆盖清单原始字节,验签后再解析。
- 公钥只放在客户端,私钥不在构建日志或资源包里。
- 清单有
keyId、sequence、平台、架构和客户端版本范围。 - 下载校验 HTTP 状态、实际长度、文件大小和 SHA-256。
- 解压拒绝越界路径、符号链接、超长路径和异常压缩比。
- 指针替换使用目标平台支持的原子操作,失败时保留上一版。
- 启动时再次校验指针和关键文件,不能只相信指针内容。
- 服务器能撤销旧版本,客户端不在本地时钟上做唯一判断。
- 下载失败、验签失败、解压失败、加载失败和初始化失败有不同原因码。
- 新版本连续启动失败会熔断,不会每次都重复加载坏版本。
完整性校验不能保证客户端没有被改,但可以把“替换一个文件就能改游戏”变成“还要同时处理清单、签名、版本和服务器校验”。