自动更新的安全设计——为什么仅靠 HTTPS 还不够

· · Windows开发, 安全, updater, 自动更新, 签名, MSIX, ClickOnce

目录

  1. 先说结论
  2. 为什么自动更新是高危区域
  3. 常见的错误模式
  4. 最佳实践
  5. 最小安全架构
  6. Windows 项目该如何考虑
  7. 最低限度的检查清单
  8. 总结
  9. 参考资料

1. 先说结论

先列出实务中的落地要点。

  • 如果需求匹配,首先应优先使用 MSIX App Installer、ClickOnce 等现有的更新基础设施
  • 如果确实需要自研 updater,最先要做的不是 UI,而是签名验证与失败恢复
  • latest.json 这样的更新信息,应当作为经过签名的 metadata 来处理,而不是未签名的配置文件
  • TLS 是必要条件,但不是充分条件
  • 更新判定的依据不应是「服务器这么说」,而应是「客户端验证后确认为正确」
  • 将签名密钥按开发环境与生产环境分离,并用 HSM 或签名服务加以保护
  • 更新失败时应采用 fail-closed(失败即中止),而不是 fail-open(失败仍继续)
  • 没有防回滚措施的 updater,应当假设它随时可能被降级回存在漏洞的旧版本
  • 如果还无法引入签名验证,那么手动分发已签名的安装包,会比自动更新更安全

归根结底,自动更新的核心不在于「如何下载」,而在于「信任什么、在哪里验证、出问题时如何回退」。

2. 为什么自动更新是高危区域

普通的功能通常都封闭在应用内部。而 updater 则一次性集齐了以下三件事:

  1. 从外部获取文件
  2. 信任这份文件
  3. 替换现有的可执行文件

也就是说,任意代码执行的通道从一开始就内置在产品之中。

这里常见的误解是「因为是 HTTPS 所以就安全」。TLS 当然是必要的,但它主要守护的是通信链路和连接目标的正当性。如果更新服务器本身被攻破、错误的构建产物被放到了正规 CDN 上、或者未签名的 manifest 被替换,仅靠 TLS 是不够的。

实际上,仅看 TUF(The Update Framework)整理的威胁,更新系统就面临这么多问题:

  • 让用户装入任意恶意软件
  • 把版本回退到存在漏洞的旧版本(rollback)
  • 隐藏新版本、让客户端一直停留在旧版本(freeze)
  • 混用互不匹配的 metadata 与构建产物(mix-and-match)

换言之,自动更新不是「文件传输」,而是「信任的分发」。只有把这一层设计好,自动更新才能真正安全地运转起来。

3. 常见的错误模式

先汇总一下实务中常见的危险做法。

常见的错误模式 危险之处 最低限度的修正方法
通过 HTTPS 获取 version.json,并直接执行其中 URL 指向的 zip / exe 容易受到源站被攻破、配置被篡改、误发布的影响 改为对签名 metadata 和构建产物进行客户端验证
只对二进制文件签名,manifest 保持未签名 URL、version、channel、强制更新标记都可能被篡改 制作包含 version / hash / size / channel / expiry 的 signed manifest
将签名密钥放在开发 PC 或 CI 的文件中 一旦被攻破,就会被用来分发带有正规签名的恶意软件 使用 HSM / 签名服务 + 审批流程 + 审计日志
更新失败时「忽略验证错误继续执行」 在出事时打开了最脆弱的通道 改为 fail-closed
覆盖更新而不保留旧版本 断电、磁盘不足、更新中途失败都会导致无法启动 采用 staging + 原子化激活(atomic activate)+ rollback
仅凭版本号比较就放行旧版本 会让回退到脆弱版本的 rollback 攻击得以通过 使用单调递增的 release version,并保存已知的最高版本号
让整个 updater 以管理员权限运行 一旦被攻破,受影响范围会更广 下载 / 验证使用低权限,替换操作分离给权限最小的 helper
从差分更新开始做起 实现复杂,容易遗漏验证环节 先从完整包更新开始

以下稍微展开说明。

3.1 止步于「因为是 HTTPS 所以没问题」

这是最常见的情况。

  • 启动时读取 latest.json
  • 取出 downloadUrl
  • 下载 zip / exe
  • 解压并替换
  • 结束

看起来似乎没问题,但信任的根基过度依赖服务器的应答。一旦更新服务器或分发配置被攻破,攻击者就能在正常的 HTTPS 之上分发恶意更新。

TLS 是必要的。但仅靠 TLS,updater 的设计还远未完成。

3.2 已经签名,但客户端并未验证

即使在发布时对文件进行了签名,如果客户端不去检查它,那也毫无意义。

常见的情形是这样:

  • CI 环节确实进行了签名
  • 但 updater 只检查 hash
  • 而这个 hash 本身还来自未签名的 manifest

这样一来,manifest 一旦被替换,hash 也会随之被替换。「检查了 hash 所以安全」这个说法,只有在 hash 的来源本身也受到保护的前提下才成立。

3.3 manifest 未签名

更新系统中真正需要保护的,并不只是可执行文件本身。至少以下信息一旦被篡改,就会带来风险:

  • version / release id
  • 下载目标的 URL 与文件名
  • hash / size
  • channel(stable / beta 等)
  • 是否为强制更新
  • 适用的 OS / 架构
  • metadata 的有效期
  • 所需的最低 updater 版本

也就是说,最好抱着「用于更新判断的信息,全部纳入 signed metadata」这样的心态。

3.4 签名密钥的管理松散

更新功能的安全性,在很大程度上就等同于密钥管理的安全性。

如果生产环境的签名密钥是以下面这些方式存放的,那就相当危险:

  • 一直放在开发 PC 的证书存储中
  • 作为 CI 的 secret 上传 .pfx
  • 多人共享同一个私钥并各自在本地保存
  • 开发用签名和生产用签名共用同一条信任链

这样一来,即便 updater 本身没有问题,也无法阻止「带有合法签名的恶意更新」。

3.5 不保留旧版本的覆盖式更新

对更新而言,失败时的设计比成功时的设计更重要。

  • 下载中途断开
  • 解压失败
  • 替换过程中断电
  • 新版本启动了,但在首次迁移时崩溃

这时如果旧版本已经被删除,恢复起来就会非常麻烦。在实务中,「更新失败」这一事实本身还不算大问题,真正麻烦的是「现场的应用打不开了」。

3.6 没有考虑 rollback(版本回退攻击)

即便是经过签名的正规版本,只要是存在漏洞的旧版本,对攻击者来说也可能是有利可图的。

举例来说:

  • version 1.8 存在已知漏洞
  • 现场已经升级到了 2.3
  • 攻击者重新分发 1.8

如果这种情况被放过,那么即便签名本身没有问题,依然十分危险。

不能只看「是否有签名」,还必须确认「当前是否允许安装这个版本」,否则防护就不够。

3.7 fail-open(失败仍继续)

这是生产环境中最不该做的事情。

  • 签名验证失败时只弹出警告便继续执行
  • 存在可以忽略证书过期错误的隐藏 flag
  • 用于调试的 skipVerify=true 在生产环境中依然残留

越是在发生故障或遭受攻击的时候,这类「后门」就越容易成为真正被利用的突破口。

4. 最佳实践

4.1 首先尽量使用现有的更新基础设施

一开始就应该先质疑「是否真的需要自研 updater」,这样更安全。

如果是 Windows,只要需求匹配,通常可以优先考虑以下几种方案:

  • MSIX + App Installer
  • ClickOnce
  • Store / MDM / 企业内部分发基础设施
  • MSI + 企业侧的分发管理

理由很简单:这样可以把更新本身的责任在一定程度上转移给平台。当然自由度会有所下降,但更新 UI、分发 manifest、包签名与运维之间的一致性会更容易保持。

需要自研 updater 的典型场景包括:

  • 需要严格控制 stable / beta / preview 等多个渠道
  • 需要分阶段发布或控制 rollout 比例
  • 出于自身业务需要,对更新时机进行精细控制
  • 存在无法用 MSIX / ClickOnce 覆盖的架构

即便在这种情况下,与其把动机理解成「想要更多自由度」,不如理解成「由自己承担更新的责任」,这样思路会更稳定,不容易走偏。

4.2 把信任的起点放在客户端一侧

安全的 updater 不会直接信任服务器的应答。客户端至少需要具备以下两点:

  1. 信任的公钥或证书链
  2. 用该密钥验证 metadata 签名的机制

换句话说,不能满足于「服务器说这是最新版」,而是要让客户端能够自行确认:「这份 metadata,确实是我所信任的签名者发布的最新版本」。

4.3 以 signed metadata 为核心进行设计

更新 metadata 至少应包含以下内容,并将其纳入签名范围。

项目 纳入的理由
release version / release id 防止 rollback、便于审计
artifact 名称、URL、package type 固定要获取的具体文件
hash、size 检测篡改、检测损坏的分发
channel 避免把 beta 混入 stable
目标 OS / architecture 防止误分发
minimum updater version 在协议变更时阻止过旧的 updater
expires_at 应对 freeze 攻击
published_at 便于审计与排查
mandatory / optional 让更新 UX 的分支也无法被篡改

这里的关键是,把用于更新判断的信息全部集中到 signed metadata 中。让判断逻辑留在客户端,而信息的真实性由签名来保障,这样的分工能减少事故。

4.4 对构建产物本身也进行验证

验证完 metadata 之后,还需要对下载下来的构建产物确认以下内容:

  • size
  • hash
  • 包签名 / 代码签名
  • 发布者与预期的标识符

如果处理的是 Windows 的 PE / MSI / MSIX,最好把 AuthentiCode 或包签名的验证作为客户端侧的前提条件,这样更安全。如果是 macOS,则应把 Developer ID 与 notarization 也作为更新流程中的前提,思路会更一致。

4.5 密钥的保护靠运维而非功能本身

密钥管理的差距,更多体现在运维层面,而不是实现层面。

至少应把以下几类密钥区分开,这样更安全:

  • 开发用签名密钥
  • staging 用签名密钥
  • 生产用签名密钥

此外,生产用密钥还应涵盖:

  • HSM
  • 云端签名服务
  • 带审批流程的 signing system
  • 审计日志
  • key rotation(密钥轮换)流程
  • 带 timestamp 的签名

都应该纳入设计范围。

「生产构建一通过,CI 就自动签名」固然方便,但一旦被攻破,波及范围也会更大。至少应确保可以追溯到谁在何时对什么进行了签名。

运维体系成熟之后,把几乎不变动的 root trust 和需要频繁重新签名的更新 metadata 分开使用不同的密钥,会更安全。让 root 更偏向 offline 保管、更新 metadata 使用另一套密钥的设计,更容易在密钥被攻破时缩小受影响范围。

4.6 fail-closed 与分阶段更新(staged update)

更新流程的基本顺序如下:

  1. 获取 metadata
  2. 验证签名、有效期与 version
  3. 将构建产物下载到 staging 区域
  4. 验证 hash / size / 签名
  5. 保留旧版本的同时准备 activation
  6. 在重启时或通过专用 helper 进行切换
  7. 首次启动后的健全性确认
  8. 出现问题则执行 rollback

这里重要的是以下两点: 在验证结束之前不进行替换 一旦失败就不再继续

4.7 收紧 updater 的权限

应尽量避免让整个 updater 以管理员权限运行。

理想的做法是进行如下权限分离:

  • 下载与验证:低权限
  • 只有实际文件替换:交给权限最小的 helper
  • helper 除了「把已验证的 package 放到指定位置」之外不做任何其他事情

越是需要权限提升的设计,越必须在提升权限之前明确划分「哪些内容已经过验证」,否则就会存在风险。

4.8 从一开始就堵死 rollback / freeze / mix-and-match

这部分事后再补会很痛苦,最好从一开始就纳入设计。

  • 防 rollback 措施 客户端保留「迄今见过的最高 metadata version / release version」,并拒绝比它更旧的版本

  • 防 freeze 措施 让 metadata 携带 expiry,并拒绝过旧的 metadata

  • 防 mix-and-match 措施 保持 metadata 之间的一致性。至少要在 manifest 本身中固定目标 artifact 的 hash / size / version

此外,如果能通过 signed metadata 分发用于拒绝特定 build 的 blocklist,或 minimum allowed version,那么在出现事故时也能更快地实现封堵。

即便不直接采用 TUF,这三项特性也相当重要。

4.9 一开始先从完整更新做起

差分更新对带宽确实有帮助,但作为最初的实现方案会相当复杂。

  • 从哪个旧版本到哪个新版本的差分
  • 应用差分前的前提 hash
  • 应用差分后的最终 hash
  • 中途失败时的恢复
  • 部分应用以及旧差分的清理

这类问题一下子就会增多。在初期版本中,做到安全地替换已签名的完整包这一步就已经足够了。

5. 最小安全架构

即便不必做到完整的 TUF 那样复杂,自研 updater 的最小安全架构大致会是这样的形态。

5.1 客户端持有的内容

  • 信任的 root 公钥,或固定的证书链
  • 当前运行中的 version
  • 迄今见过的最高 metadata version / release version
  • 允许的 channel
  • 用于 rollback 的上一个版本

5.2 服务器返回的内容

  • 已签名的 update metadata
  • 已签名或经过 platform 签名的构建产物
  • 如有需要,还包括 blocklist / minimum allowed version 信息

5.3 典型流程

获取 metadata
  ↓
验证签名・expiry・version・channel
  ↓
将构建产物下载到 staging
  ↓
验证 size / hash / package signature
  ↓
保留旧版本的同时进行 activation
  ↓
首次启动失败则 rollback

这里重要的是,仅凭更新服务器的应答,什么都无法成立。真正让它成立的,是客户端所持有的 trust anchor 和验证逻辑。

6. Windows 项目该如何考虑

对于 Windows 应用,最好先从分发方式反推,这样更容易梳理清楚。

  • 如果需求匹配,可用 MSIX App Installer
  • 如果是 .NET 的企业内部应用,且 per-user 场景合适,可用 ClickOnce
  • 如果涉及服务、driver、shell extension,或需要独立控制渠道,则 MSI + 自研 updater 也是可比较的方案

不过,即便选择自研 updater,要做的事情并不会减少,反而会增多。

  • Authenticode / 包签名的验证
  • signed manifest
  • 防 rollback 措施
  • 更新 helper 的权限分离
  • updater 自身的更新策略

Windows 上常见的危险做法,是 DownloadFile -> unzip -> kill process -> overwrite -> restart 这样一条直线走到底。这种做法确实能跑起来,但安全性和恢复能力都很弱。

靠「更多信息 → 仍要运行」来让用户跳过 SmartScreen 或 UAC 警告的做法,并不是更新设计,而是「让用户对警告变得麻木」。要打造正确的更新路径,不应该是让用户习惯警告,而应该采用不容易触发警告的分发与验证方案。

关于分发方式本身的比较,我们在下面这篇文章中也做了整理。 Windows 应用的分发方式该如何选择——MSI / MSIX / ClickOnce / xcopy / 自研 updater 判断表

7. 最低限度的检查清单

在发布自研 updater 之前,至少应确认以下这些事项。

  • 更新 metadata 已签名
  • metadata 中包含 version / hash / size / channel / expiry
  • 客户端已验证签名与 version
  • 已验证构建产物的 hash 与 platform 签名
  • 生产签名密钥已与开发环境隔离
  • 保留密钥使用日志与审批记录
  • 使用带 timestamp 的签名
  • 通过 staging 更新,在保留旧版本的同时进行切换
  • 具备 rollback 的条件与流程
  • 验证失败时以 fail-closed 方式中止
  • updater 自身也有更新方针
  • 能够分发 blocklist / minimum allowed version
  • 具备用于中止分阶段发布的 kill switch
  • 能够观测失败率、rollback 率与签名验证失败情况

如果这份清单上空缺项较多,那么与先做 updater 的 UI 相比,先把分发信任模型梳理清楚会更有效果。

8. 总结

自动更新功能的安全性,归根结底可以归结为这一点:

要设计的不是更新的便利性, 而是信任谁、以及客户端如何验证这份信任。

在此基础上,实务中的判断大致可以这样概括:

  • 如果现有基础设施已经足够,首先应该采用它
  • 如果要自研 updater,应在 HTTPS 之前先引入带签名的 metadata 与密钥管理
  • 不设计失败恢复与 rollback 的 updater,在生产环境中会很难扛住
  • updater 不是分发功能,而是产品安全边界本身

如果目前的架构接近「latest.json + zip 替换」,那么最先该修正的与下载处理本身相比,更应是信任的建立方式。仅仅修正这一点,风险程度就会大不一样。

9. 参考资料

相关主题

与本主题相关的主题页面。可以以本文为起点,进一步访问相关服务或其他文章。

Windows 技术主题

汇总 Windows 开发、故障排查、既有资产利用等技术主题的入口页面。

与本主题相关的服务

Windows 应用开发

自动更新不仅关乎 UI,还是一项涵盖分发方式、权限、恢复与运维的整体设计。无论是新开发 Windows 应用,还是对既有软件进行重新梳理,都可以从整理更新方式开始着手。

技术咨询与设计评审

从「是否真的需要自研 updater」「MSIX / ClickOnce 是否已经足够」「当前的更新设计哪里存在风险」这类梳理阶段开始,都可以进行咨询。

作者简介

小村 豪

小村软件有限公司 代表

主要从事 Windows 软件开发、技术咨询与故障排查,尤其擅长处理保留既有资产的项目,以及原因难以查明的故障调查。

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

自动更新只要通过 HTTPS 通信就安全了吗?
TLS 是必要条件,但不是充分条件。TLS 主要保护的是通信链路和连接目标的正当性,无法应对更新服务器本身被攻破、错误的构建产物被放到正规 CDN 上,或未签名的 manifest 被替换等情况。更新判定不应基于「因为服务器这么说」,而应基于「客户端验证了签名的 metadata 并确认其正确」。
更新用的 metadata 应该包含哪些内容并进行签名?
基本原则是把所有用于判断更新的信息都集中到 signed metadata 中。具体包括:release version、构建产物的 URL 与文件名、hash 与 size、channel(stable / beta 等)、目标 OS 与架构、minimum updater version、metadata 的有效期(expires_at),以及是否为强制更新的标记等。如果只对二进制文件签名而让 manifest 保持未签名,那么 URL、版本号和强制更新标记就仍有被篡改的余地。
什么是 rollback 攻击?该如何防范?
即便是经过签名的正规版本,攻击者也可能重新分发存在已知漏洞的旧版本,诱使系统被降级为脆弱版本。由于签名本身是合法的,单靠签名验证无法防范这种攻击。防范措施包括:客户端保留迄今见过的最高 release version,并拒绝比它更旧的版本;同时让 metadata 携带有效期以防止隐藏新版本的 freeze 攻击,并在 manifest 中固定构建产物的 hash、size 与 version,从而堵住 mix-and-match 攻击。
应该自己开发 updater,还是使用现有的机制?
如果需求匹配,首先应优先使用 MSIX App Installer、ClickOnce 等现有的更新基础设施,这样更安全,因为可以把更新的责任范围转移给平台。只有在存在现有基础设施无法覆盖的需求时——例如需要严格控制多个渠道或进行分阶段发布——才需要自研 updater。即便如此,最先要引入的也不是 UI,而是签名验证与失败恢复:坚持验证失败即中止(fail-closed)的原则,并在保留旧版本的同时进行切换,以便能够执行 rollback。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表