2026 年 6 月,安全研究员 MrBruh 披露了 AMD Ryzen Master 自动更新组件中的一个远程代码执行(Remote Code Execution, RCE)漏洞——更新清单通过 HTTPS 获取,但实际的可执行文件下载却走的是 HTTP 明文传输,且没有任何签名验证。攻击者只需在局域网内发起中间人攻击(Man-in-the-Middle, MITM),就能将合法更新替换成任意恶意程序并自动执行。更令人惊讶的是,AMD 在漏洞报告后要求研究者撤下博文,却花了 124 天才完成修复——而修复方案仅仅是给 URL 加个 s。
这个事件绝非孤例。从 SolarWinds 供应链攻击到 Codecov bash uploader 篡改,软件更新机制一直是攻击者眼中的高价值目标。对于每一个发布桌面应用、CLI 工具或 IoT 固件的开发者来说,理解更新安全不是可选项,而是基本功。
🔓 一、自动更新的三大致命漏洞模式
自动更新看似简单——检查版本、下载文件、替换安装——但每一步都可能引入严重的安全漏洞。以下是最常见的三种攻击面。
1.1 HTTP 明文传输:MITM 的温床
AMD Ryzen Master 的问题就出在这里。更新清单 XML 文件托管在 HTTPS 端点,但清单中列出的下载链接全部是 http:// 开头。这意味着:
- ✅ 清单本身是安全的(HTTPS)
- ❌ 实际下载的二进制文件完全暴露在 MITM 风险中
攻击者只需在目标网络中执行 ARP 欺骗或 DNS 劫持,就能拦截下载请求并返回恶意文件。在公共 Wi-Fi、企业内网甚至 ISP 层面,这种攻击的门槛极低。
⚠️ **警告:**永远不要通过 HTTP 下载任何可执行文件。即使清单通过 HTTPS 获取,下载链接本身也必须是 HTTPS。
1.2 缺少完整性校验:下载 ≠ 安全
即使使用了 HTTPS,也不能保证文件没有被篡改。服务器可能被入侵、CDN 可能被投毒、构建管道可能被渗透。因此,下载后的 完整性校验 至关重要。
AMD 的"修复方案"是在下载后执行 CRC-32 校验——这几乎等于没有校验。CRC-32 是一个 32 位校验和,设计目标是检测传输错误而非防止恶意篡改。攻击者可以轻松构造一个既通过 CRC-32 检查又包含恶意载荷的文件。
| 校验方式 | 位数 | 抗碰撞能力 | 适用场景 | 推荐 |
|---|---|---|---|---|
| CRC-32 | 32 位 | 极弱,可构造碰撞 | 传输错误检测 | ❌ 不推荐 |
| MD5 | 128 位 | 已被攻破,可碰撞 | 已弃用 | ❌ 不推荐 |
| SHA-256 | 256 位 | 当前安全 | 文件完整性校验 | ✅ 推荐 |
| SHA-512 | 512 位 | 当前安全 | 高安全需求 | ✅ 推荐 |
| Ed25519 签名 | 256 位 | 数字签名 | 身份认证 + 完整性 | ✅✅ 强烈推荐 |
📌 **记住:**完整性校验只告诉你"文件没有变",但不告诉你"文件来自可信来源"。要同时保证完整性和来源可信,必须使用 数字签名。
1.3 无签名验证:最危险的信任链断裂
数字签名是更新安全的最后一道防线。开发者用自己的私钥对发布文件签名,客户端用对应的公钥验证签名。这样即使攻击者能够篡改下载文件,也无法伪造有效的签名。
AMD 声称修复后增加了"签名验证",但实际上只是 CRC-32 校验。这说明很多开发者对"签名验证"的理解存在偏差——哈希校验不等于数字签名。
💡 **提示:**哈希校验(SHA-256)保证文件没有被篡改,但不验证来源。数字签名(Ed25519/RSA)同时保证完整性和来源身份,是更新安全的金标准。
🛡️ 二、安全更新机制完整实现
接下来我们用 Node.js 实现一个生产级的安全自动更新器。核心原则是 Trust Nothing, Verify Everything。
2.1 使用 Ed25519 进行代码签名
Ed25519 是目前最推荐的签名算法——签名短(64 字节)、验证快、安全性高。以下是完整的签名和验证流程:
// Ed25519 签名生成与验证示例
import { generateKeyPairSync, sign, verify, createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
// 生成密钥对(仅需执行一次,私钥必须离线保存)
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
const privKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
// === 签名端(构建服务器 / 发布流程)===
function signRelease(filePath, privateKeyPem) {
const fileBuffer = readFileSync(filePath);
const sha256 = createHash('sha256').update(fileBuffer).digest();
const keyObject = createPrivateKey(privateKeyPem);
const signature = sign(null, sha256, keyObject);
return {
sha256: sha256.toString('hex'),
signature: signature.toString('base64'),
algorithm: 'Ed25519',
timestamp: new Date().toISOString(),
};
}
// === 验证端(客户端自动更新器)===
function verifyRelease(filePath, manifest, publicKeyPem) {
const fileBuffer = readFileSync(filePath);
const sha256 = createHash('sha256').update(fileBuffer).digest();
// 第一步:SHA-256 完整性校验
if (sha256.toString('hex') !== manifest.sha256) {
return { valid: false, reason: 'SHA-256 哈希不匹配,文件已被篡改' };
}
// 第二步:Ed25519 签名验证
const keyObject = createPublicKey(publicKeyPem);
const sigBuffer = Buffer.from(manifest.signature, 'base64');
const isValidSig = verify(null, sha256, keyObject, sigBuffer);
if (!isValidSig) {
return { valid: false, reason: '签名验证失败,文件来源不可信' };
}
return { valid: true, reason: '文件完整性与来源均已验证' };
}
// 使用示例
const manifest = signRelease('./app-v2.0.0.exe', privKeyPem);
writeFileSync('./manifest.json', JSON.stringify(manifest, null, 2));
const result = verifyRelease('./app-v2.0.0.exe', manifest, pubKeyPem);
console.log(result); // { valid: true, reason: '文件完整性与来源均已验证' }
⚠️ **警告:**私钥必须存储在离线的硬件安全模块(HSM)或密钥管理服务中,绝不能放在构建服务器的环境变量里。一旦私钥泄露,攻击者可以签名任何恶意文件。
2.2 安全的更新清单设计
更新清单(Update Manifest)是客户端和服务端之间的信任契约。一个安全的清单必须包含版本号、下载 URL、文件哈希、签名和过期时间:
// 安全更新清单生成器
import { createHash, createPrivateKey, sign } from 'node:crypto';
import { readFileSync, writeFileSync, statSync } from 'node:fs';
function generateManifest(options) {
const { version, releaseUrl, files, privateKeyPem, previousManifest } = options;
const fileEntries = files.map((file) => {
const buffer = readFileSync(file.path);
const sha256 = createHash('sha256').update(buffer).digest('hex');
const size = statSync(file.path).size;
return {
name: file.name,
platform: file.platform, // 'win-x64', 'darwin-arm64', 'linux-x64'
url: `${releaseUrl}/${version}/${file.name}`,
sha256,
size,
};
});
const manifest = {
version,
releasedAt: new Date().toISOString(),
// 关键:清单本身的过期时间,防止重放攻击
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
minimumVersion: previousManifest?.version || '0.0.0', // 最低可升级版本
files: fileEntries,
// 清单签名(签名的是清单内容,不是文件本身)
_signature: null,
};
// 对清单内容签名(排除 _signature 字段)
const signPayload = JSON.stringify({ ...manifest, _signature: undefined });
const sha256 = createHash('sha256').update(signPayload).digest();
const key = createPrivateKey(privateKeyPem);
manifest._signature = {
algorithm: 'Ed25519',
value: sign(null, sha256, key).toString('base64'),
};
return manifest;
}
// 生成清单
const manifest = generateManifest({
version: '2.1.0',
releaseUrl: 'https://releases.example.com/app',
files: [
{ name: 'app-2.1.0-win-x64.exe', path: './dist/app-2.1.0-win-x64.exe', platform: 'win-x64' },
{ name: 'app-2.1.0-darwin-arm64.dmg', path: './dist/app-2.1.0-darwin-arm64.dmg', platform: 'darwin-arm64' },
{ name: 'app-2.1.0-linux-x64.AppImage', path: './dist/app-2.1.0-linux-x64.AppImage', platform: 'linux-x64' },
],
privateKeyPem: readFileSync('./keys/release-signing-key.pem', 'utf-8'),
});
writeFileSync('./dist/manifest.json', JSON.stringify(manifest, null, 2));
console.log(`✅ 清单已生成,包含 ${manifest.files.length} 个平台文件`);
这里有几个关键设计决策:
- ✅ 清单签名:对整个清单签名,而不仅仅是文件哈希
- ✅ 过期时间:防止攻击者重放旧版本清单进行降级攻击
- ✅ 最低版本:强制用户不能降级到已知有漏洞的旧版本
- ✅ 全平台支持:每个平台的二进制文件独立签名
💡 **提示:**使用 Electron 的应用可以直接使用 electron-updater 的签名机制,它默认使用 SHA-512 + RSA 签名。但自定义更新器需要手动实现上述流程。
2.3 客户端安全更新流程
客户端是整个安全链条中最关键的环节。以下是完整的安全更新流程:
// 客户端安全更新器(简化版)
import { createPublicKey, createHash, verify } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
const TRUSTED_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...(你的公钥硬编码在客户端)
-----END PUBLIC KEY-----`;
async function checkAndUpdate(manifestUrl) {
// 第一步:通过 HTTPS 获取清单
const manifestRes = await fetch(manifestUrl, {
headers: { 'Cache-Control': 'no-cache' },
});
if (!manifestRes.ok) throw new Error(`清单获取失败: ${manifestRes.status}`);
const manifest = await manifestRes.json();
// 第二步:检查清单是否过期(防重放攻击)
if (new Date(manifest.expiresAt) < new Date()) {
throw new Error('清单已过期,可能遭受重放攻击');
}
// 第三步:验证清单签名
const { _signature, ...manifestBody } = manifest;
const payload = JSON.stringify(manifestBody);
const sha256 = createHash('sha256').update(payload).digest();
const pubKey = createPublicKey(TRUSTED_PUBLIC_KEY);
const sigBuf = Buffer.from(_signature.value, 'base64');
const sigValid = verify(null, sha256, pubKey, sigBuf);
if (!sigValid) {
throw new Error('清单签名验证失败,可能遭受中间人攻击');
}
// 第四步:获取当前平台对应的文件信息
const platformKey = `${process.platform}-${process.arch}`;
const fileInfo = manifest.files.find((f) => f.platform === platformKey);
if (!fileInfo) throw new Error(`没有找到当前平台 (${platformKey}) 的更新文件`);
// 第五步:通过 HTTPS 下载文件
const fileRes = await fetch(fileInfo.url);
if (!fileRes.ok) throw new Error(`文件下载失败: ${fileRes.status}`);
const tempPath = `./update-${manifest.version}.tmp`;
const fileStream = createWriteStream(tempPath);
await pipeline(Readable.fromWeb(fileRes.body), fileStream);
// 第六步:验证下载文件的 SHA-256
const downloaded = await import('node:fs').then((fs) => fs.readFileSync(tempPath));
const fileHash = createHash('sha256').update(downloaded).digest('hex');
if (fileHash !== fileInfo.sha256) {
throw new Error('文件哈希不匹配,下载过程中可能被篡改');
}
console.log(`✅ 更新 ${manifest.version} 验证通过,准备安装`);
// 第七步:执行安装(需要提权,使用 spawn 以最小权限运行)
}
这个流程遵循了 Defense in Depth 原则——每一层都是独立的安全屏障:
- HTTPS 传输层加密
- 清单过期时间防重放
- 清单签名防篡改
- 文件哈希防传输错误
- 公钥硬编码防证书伪造
🔧 三、主流更新框架安全对比与最佳实践
3.1 框架安全特性对比
选择正确的更新框架可以避免大部分安全问题。以下是主流框架的安全特性对比:
| 框架 | 传输加密 | 签名验证 | 降级防护 | 回滚机制 | 推荐 |
|---|---|---|---|---|---|
| Electron autoUpdater | ✅ HTTPS | ✅ SHA-512 + RSA | ⚠️ 需配置 | ✅ 内置 | ✅ 推荐 |
| Squirrel.Windows | ✅ HTTPS | ✅ SHA-1 | ❌ 无 | ✅ 内置 | ⚠️ 需加固 |
| Sparkle (macOS) | ✅ HTTPS | ✅ EdDSA | ✅ 内置 | ✅ 内置 | ✅✅ 强烈推荐 |
| Tauri updater | ✅ HTTPS | ✅ Ed25519 | ✅ 内置 | ✅ 内置 | ✅✅ 强烈推荐 |
| 自定义实现 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 | ⚠️ 高风险 |
💡 **提示:**如果使用 Electron 构建桌面应用,推荐搭配 Tauri 或 electron-builder 的签名功能。Tauri 的更新器默认使用 Ed25519 签名,安全性开箱即用。
3.2 安全检查清单
在发布任何带自动更新功能的软件之前,逐项检查以下安全要求:
- ✅ 所有下载链接使用 HTTPS(TLS 1.2+)
- ✅ 下载文件使用 SHA-256 或更强的哈希校验
- ✅ 更新清单使用数字签名(Ed25519 推荐)
- ✅ 客户端硬编码公钥,不从网络获取
- ✅ 清单包含过期时间,防止重放攻击
- ✅ 设置最低可升级版本,防止降级攻击
- ✅ 下载失败时保留旧版本,实现自动回滚
- ❌ 不要使用 CRC-32、MD5 做安全校验
- ❌ 不要在 HTTP 连接上下载可执行文件
- ❌ 不要信任从 DNS 或证书透明度日志获取的公钥
- ⚠️ 私钥存储在 HSM 或离线环境,不放在 CI/CD 中
- ⚠️ 更新服务器与应用服务器隔离部署
3.3 公钥分发策略
公钥如何安全地到达客户端,是整个信任链的起点。常见策略有三种:
策略一:硬编码在客户端二进制中(最推荐)
// 公钥直接写死在源码中,编译时嵌入二进制
const TRUSTED_PUBLIC_KEY = 'MCowBQYDK2VwAyEA...';
优点:不依赖任何外部信任源,攻击面最小。缺点:密钥轮转需要发新版本。
策略二:通过证书透明度(Certificate Transparency)+ Key Pinning
适用于需要灵活轮转密钥的场景,但实现复杂度高。
策略三:多签机制(高安全需求)
要求更新清单同时被两个或以上独立密钥签名,单个密钥泄露不影响安全。
⚠️ **警告:**不要从网络动态获取公钥。如果公钥通过 HTTPS 获取,你就回到了"信任 CA 证书"的老路上,证书伪造攻击(如 DigiNotar 事件)会让整个更新安全形同虚设。
💡 总结
AMD RCE 事件给所有开发者敲响了警钟:自动更新不是"写个 HTTP 请求下载文件"那么简单。安全的更新机制需要 传输加密 + 完整性校验 + 数字签名 + 降级防护 的四层防线。
⚡ 关键结论:
- 永远使用 HTTPS 下载所有可执行文件,没有例外
- 使用 Ed25519 数字签名,而不是 CRC-32 或 MD5
- 公钥硬编码在客户端,不从网络获取
- 优先选择成熟的更新框架(Sparkle、Tauri updater),而不是自己造轮子
- 私钥安全是重中之重——泄露私钥等于放弃所有安全保障
安全更新机制不是功能需求,而是对用户的基本尊重。你发布的每一个二进制文件,都可能在数百万台设备上以管理员权限执行。请对得起这份信任。
相关工具推荐:
- Tauri Updater — Rust 驱动的桌面应用更新框架,Ed25519 签名开箱即用
- Sparkle — macOS 应用更新的行业标准
- minisign — 轻量级文件签名工具,适合 CLI 工具分发
- sigstore — 无密钥签名基础设施,适合 CI/CD 流水线
- jsjson.com 在线工具 — 在线哈希计算、Base64 编解码等开发者工具