Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
Methodology

openpgpjs-v6 hunt, iteration 1

Time: 2026-04-17 ~07:10 UTC Target: openpgpjs (YWH program: openpgp-js-bug-bounty-program, sponsored by Sovereign Tech Agency, €10k critical)

Confirmed in scope

  • YWH program URL: https://yeswehack.com/programs/openpgp-js-bug-bounty-program
  • Acknowledged in GHSA-8qff-qr5q-5pr8 (CVE-2025-47934).

Patched bug, CVE-2025-47934

  • v6.1.0 → v6.1.1 patched in src/message.js verify() (lines 591-606).
  • Root cause: verify() mutated msg.packets by push(...)-ing packets drained from the message stream. The literalDataList was computed BEFORE the mutation; signatures were verified against literalDataList[0] (the original/legit literal data). After verify returned, openpgp.verify wrapper called message.getLiteralData() which uses msg.packets.findPacket(literalData), which now returns the FIRST literal data, but the streamed (post-mutation) literal data ends up in result.data via the linked stream.
  • Fix: use a local packets variable that is packets.concat(streamed) instead of mutating msg.packets.

Variant candidates to chase next iteration

Candidate A, Compression Streams refactor (commit fccbc3ec, in 6.2.0+)

Big rewrite of compressed_data.js for native Compression Streams API. The new flow: - Decompress: pulls this.compressed through a web stream, then ArrayStream-back if not originally a stream. - The decompressed bytes are then handed to PacketList.fromBinary/parser as a stream.

Risk: nested compressed messages with attacker-shaped inner packets may bypass the local-packets fix because unwrapCompressed() (line 656) returns new Message(compressed[0].packets), the inner packets list. Subsequent verify on inner packets uses the same packets.concat(stream) pattern, but getLiteralData() (line 318) calls unwrapCompressed() AGAIN, so on the OUTER message it traverses to inner.packets which may now have been further mutated by stream parsing in another async path.

Candidate B, decrypt flow re-verification

Per advisory, decrypt with verificationKeys is also affected. The fix path goes through verify() so should be covered, BUT: decrypt returns a NEW Message(symEncryptedPacket.packets) (line 148) and then sets symEncryptedPacket.packets = new PacketList(). If the inner packets list still has a live .stream reference, subsequent verify→getLiteralData on the new Message has the same window. Need to check whether the inner stream gets a concat or stays mutated.

Candidate C, appendSignature (line 669)

async appendSignature(detachedSignature, config) {
 await this.packets.read(
 util.isUint8Array(detachedSignature) ? detachedSignature : (await unarmor(detachedSignature)).data,
 allowedDetachedSignaturePackets,
 config
 );
}

This calls this.packets.read(...), does read mutate? PacketList.read typically appends to this. So appendSignature MUTATES msg.packets. If a caller does: 1. msg = readMessage(M_evil) → msg.packets has [literal(P_evil)] 2. msg.appendSignature(legitSigOverPlegit) → msg.packets = [literal(P_evil), sig(legit over P_legit)] 3. verify(msg, key), sig is genuine over P_legit, but literalDataList[0] = P_evil → signature.verify should reject because hash mismatch.

So C might not work, verify computes hash of literalDataList[0] and compares with the sig’s hashed digest. They won’t match.

UNLESS the sig’s hashed digest can be made to match by attacker controlling P_evil to be a near-collision of P_legit (cryptographic). Out of scope.

Next iteration plan

  1. Run a streaming PoC against 6.1.0 to confirm the canonical bug fires.
  2. Run the same PoC structure against 6.3.0 to confirm patched.
  3. Then mutate the PoC to use compressed-data wrapper with attacker-controlled inner literal, see if 6.3.0 still leaks in the linkStreams/result.data path.
  4. If candidate A holds: write report. If not, pivot to OpenPGP.js v5 line and check if 5.11.3 fix is structurally identical (variants may exist on the v5 line that don’t backport cleanly).

Source · github.com/zionsworking/security-research-notebook · methodology/openpgpjs-cve-2025-47934-rootcause.md