自动更新的安全设计——为什么仅靠 HTTPS 还不够
· 小村 豪 · Windows开发, 安全, updater, 自动更新, 签名, MSIX, ClickOnce
目录
- 先说结论
- 为什么自动更新是高危区域
- 常见的错误模式
- 最佳实践
- 最小安全架构
- Windows 项目该如何考虑
- 最低限度的检查清单
- 总结
- 参考资料
1. 先说结论
先列出实务中的落地要点。
- 如果需求匹配,首先应优先使用 MSIX App Installer、ClickOnce 等现有的更新基础设施
- 如果确实需要自研 updater,最先要做的不是 UI,而是签名验证与失败恢复
- 像
latest.json这样的更新信息,应当作为经过签名的 metadata 来处理,而不是未签名的配置文件 - TLS 是必要条件,但不是充分条件
- 更新判定的依据不应是「服务器这么说」,而应是「客户端验证后确认为正确」
- 将签名密钥按开发环境与生产环境分离,并用 HSM 或签名服务加以保护
- 更新失败时应采用 fail-closed(失败即中止),而不是 fail-open(失败仍继续)
- 没有防回滚措施的 updater,应当假设它随时可能被降级回存在漏洞的旧版本
- 如果还无法引入签名验证,那么手动分发已签名的安装包,会比自动更新更安全
归根结底,自动更新的核心不在于「如何下载」,而在于「信任什么、在哪里验证、出问题时如何回退」。
2. 为什么自动更新是高危区域
普通的功能通常都封闭在应用内部。而 updater 则一次性集齐了以下三件事:
- 从外部获取文件
- 信任这份文件
- 替换现有的可执行文件
也就是说,任意代码执行的通道从一开始就内置在产品之中。
这里常见的误解是「因为是 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 不会直接信任服务器的应答。客户端至少需要具备以下两点:
- 信任的公钥或证书链
- 用该密钥验证 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)
更新流程的基本顺序如下:
- 获取 metadata
- 验证签名、有效期与 version
- 将构建产物下载到 staging 区域
- 验证 hash / size / 签名
- 保留旧版本的同时准备 activation
- 在重启时或通过专用 helper 进行切换
- 首次启动后的健全性确认
- 出现问题则执行 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. 参考资料
- CISA Secure by Design Pledge
- NIST: Security Considerations for Code Signing
- NIST Secure Software Development Framework (SSDF)
- The Update Framework Specification
- TUF: Roles and metadata
- TUF: Security
- Microsoft Learn: Authenticode Digital Signatures
- Microsoft Learn: Auto-update and repair apps - MSIX
- Microsoft Learn: ClickOnce Deployment and Security
- Apple Developer: Developer ID
- CA/Browser Forum: Baseline Requirements for the Issuance and Management of Publicly-Trusted Code Signing Certificates
相关主题
与本主题相关的主题页面。可以以本文为起点,进一步访问相关服务或其他文章。
Windows 技术主题
汇总 Windows 开发、故障排查、既有资产利用等技术主题的入口页面。
与本主题相关的服务
Windows 应用开发
自动更新不仅关乎 UI,还是一项涵盖分发方式、权限、恢复与运维的整体设计。无论是新开发 Windows 应用,还是对既有软件进行重新梳理,都可以从整理更新方式开始着手。
技术咨询与设计评审
从「是否真的需要自研 updater」「MSIX / ClickOnce 是否已经足够」「当前的更新设计哪里存在风险」这类梳理阶段开始,都可以进行咨询。
作者简介
小村 豪
小村软件有限公司 代表
主要从事 Windows 软件开发、技术咨询与故障排查,尤其擅长处理保留既有资产的项目,以及原因难以查明的故障调查。
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows出现“Windows 已保护你的电脑”提示的原因
整理Windows应用分发时出现SmartScreen警告的原因,从代码签名、EV/OV证书、Azure Artifact Signing、MSIX、Microsoft Store、ClickOnce、企业内部分发到App Control,以实务角度梳理应对方法。
Windows 应用发布方式怎么选 - MSI / MSIX / ClickOnce / xcopy / 自定义 updater 判断指南
Windows 应用的发布方式不是 installer 形式的偏好问题,而是与 OS 的耦合程度和更新责任由谁承担的选择。本文从实务角度整理 MSI / MSIX / ClickOnce / xcopy / 自定义 updater 的选型思路。
ClickOnce 是什么 - 从实务角度整理其原理、更新机制以及适用与不适用的场景
本文围绕用于 .NET Windows 桌面应用发布的 ClickOnce 技术,结合 Mermaid 图整理其清单(manifest)、更新、缓存、签名机制,以及适用与不适用的项目类型。
Windows DLL 名称解析机制 - 搜索顺序与 SxS
本文从实务角度整理 Windows 的 DLL 名称解析机制,涵盖 DLL search order、Known DLLs、loaded-module list、API set、SxS manifest,以及 LoadLibrary 系列 API 的影响。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
常见问题
汇总了咨询这一主题时常见的问题。
- 自动更新只要通过 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。