← THE INDEX  ·  WRITEUP

Root-Cause Analysis: OpenPGP.js CVE-2025-47934

A close read of the signature-verification bypass in OpenPGP.js v6: how a packet-list mutation during streaming verification allowed result.data to diverge from the verified content.

Summary

This is a root-cause analysis of CVE-2025-47934, a signature-verification bypass in OpenPGP.js v6.1.0, patched in v6.1.1. The CVE is publicly acknowledged in GHSA-8qff-qr5q-5pr8. This writeup was produced during a variant-hunting exercise against the OpenPGP.js codebase.

The vulnerability allowed a crafted signed message to pass signature verification while returning different plaintext content to the caller than the content that was actually verified. An attacker could present a legitimately signed message body alongside attacker-controlled data, and the verification result would report success while delivering the attacker's content as result.data.

Impact

Any application relying on openpgp.verify() or openpgp.decrypt() with verificationKeys to authenticate message content before processing it was affected. A successful exploit allowed:

  • Delivery of attacker-controlled plaintext to the application while reporting a valid signature over different content.
  • Bypassing integrity checks in secure messaging, encrypted file transfer, and software update verification workflows that used OpenPGP.js v6.

The advisory rates this High. The bug was fixed in v6.1.1 by a single-line change that prevents the packet list mutation.

Root cause

The core issue is a mutation/window mismatch during streaming verification in src/message.js.

verify() (lines 591-606 in v6.1.0) processes a signed message in streaming mode. The verification window and the data delivery window used different views of the same packet list:

  1. literalDataList was computed from msg.packets before the stream was consumed.
  2. verify() then consumed the message stream, pushing drained packets back into msg.packets via push(...), mutating the list.
  3. After verify() returned, the openpgp.verify wrapper called message.getLiteralData(), which calls msg.packets.findPacket(LiteralDataPacket). Because the packet list had been mutated, findPacket could return a different literal-data packet than the one used for signature verification.
  4. The linked stream set up before the mutation fed attacker-controlled content into result.data.

The result: signatures[0].valid === true (because the signature genuinely covers literalDataList[0], the original packet), but result.data contained bytes from a different packet that was appended during stream consumption.

The fix in v6.1.1 replaces the mutable push pattern with an immutable local variable:

// v6.1.0 (vulnerable): mutates msg.packets
msg.packets.push(...streamed);

// v6.1.1 (fixed): local variable, no mutation
const packets = msg.packets.concat(streamed);

This keeps msg.packets stable across the verify call, so getLiteralData() always finds the same packet that was hashed.

Proof of concept

The following pseudocode illustrates the exploitation structure. Concrete exploitation requires OpenPGP.js v6.1.0 and a crafted message with a legitimate inline signature over one literal body and an attacker-controlled body appended to the stream.

Variant candidates evaluated

This analysis was produced as part of a multi-iteration variant hunt. After confirming the root cause, three candidate variants were evaluated against the v6.2.0+ codebase:

Candidate A: Compression Streams refactor (commit fccbc3ec, v6.2.0+). The refactored compressed_data.js uses native Compression Streams API. unwrapCompressed() returns new Message(compressed[0].packets). The packets.concat(stream) pattern is used inside verify for the inner packets, but getLiteralData() on the outer message traverses to inner.packets via unwrapCompressed() again. A nested compressed message with attacker-shaped inner packets was evaluated as a potential bypass of the local-packets fix. Not exploitable in current analysis: the outer/inner packet boundary is respected, and the concat fix propagates correctly.

Candidate B: decrypt flow with verificationKeys. The advisory noted decrypt() is also affected. The fix path goes through the same verify() function, so the patch applies. However, decrypt() creates new Message(symEncryptedPacket.packets) and clears the original. If the inner stream still has a live reference during async verify, the window question recurs. Evaluated and closed: the inner stream is consumed before getLiteralData() is called on the new message.

Candidate C: appendSignature mutation. appendSignature() calls this.packets.read(...), which appends to msg.packets. A caller who does appendSignature(legitSig) on a message containing attacker-controlled literal data and then calls verify() would need the sig's hashed digest to match literalDataList[0] (the attacker-controlled packet). This requires a cryptographic near-collision and is out of scope.

All three candidates were evaluated and closed. No exploitable variant was identified in v6.2.0+.

Disclosure and fix

CVE-2025-47934 is publicly acknowledged in GHSA-8qff-qr5q-5pr8. The fix shipped in OpenPGP.js v6.1.1 as a one-line change in src/message.js. Users of v6.1.0 or earlier should upgrade immediately. Applications that use OpenPGP.js for message authentication, signature verification, or integrity checking of encrypted data are directly affected.