MemoryMap::read in the pageant crate (part of the russh workspace, used by
russh's SSH-agent client on Windows via AgentClient::connect_pageant) copies a
peer-controlled number of bytes out of an 8192-byte shared-memory view with
no bounds check — unlike the sibling MemoryMap::write, which correctly
rejects oversize access with Error::Overflow. The byte count comes straight
from a u32 length prefix that the responding "Pageant" process writes into the
shared mapping. A malicious local process that answers as the Pageant agent can
therefore cause:
u32 (vec![0; n]).This was reproduced end-to-end against the real, unmodified pageant crate
(not a model) on x86_64-pc-windows-gnu under Wine; see "Proof of concept".
MemoryMap::read(size) walks off the end of
the 8192-byte view and faults on the next, unmapped page — an
EXCEPTION_ACCESS_VIOLATION that crashes the russh SSH client. Independently,
a size near u32::MAX drives a ~4 GiB vec![0; n] before any copy.read returns those adjacent bytes to russh as the
"agent response", which russh then parses as agent identities/signatures. This
arm depends on process memory layout, so it is opportunistic; the crash/alloc
is the deterministic outcome.FindWindowW("Pageant", "Pageant") and passes the shared-mapping name inside
the WM_COPYDATA COPYDATASTRUCT. Any local process can register a window
of class + title "Pageant", receive that name, open the same mapping, and
write a hostile size. So an unprivileged local process impersonating Pageant
can attack every russh-based SSH client that uses the Pageant agent.pageant/src/wmmessage.rs
MemoryMap::read (:160-171) — no bound (contrast MemoryMap::write
:139-158, which returns Error::Overflow when pos + len > length).query_pageant_direct (:199-237) — reads a 4-byte u32 size from the
shared mapping (:233) and calls map.read(size) (:234) with no check
against _AGENT_MAX_MSGLEN (8192).AgentClient::connect_pageant → PageantStream →
query_pageant_direct.Platform: Windows only (cfg(windows)), local attacker. Verified against
the pageant crate v0.2.2 as shipped in russh v0.63.1 (d3ae702, the
latest release). read has never had a bound in any revision
(git log -p -- pageant/src/wmmessage.rs).
fn write(&mut self, data: &[u8]) -> Result<(), Error> {
if self.pos + data.len() > self.length { // :140 BOUND PRESENT
return Err(Error::Overflow);
}
... copy_nonoverlapping(&data[0], view+pos, data.len()) ...
}
fn read(&mut self, n: usize) -> Vec<u8> { // :160 NO BOUND
let out = vec![0; n]; // n up to 0xFFFF_FFFF (CWE-789)
unsafe {
std::ptr::copy_nonoverlapping(
self.view.Value.add(self.pos) as *const u8, // view is length==8192
out.as_ptr() as *mut u8,
n, // reads n bytes, may run past the view (CWE-125)
);
}
self.pos += n;
out
}
query_pageant_direct creates the mapping at _AGENT_MAX_MSGLEN = 8192, writes
the request, sends the WM_COPYDATA, then reads the response the peer wrote:
map.seek(0);
let mut buf = map.read(4);
let size = u32::from_be_bytes([buf[0],buf[1],buf[2],buf[3]]) as usize; // :233 peer-controlled
buf.extend(map.read(size)); // :234 unbounded
Nothing checks 4 + size <= 8192, so map.read(size) runs past the 8 KiB view.
Because the bug is Windows-only (WM_COPYDATA + MapViewOfFile), the PoC is a
Windows cross-build (x86_64-pc-windows-gnu) driven under Wine, entirely
inside a Linux container. It exercises the real, unmodified crate: the PoC
takes a path dependency on pageant and calls
pageant::wmmessage::query_pageant_direct, exactly what
AgentClient::connect_pageant uses. A second thread impersonates Pageant
(registers the window class + title "Pageant") and, on WM_COPYDATA, opens
the shared mapping russh created and writes an attacker-chosen 4-byte
big-endian length.
poc/run.sh builds and runs it. Full log in results/e2e-wine-run.log:
======== LEG 1 — ATTACK: fake agent reports length 0x00080000 (512 KiB) >> 8192-byte view ========
[attacker] impersonating Pageant window is up (class+title "Pageant")
[victim ] calling pageant::wmmessage::query_pageant_direct() over the 8192-byte view ...
[attacker] WM_COPYDATA received; shared mapping = "PageantRequestpoc"
[attacker] wrote hostile response length = 524288 (0x00080000) into the 8192-byte view
wine: Unhandled page fault on read access to 0000000002092000 at address 00000002282CFDC4 ...
WINE-EXIT=5
======== LEG 2 — CONTROL: fake agent reports length 0x00000010 (16 B), in-bounds ========
[victim ] returned 20 bytes — request+response fit inside the 8192-byte view (in-bounds control); no fault
WINE-EXIT=0
size makes MemoryMap::read read past the
8192-byte view; Wine reports an unhandled page fault at a page-aligned address
(0x…2092000) — the out-of-bounds read as an access violation (crash). Exit
code 5 = STATUS_ACCESS_VIOLATION.size returns cleanly. Same code path; the
only difference is whether the peer's length exceeds the view — isolating the
missing bound.Fix validation. With patch/pageant-read-bound.patch applied and the PoC
rebuilt against the patched crate, the identical attack input is rejected
(results/patched-wine-run.log):
[attacker] wrote hostile response length = 524288 (0x00080000) into the 8192-byte view
[victim ] query_pageant_direct error: Overflow
WINE-EXIT=0
No fault; the read is refused at the bound, exactly as write already refuses
oversize writes. (A pure-logic Linux model of the same control flow is also
included as poc/pageant_read_oob_demo.rs / results/logic-demo-run.log.)
Mirror write's guard in read and validate the response length before
allocating/copying. patch/pageant-read-bound.patch:
MemoryMap::read(n) returns Result<Vec<u8>, Error> and returns
Error::Overflow when self.pos + n > self.length;query_pageant_direct propagates that Result and additionally rejects
size > _AGENT_MAX_MSGLEN - 4 before map.read(size).{
"cwe_ids": [
"CWE-125",
"CWE-789"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-30T23:41:02Z",
"nvd_published_at": "2026-09-29T19:17:23Z",
"severity": "MODERATE"
}