Zion Boggan

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

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

sequoia-pgp hunt, iteration 2 (stream.rs read-after-verify-fail)

Time: 2026-04-17 ~08:47 UTC Target file: openpgp/src/parse/stream.rs Focus: CVE-2025-47934 analog in Verifier/Decryptor read path.

Architecture

  • Verifier<H> wraps Decryptor<NoDecryptionHelper<H>>, single implementation.
  • Decryptor::read_helper streams bytes from a Literal Data packet, holding back buffer_size bytes so verification (finish_maybeverify_signatureshelper.check) runs BEFORE the final bytes are delivered.
  • Reserve-hold-back design is the correct counter-pattern to the openpgpjs bug.
  • helper.check(results) is the last step; returns Err if the user’s VerificationHelper::check rejects the MessageStructure.

Candidate: reserve populated before verify error returns (stream.rs:2660-2765)

Sequence in finish_maybe(): 1. Line 2662: self.oppr.take(), oppr becomes None. 2. Line 2671: self.reserve = Some(Protected::from(pp.steal_eof()?)) - reserve is populated. 3. Lines 2673-2743: loop processing remaining packets (signatures, MDC). 4. Line 2756: self.verify_signatures(), calls helper.check(results) at stream.rs:2985. Returns Err on bad signature.

If step 3 or step 4 returns Err, finish_maybe propagates the error. BUT self.reserve remains populated and self.oppr is None. On the next call to read_helper, line 2999 (if let Some(ref mut reserve) = self.reserve) fires and drains the reserve into the caller’s buffer.

Impact analysis

  • Hardening gap, not a slam-dunk P1. A correct caller (read_to_end or similar) sees the initial Err and stops, Rust stdlib guarantees no bytes were read into their buffer for the errored call. They never read again.
  • Downstream misuse turns it into a vuln: any caller that catches Err from Verifier::read and continues reading (e.g. reads in a loop with while let Ok(n) = r.read(...) and treats subsequent Ok(n) bytes as verified) gets attacker-tampered bytes after a helper.check rejection.
  • Downstream audit needed: sq-cli, sequoia-cert-store, any caller that does loop { match read... } and doesn’t quit the whole verification on Err.

Suggested fix pattern

Clear self.reserve in the error path of finish_maybe, or delay assignment until after verify_signatures() succeeds. Preferred: reorder so verify_signatures runs before reserve assignment, OR wrap reserve in a “verified” flag only set true on success.

Next iteration plan

  1. Write a minimal Rust PoC that exercises the read-after-err path on a tampered message. If it returns tampered bytes, we have a real hardening finding, severity depends on downstream misuse incidence.
  2. In parallel, audit RawCertParser dispatch (RUSTSEC-2024-0345 variant class), look for parser match arms that don’t advance the cursor on fall-through.
  3. Check sq-cli (separate repo) for any read loop that might retry on Err.

Iteration 2 closure (PoC result)

PoC built and ran (probe at /tmp/probe). Empirical result: - VerifierBuilder::with_policy(... helper) itself returned Err: no good signature. - No Verifier object existed to call read() on.

Re-read of stream.rs:2500-2513 and :2510 explains: during builder setup the parser loop encounters Packet::Literal, calls v.finish_maybe()? which (for messages small enough that data_len ≤ buffer_size) runs verify_signatures() synchronously. Verification failure surfaces during with_policy, not as a deferred Err on read().

For LARGE messages (> DEFAULT_BUFFER_SIZE = 25 MB), verification IS deferred to read-EOF. But the caller has already received ~25 MB of unverified pre-EOF chunks before reaching the verify gate. Treating those pre-EOF bytes as verified would be caller misuse, the documented contract is “treat data as verified only after read returns 0 (EOF)”. The reserve-leak adds nothing the caller doesn’t already have.

Verdict: NOT a CVE-2025-47934 analog. The openpgpjs bug delivered data that DIFFERED from the verified window. Sequoia delivers exactly the bytes hashed - the question is only WHEN the user can trust them, and the user trusts them at read=0. Reserve-leak after Err is a hardening surprise, not a verification bypass.

Closing this lead. Pivoting to RUSTSEC-2024-0345 variant class (parser dispatch fall-through that doesn’t advance the cursor).


Source · github.com/zionsworking/security-research-notebook · methodology/sequoia-pgp-variant-hunting-2.md