GitHub Advisoryadvisory · crates.io
GHSA-662x-8wvj-2276 pageant: unbounded frame buffering on peer-controlled 4-byte length prefix (memory-exhaustion DoS)
LOW
Official page ↗
- Aliases
- CVE-2026-116775
- Ecosystem
- crates.io
- Packages
- pageant
- Severity
- Low
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
- Weaknesses
- CWE-770
- Reviewed
- 2026-10-30
Affected packages
• crates.io pageant: >= 0.2.0, < 0.2.5 → fixed in 0.2.5
Description
Summary
`pageant::PageantStream` reads a 4-byte big-endian length prefix from its input stream and buffers until `len + 4` bytes are available, without ever validating `len`. The underlying WM_COPYDATA transport physically cannot carry frames larger than 8 KiB (`_AGENT_MAX_MSGLEN`, the fixed-size shared memory mapping), so an oversized frame is destined to fail — but only after the re-framing task has already buffered the full claimed size. A peer controlling the stream bytes can claim a frame of up to ~4 GiB and either stream that much data (forcing a corresponding allocation) or simply stop mid-frame and keep the partial buffer resident indefinitely.
Vulnerable code
`pageant/src/wmmessage.rs`, `PageantStream::new()` (the spawned re-framing task), current main:
```rust
let mut buf = BytesMut::new();
while let Ok(n) = two.read_buf(&mut buf).await {
if n == 0 { break; }
if buf.len() Result> {
let mut stream = pageant::PageantStream::new().await?;
// Claim a ~4 GiB frame, stream 256 MiB, then stall: the task buffers all of it.
stream.write_all(&0xFFFF_F000u32.to_be_bytes()).await?;
let chunk = vec![0u8; 256 * 1024];
for _ in 0..1024 { stream.write_all(&chunk).await?; } // 256 MiB, frame never completed
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(())
}
```
Observed on Windows 11, pageant 0.2.4 (source v0.64.1, Rust 1.99): process working set grows from ~7.4 MB to ~264.7 MB after 256 MiB is streamed, and stays there while the peer stalls; no error is ever raised. Memory grows linearly with bytes sent, up to the claimed length. (Allocation behavior additionally verified with a counting global allocator.)
Suggested fix
Reject frames that cannot fit the transport before buffering:
```diff
let len = BigEndian::read_u32(&buf) as usize;
+ // The WM_COPYDATA transport is backed by a fixed-size shared
+ // memory mapping: larger requests can never fit. Reject them
+ // before buffering. Without this cap a malicious peer could
+ // force up to ~4 GiB of buffering by claiming a huge frame
+ // and never completing it.
+ if len.saturating_add(4) > _AGENT_MAX_MSGLEN {
+ debug!("dropping oversized Pageant request: {len} bytes");
+ break;
+ }
if buf.len() < len + 4 {
continue;
}
```
`saturating_add` also removes a theoretical 32-bit `len + 4` overflow. `break` shuts down the re-framing task (the writer observes the stream close); returning an error through the stream would work equally well. Happy to submit a PR with this change, or with any alternative you prefer.
Reporter
Tshellauth
Related
Alias · CVE (1)
- CVE-2026-116775 pageant provides a PageantStream type that implements AsyncRead and AsyncWrite traits and can be used to communicate with a running Pageant… vulnerability
Weakness · CWE (1)
- CWE-770 Allocation of Resources Without Limits or Throttling weakness
References
- https://github.com/Eugeny/russh/security/advisories/GHSA-662x-8wvj-2276 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-116775 advisory
- https://github.com/Eugeny/russh/commit/69e4b55513eaa8ac9d2ad4236f13171fc164e10a web
- https://github.com/Eugeny/russh package
- https://github.com/Eugeny/russh/releases/tag/v0.65.0 web