交付与更新

更新包,能往客户机写任何文件

构建三层防御阻止 OTA 恶意包注入:签名验证、解压边界、版本单调性。

OTA 在客户机上解包并替换文件,这是一条高权限路径。如果源被投毒或包被构造,后果是任意文件写入或目录穿越。我采用三层独立的防御机制,每一层都针对不同的威胁模型,且它们互相不可替代。

第一层:签名验证链

每次 OTA 的起点是 release manifest(描述什么版本、文件列表、发布时间)和一个 zip 包体。两者都用 minisign 签名,公钥内嵌在客户端里。验签失败立即终止,后续步骤都走不到。

这一层的目的是确保来源可信。即使后续步骤再严密,如果源头被投毒了,也挡不住。

信任链的完整性。签名验证看似简单,但细节决定能否拦住真实的攻击。曾在 dress rehearsal 时遇到这样的故障:

release manifest 的签名文件(release-manifest.sig)生成时缺少 publishedAt 字段。launcher 的验证代码强制检查这个字段——它不仅验证签名本身有效,还校验签名的 trusted comment 包含 publishedAt 且与 manifest 内容一致。这样设计是为了确保签名的上下文完整:不只验证"这个文件确实由 A 签的",还验证"这个文件在时间 T 由 A 签的"。

缺少 publishedAt 导致验签必失败:

[launcher] Stage 6 OTA check error: trusted_comment missing publishedAt

排查过程:看 sig 文件实际内容,trusted comment 只有 kind=release_manifest version=0.14.0 signedAt=...,缺少 publishedAt=...。跟踪 sign CLI 的代码,release-manifest 子命令调用 sign::sign_file 时 extra_fields 传空数组,没有 publishedAt。修复是给 sign 命令加 --published-at <RFC3339> 参数,从 manifest.json 的 publishedAt 字段抠出来。

这个故障说明了签名链的脆弱面:签名生成端和验证端的合约必须完全一致,否则合法的包也会被拒。反过来说,这个严格的检查正是设计想要的效果——它提高了被攻击的成本。如果攻击者改了包的内容,要伪造一个新签名,不仅要破解私钥,还要用 launcher 能接受的格式生成 trusted comment。

第二层:解压边界

即使签名合法,包的内容也可能被精心构造来攻击解压逻辑。这一层用四个独立的检查阻止两类常见攻击:zip bomb 和目录穿越。

解压大小限制

单个文件大小上限已经存在:4 GiB。但攻击者可以用多个小文件堆积,总解压体积溢出磁盘。N 个 sub-4GiB 的文件累积可以填满任意容量。这是 CVE-2003-1564 级别的威胁。

防御方法是加总解压上限:10 GiB。这个阈值的选择基于实际情况——完整的 release 包括 panel 应用、多个引擎、二进制工具、数据文件,当前实际体积远低于 10 GiB,留足了安全裕度。

大小检查的时机很关键:在解压前累加 entry 的 uncompressed size(从 zip header 读取),一旦超限立即 bail。这样不需要真正解压 10 GiB 的数据就能拦住攻击。当然,zip header 的 size 字段本身可能被伪造——攻击者可以声明 size=0 但实际写入数据。这靠第二个检查兜底:io::copy 的实际写入字节数仍受 zip stream 的物理限制,不会让你写出超过压缩数据本身的内容。

拒绝符号链接

zip 格式可以记录条目的 unix 权限模式,其中包括 mode bit 0o120000 表示符号链接。当前代码会调用 File::create(&dest) 写入条目,对于 symlink-mode 条目,zip 库不会真创建符号链接,而是当作普通文件写出——这是"意外安全"。

问题在于这种安全不是明确的。zip 库升级或 API 改变都可能破坏这个假设。防御方法是显式 reject:检查 entry.unix_mode() & 0o170000 == 0o120000,如果是 symlink mode 直接 bail。

if let Some(mode) = entry.unix_mode() {
    if mode & 0o170000 == 0o120000 {
        bail!("OTA_ZIP_SYMLINK_REJECTED: entry `{}` has symlink mode bit", entry_name);
    }
}

这样即使 zip 库在未来支持真正创建符号链接,launcher 也会拒绝。当前生产的 OTA 包不应该包含 symlink,如果未来需要支持,那是 spec 变更后的事,明确决策即可。

路径越界检查

这个已经存在——safe_join 函数拒绝 Component::ParentDir / Component::Prefix / Component::RootDir,防止路径遍历攻击。zip 的 entry name 比如 ../../etc/passwd 会被拒绝。

这些检查为什么互补

大小检查防止资源耗尽(存储满、内存爆炸);symlink 拒绝防止权限提升(比如创建指向系统敏感文件的链接);路径越界检查防止文件覆盖(比如解出文件到安装目录外)。单有其中一个都不够——大小检查无法阻止精心放置的 symlink 攻击,路径检查也无法阻止大量小文件堆积。

第三层:版本单调性

已安装的版本号不能倒退。这防止被强推旧的、已知有漏洞的版本。

检查逻辑在 version_ge 函数里:当前版本 >= 要求版本才允许降级逻辑执行。最初的实现用简单的数字拆分比较,后来改用 semver::Version::parse 以支持正式的语义版本号,包括预发布版本的顺序(1.0.0-rc.2 > 1.0.0-rc.1)和预发布 < 正式版的规则。

fn version_ge(current: &str, required: &str) -> bool {
    let parse = |s: &str| {
        semver::Version::parse(s.trim_start_matches('v'))
    };
    match (parse(current), parse(required)) {
        (Ok(c), Ok(r)) => c >= r,
        _ => {
            eprintln!("[launcher] WARN: version_ge parse failed");
            false  // 解析失败保守判定为 false,走 RunChain 重新 OTA
        }
    }
}

这一层防的是一种很具体的操作事故:手误把已撤回的旧版本重新发布。如果没这一层,客户机会被自动推回旧版本;有了这一层,旧包虽然验签合法、解压也通过了,但版本号检查会拒绝它。

为什么三层都必要

防御什么时候失效后果
签名验证私钥泄露任何内容都能合法签名,后续检查都白搭
解压边界被绕过zip bomb 填满磁盘、symlink 提权、文件覆盖
版本单调被绕过被推回已知有漏洞的旧版本

签名验证保证来源可信——这是前提。但"来自信任方"的包仍可能是:

  • 意外生成的恶意内容(比如临时文件被打进 zip)
  • 被中间人修改过(虽然验签会失败,但某些场景下可能有变量窗口)
  • 通过合法渠道发布的有漏洞的旧版本(这时签名完全合法)

解压边界独立处理包内容的风险,不依赖于包的签名状态。版本单调性独立处理版本回退风险。三个不同的威胁模型需要三层不同的防御。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。