PT-2026-42625 · Crates.Io · Russh+1

Published

2026-05-21

·

Updated

2026-05-21

CVSS v3.1

7.5

High

VectorAV: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 releases

Summary

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.rs
  • mlock / munlock previously accepted zero-length calls and performed null-pointer validation inside the unsafe OS-call path
There are two relevant reachability stories:
  1. current local reachability in russh
  • russh/src/keys/agent/client.rs
  • AgentClient::read response() read a peer-supplied u32 length and then resized self.buf to that value before reading the payload
  • russh/src/keys/agent/server.rs
  • Connection::run() read a peer-supplied u32 length and then resized self.buf to 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.
  1. historical remote reachability in older russh
  • before commit 712e32b (first released in v0.58.0), non-secret transport and compression buffers in russh still used CryptoVec
  • I verified this in a detached pre-712e32b worktree by adding and running:
  • cipher::tests::remote packet length grows transport cryptovec buffer
  • compression::tests::remote compressed payload expands cryptovec output
  • those tests show that remote SSH traffic could grow CryptoVec through:
  • 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:
  1. hardens CryptoVec itself
  • checked capacity growth
  • checked length arithmetic
  • immediate allocation-failure handling
  • zero-length mlock / munlock no-ops
  • explicit null-pointer validation before entering the Unix unsafe locking calls
  1. hardens the real untrusted-input path
  • caps agent frame lengths at 256 * 1024 on 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 CryptoVec through transport and compression buffers in russh < 0.58.0
  • under constrained memory, the historical remote compression path can be turned into a process abort in the old Unix CryptoVec code
  • 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

Weakness Enumeration

Related Identifiers

GHSA-G9F8-WQJ9-FJW5

Affected Products

Russh
Russh-Cryptovec