Skip to content

CVE‐2026‐85515

David Hook edited this page Oct 2, 2026 · 1 revision

Title: OpenPGP message truncation not reported, bypassing the SEIPDv1 integrity check.

Issue affecting: BC before 1.86 (from 1.74; the SEIPDv1 half from 1.81, when the high-level API was introduced), BC-LTS before 2.73.13 (from 2.73.0, the AEAD route only — that edition does not ship the high-level API), BC-FJA before bcpg-fips 1.0.14 (from 1.0.7), 2.0.14.1 (from 2.0.7) and 2.1.14 (the AEAD route only).

Fixed versions: BC 1.86, BC-LTS 2.73.13, BC-FJA bcpg-fips 1.0.14 (from 1.0.7), 2.0.14.1 (from 2.0.7) and 2.1.14.

Platform affected: Java 8 and later.

RFC 9580 sec. 13.7 is the one place the specification addresses the streaming case directly. An implementation "MAY choose to release cleartext from the fully authenticated chunks before it to the user if it is operating in a streaming fashion, but it MUST indicate a clear error message as soon as the truncation is detected", and one that discovers malleable ciphertext "MUST generate a clear error message that indicates the integrity of the message is suspect". Bouncy Castle detected the truncation and then discarded the detection, so no error was ever indicated.

The common cause is one line of laundering. When a message is truncated but the length field of the enclosing packet is left as it was, BCPGInputStream.PartialInputStream raises an EOFException for the ciphertext it was promised and did not get — exactly the condition sec. 13.7 defines as truncation. BCPGInputStream.nextPacketTag() catches EOFException and reports it as a clean end of message, so the packet stream above simply stopped, having been told there were no more packets. Both routes below turn on that. Note that leaving the outer length alone is the simpler manipulation: the attacker who corrects it is the one the earlier CVE-2026-12817 fix already defeats.

Route 1, AEAD (SEIPDv2 and the v5 AEAD packet). When the literal data packet ended on an AEAD chunk boundary and the consumer read in increments smaller than one chunk, the look-ahead for the packet after the literal was what triggered the read of the truncated chunk. The EOFException escaped the AEAD decryption stream and was laundered one layer above it, so BcAEADUtil / JceAEADUtil never reached the trailing message tag of sec. 5.13.2 that authenticates the total plaintext length. The caller was handed the plaintext of the fully authenticated chunks, every packet following the literal was silently dropped, and no exception was raised: a signed and encrypted message read back as a well-formed unsigned one, with an empty signature list. Every byte released on this route remained individually AEAD-authenticated, so the failure is the missing truncation error and the silent loss of trailing packets rather than any forgery. This is a residual of CVE-2026-12817, which closed the same outcome for an attacker who corrects the outer packet length.

Route 2, SEIPDv1 (MDC), and the more serious of the two. IntegrityProtectedInputStream verifies the modification detection code from close(), and it reached close() only by closing itself when a read of it returned -1. A truncated message never produces that -1, because the packet stream above it stops early on the laundered EOFException. The stream was therefore never closed, PGPEncryptedData.verify() never ran, and the recipient was handed CFB-decrypted plaintext on which no integrity check of any kind had been performed — the malleable ciphertext sec. 13.7 requires an error for. Measured on a message truncated into that shape, 136 distinct single-byte modifications of the ciphertext produced accepted, altered plaintext with no exception raised, and the trailing signature packet was dropped so a signed message again read back as unsigned.

Reachability is a property of the message rather than of anything the attacker supplies. On the AEAD route the shape needs the literal packet to end on a chunk boundary, which held for 3 of 131 consecutive payload lengths measured; on the SEIPDv1 route it needs only the preceding packet to end on a cipher block boundary, which held for one payload length in sixteen, at a truncation offset that did not move with the payload length. Two of the remaining conditions are the defaults rather than the exception: the high-level generator does not compress, and appends a padding packet after the literal inside a SEIPDv2 body, so a message with a trailing packet to lose is its ordinary output; and it defaults to the 64-byte minimum chunk, while reading a byte at a time is a common idiom. A caller that inspects the signature count sees zero and can react; a caller that only checks for an exception cannot.

Not affected: the low-level API, where a caller that invokes PGPEncryptedData.verify() directly gets the check regardless — this is what the OpenPGP examples under misc/ do — and consumers reading in increments of a whole AEAD chunk or more, which reported the truncation correctly throughout.

The AEAD decryption streams in BcAEADUtil and JceAEADUtil now re-throw an EOFException from the underlying packet stream as a plain IOException ("truncated AEAD data"), which nextPacketTag() does not launder. OpenPGPMessageInputStream.close() now closes its layer's integrity-protected stream itself rather than relying on that stream having seen the end of its data, so the MDC is verified on the way out of every message; IntegrityProtectedInputStream.close() was made idempotent, as java.io.Closeable requires, which that depends on — the stream is genuinely closed twice on the ordinary path, and PGPEncryptedData.verify() consumes the digest state behind it and cannot be run a second time. The legacy jdk1.1 operator overlay, which carried neither this fix nor the CVE-2026-12817 one, received both.

The fix was introduced in commit ab7a235d1c.

Credit: Arpan Sharma.

Clone this wiki locally