跳转至

热更新包与资源验签

旧清单配新文件

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.dll
  • C:\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 上不同,不能一律用当前平台规则推断目标文件系统。对于压缩包里的符号链接,最好直接拒绝,不要尝试还原。还要限制条目数量、单文件解压后大小和总解压大小,避免一个包把磁盘写满。

原子切换

下载和校验都通过后,不要覆盖正在使用的目录。版本目录应当是不可变的:

content/
  versions/
    build-20261114-1/
    build-20261113-2/
  current.json
  previous.json

新版本先写进临时目录,完整校验后再写指针。指针文件只保存 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 配置下行为不完全一样,要在目标设备上实测。

切换前后都要保留至少一个可用版本。启动时:

  1. 读取 current.json,限制读取大小和字段格式。
  2. 确认版本目录存在,不是符号链接。
  3. 再次校验清单签名和关键文件。
  4. 校验失败时尝试 previous.json。
  5. 上一版也失败时回退内置版本。
  6. 记录失败阶段和原因,不要用同一个错误码覆盖所有情况。

不要在启动时删除所有旧版本。下载失败、解压失败或新版本加载失败时,旧版本就是唯一的恢复路径。清理应在确认新版本健康并保留回退窗口之后进行。

防回滚

旧清单也可能有合法签名。攻击者可以重放一个有漏洞的旧版本,或者让 CDN 返回旧目录。只检查签名,无法区分“旧但合法”和“现在允许”。

需要同时检查:

  • 服务器下发的 minimumSequence 或允许的 buildId 列表。
  • 清单的 expiresAt 和客户端版本范围。
  • 平台、架构和渠道是否匹配。
  • 本次下载会话是否有有效期和一次性 nonce。
  • 已撤销的 keyId 和 buildId 是否在拒绝列表里。

不要只依赖客户端时钟判断过期。玩家可以改系统时间,设备时间也可能不准。时间条件放在服务器判断,服务器签发的下载会话带过期时间,客户端只检查会话是否还在有效期内。

离线启动可以允许使用最后一次确认过的版本,但要有明确策略:

  • 记录最后一次服务器确认的时间和版本。
  • 设备时间只作为提示,不作为唯一依据。
  • 离线时长超过阈值后,至少限制交易、排行榜和联机。
  • 重新联网后立刻重新检查版本白名单。

客户端本地记录可以删除或修改,所以离线策略只能减少影响,不能证明版本绝对新鲜。

密钥轮换

私钥不要放进客户端、资源包或普通构建日志。签名机可以离线保存,构建系统只拿到签名后的产物。

密钥轮换至少要预留一个强制更新周期:

  1. 新客户端同时内置旧公钥和新公钥。
  2. 服务器开始使用新 keyId 签清单。
  3. 观察旧版本的下载和升级数据。
  4. 等支持旧公钥的客户端都过期后,再移除旧公钥。

如果私钥泄露,除了更换公钥,还要在服务器拒绝旧 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。
  • 解压拒绝越界路径、符号链接、超长路径和异常压缩比。
  • 指针替换使用目标平台支持的原子操作,失败时保留上一版。
  • 启动时再次校验指针和关键文件,不能只相信指针内容。
  • 服务器能撤销旧版本,客户端不在本地时钟上做唯一判断。
  • 下载失败、验签失败、解压失败、加载失败和初始化失败有不同原因码。
  • 新版本连续启动失败会熔断,不会每次都重复加载坏版本。

完整性校验不能保证客户端没有被改,但可以把“替换一个文件就能改游戏”变成“还要同时处理清单、签名、版本和服务器校验”。