PT-2026-42625 · Crates.Io · Russh+1
Published
2026-05-21
·
Updated
2026-05-21
CVSS v3.1
7.5
High
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
Title
Unchecked
CryptoVec allocation and growth handling was reachable from local agent inputs in current russh releases and from remote SSH traffic in historical pre-0.58.0 releasesSummary
CryptoVec used unchecked capacity growth, unchecked length arithmetic, and unsafe allocation/locking paths. In current russh releases, local SSH agent peers could still feed attacker-controlled frame lengths into buffer growth before validation. In older russh releases before 0.58.0, remote SSH traffic also reached CryptoVec through transport and compression buffers.Details
The underlying unsafe paths were in
CryptoVec:cryptovec/src/cryptovec.rs- unchecked capacity growth
- unchecked length arithmetic in growth callers
- raw allocation and reallocation paths coupled to those sizes
cryptovec/src/platform/unix.rsmlock/munlockpreviously accepted zero-length calls and performed null-pointer validation inside theunsafeOS-call path
There are two relevant reachability stories:
- current local reachability in
russh
russh/src/keys/agent/client.rsAgentClient::read response()read a peer-suppliedu32length and then resizedself.bufto that value before reading the payloadrussh/src/keys/agent/server.rsConnection::run()read a peer-suppliedu32length and then resizedself.bufto that value before reading the payload
This is the path that still existed in current
0.60.x releases before the fix, although by then those buffers were no longer CryptoVec.- historical remote reachability in older
russh
- before commit
712e32b(first released inv0.58.0), non-secret transport and compression buffers inrusshstill usedCryptoVec - I verified this in a detached pre-
712e32bworktree by adding and running: cipher::tests::remote packet length grows transport cryptovec buffercompression::tests::remote compressed payload expands cryptovec output- those tests show that remote SSH traffic could grow
CryptoVecthrough: - transport packet reads
- zlib decompression output
Also added a constrained-memory reproduction in that historical worktree:
compression::tests::remote compressed payload can crash under memory limit
That test re-execs the test binary under
prlimit --as=134217728, decompresses a highly compressible payload that expands to 96 MiB, and reliably aborts in the old Unix CryptoVec path when NonNull::new unchecked() receives a null pointer after allocation failure.The prepared patch does two things:
- hardens
CryptoVecitself
- checked capacity growth
- checked length arithmetic
- immediate allocation-failure handling
- zero-length
mlock/munlockno-ops - explicit null-pointer validation before entering the Unix
unsafelocking calls
- hardens the real untrusted-input path
- caps agent frame lengths at
256 * 1024on both client and server before resizing buffers
This cap matches OpenSSH’s agent framing guardrail.
PoC
The following end-to-end tests demonstrate the real untrusted-input path by feeding oversized peer-controlled agent frame lengths into the public client and server flows and asserting that they are rejected before buffer growth.
Client-side agent reply path:
#[test]
fn oversized agent response is rejected before allocation() -> std::io::Result<()> {
let runtime = tokio::runtime::Builder::new current thread()
.enable all()
.build()?;
runtime.block on(async {
let (mut writer, reader) = tokio::io::duplex(64);
let server = tokio::spawn(async move {
let mut frame = [0u8; 4];
writer.read exact(&mut frame).await?;
let len = BigEndian::read u32(&frame) as usize;
let mut body = vec![0; len];
writer.read exact(&mut body).await?;
BigEndian::write u32(&mut frame, (MAX AGENT FRAME LEN + 1) as u32);
writer.write all(&frame).await?;
Ok::<(), std::io::Error>(())
});
let mut client = AgentClient::connect(reader);
let err = client.request identities().await.unwrap err();
assert!(matches!(err, Error::AgentProtocolError));
server.await.expect("server task")?;
Ok::<(), std::io::Error>(())
})?;
Ok(())
}
Server-side agent request path:
#[test]
fn oversized agent request is rejected before allocation() -> std::io::Result<()> {
let runtime = tokio::runtime::Builder::new current thread()
.enable all()
.build()?;
runtime.block on(async {
let (server, mut client) = tokio::io::duplex(64);
let connection = Connection {
lock: Lock(std::sync::Arc::new(std::sync::RwLock::new(crate::CryptoVec::new()))),
keys: KeyStore(std::sync::Arc::new(std::sync::RwLock::new(
std::collections::HashMap::new(),
))),
agent: Some(()),
s: server,
buf: Vec::new(),
};
let server = tokio::spawn(async move { connection.run().await });
let mut frame = [0u8; 4];
BigEndian::write u32(&mut frame, (MAX AGENT FRAME LEN + 1) as u32);
client.write all(&frame).await?;
drop(client);
let err = server.await.expect("server task").unwrap err();
assert!(matches!(err, Error::AgentProtocolError));
Ok::<(), std::io::Error>(())
})?;
Ok(())
}
These tests pass on the fixed branch and fail on unfixed
v0.60.2, where oversized agent frame lengths are not rejected at the framing boundary.For historical
russh < 0.58.0, I also verified remote reachability into CryptoVec in a detached pre-712e32b worktree (91d431d, package version 0.57.1).Transport packet read path:
#[test]
fn remote packet length grows transport cryptovec buffer() -> std::io::Result<()> {
let runtime = tokio::runtime::Builder::new current thread()
.enable all()
.build()?;
runtime.block on(async {
let packet len = MAXIMUM PACKET LEN;
let (mut writer, mut reader) = tokio::io::duplex(packet len + 4);
let writer task = tokio::spawn(async move {
let mut packet = vec![0u8; packet len + 4];
packet[..4].copy from slice(&(packet len as u32).to be bytes());
writer.write all(&packet).await?;
Ok::<(), std::io::Error>(())
});
let mut buffer = SSHBuffer::new();
let mut cipher = clear::Key;
let n = read(&mut reader, &mut buffer, &mut cipher).await.unwrap();
assert eq!(n, packet len + 4);
assert eq!(buffer.buffer.len(), packet len + 4);
assert eq!(&buffer.buffer[..4], &(packet len as u32).to be bytes());
writer task.await.expect("writer task")?;
Ok::<(), std::io::Error>(())
})?;
Ok(())
}
Compression growth path:
#[test]
fn remote compressed payload expands cryptovec output() {
let payload = vec![b'A'; 64 * 1024];
let compression = Compression::new(&ZLIB);
let mut compressor = Compress::None;
let mut decompressor = Decompress::None;
compression.init compress(&mut compressor);
compression.init decompress(&mut decompressor);
let mut compressed = CryptoVec::new();
let encoded = compressor
.compress(&payload, &mut compressed)
.expect("compress")
.to vec();
let mut output = CryptoVec::new();
let decoded = decompressor
.decompress(&encoded, &mut output)
.expect("decompress");
assert eq!(decoded.len(), payload.len());
assert eq!(decoded, payload.as slice());
assert!(encoded.len() < output.len());
}
Constrained-memory crash reproduction for the historical remote compression path:
#[test]
fn remote compressed payload can crash under memory limit() {
const CHILD ENV: &str = "RUSSH REMOTE COMPRESS CRASH CHILD";
if std::env::var os(CHILD ENV).is some() {
let payload = vec![b'A'; 96 * 1024 * 1024];
let compression = Compression::new(&ZLIB);
let mut compressor = Compress::None;
let mut decompressor = Decompress::None;
compression.init compress(&mut compressor);
compression.init decompress(&mut decompressor);
let mut compressed = CryptoVec::new();
let encoded = compressor
.compress(&payload, &mut compressed)
.expect("compress")
.to vec();
let mut output = CryptoVec::new();
let decoded = decompressor
.decompress(&encoded, &mut output)
.expect("decompress");
assert eq!(decoded.len(), payload.len());
return;
}
let exe = std::env::current exe().expect("current exe");
let status = Command::new("prlimit")
.args([
"--as=134217728",
"--",
exe.to str().expect("utf8 exe path"),
"--exact",
"compression::tests::remote compressed payload can crash under memory limit",
"--nocapture",
])
.env(CHILD ENV, "1")
.status()
.expect("spawn child");
assert!(
!status.success(),
"expected child to fail under constrained address space"
);
}
On that historical worktree, the constrained-memory child aborts in the old Unix
CryptoVec path with:unsafe precondition(s) violated: NonNull::new unchecked requires that the pointer is non-null
thread caused non-unwinding panic. aborting.
To run the reproduced checks:
cargo test -p russh oversized agent response is rejected before allocation -- --nocapture
cargo test -p russh oversized agent request is rejected before allocation -- --nocapture
cargo test -p russh-cryptovec
Historical pre-
0.58.0 checks were run from the detached 91d431d worktree with:cargo test --offline -p russh remote packet length grows transport cryptovec buffer -- --nocapture
cargo test --offline -p russh remote compressed payload expands cryptovec output -- --nocapture
cargo test --offline -p russh remote compressed payload can crash under memory limit -- --nocapture
Impact
This is a memory-safety hardening issue with demonstrated untrusted-input reachability.
What is demonstrated:
- current local agent peers could previously reach allocation growth directly from attacker-controlled frame lengths
- historical remote SSH traffic could previously reach
CryptoVecthrough transport and compression buffers inrussh < 0.58.0 - under constrained memory, the historical remote compression path can be turned into a process abort in the old Unix
CryptoVeccode - the fixed code now rejects oversized agent frames early and hardens the underlying allocation paths
What is not demonstrated:
- practical code execution
- a demonstrated integrity or confidentiality break
Fix
Allocation of Resources Without Limits
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Russh
Russh-Cryptovec