Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🦀
🦀 crates.io
Not in CISA KEV
HIGH severity

GHSA-g9f8-wqj9-fjw5

HIGH

GHSA-g9f8-wqj9-fjw5 is a high-severity (CVSS 7.5) CWE-770 vulnerability in russh-cryptovec. O3 Security confirms whether GHSA-g9f8-wqj9-fjw5 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.

Russh: Unchecked CryptoVec allocation and growth handling is reachable

Also known asCVE-2026-46673RUSTSEC-2026-0153RUSTSEC-2026-0154
Published
May 21, 2026
Updated
Jun 11, 2026
Affected
2 pkgs
Patched
2 / 2
Exploits
None indexed
Exploitation data as of Aug 12, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.
  • CISA assesses this as automatable — exploitation doesn’t require manual, per-target effort, which raises the odds of mass scanning and opportunistic attacks.

Exploitation and automatability from CISA’s SSVC triage for GHSA-g9f8-wqj9-fjw5.

EPSS Exploitation Probability

via FIRST.org ↗
0.3%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs18th percentile — riskier than 18% of all scored CVEsHighest risk
0.00%0.25%0.51%0.76%0.3%0.3%0.3%Jul 26Aug 26Aug 26

EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.

How urgent is this, really

GHSA-g9f8-wqj9-fjw5 plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.

Where this sits among everything scored

Of 358,265 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.

Real-World Exposure

2 pkgs affected
🦀russh-cryptovec🦀russh

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects crates.io packages — download data is not available via public APIs for these ecosystems.

Description

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
  2. 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

Affected Packages

2 total 2 fixed
EcosystemPackageVulnerable rangeFix
🦀crates.iorussh-cryptovecall versions0.60.3
🦀crates.iorusshall versions0.60.3

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for russh-cryptovec. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update russh-cryptovec to 0.60.3 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-g9f8-wqj9-fjw5 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-g9f8-wqj9-fjw5 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-g9f8-wqj9-fjw5. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

### 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 T
O3 Security · Impact-Aware SCA

Is GHSA-g9f8-wqj9-fjw5 in your dependencies?

O3 detects GHSA-g9f8-wqj9-fjw5 across crates.io dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.

GHSA-g9f8-wqj9-fjw5: russh-cryptovec (High… | O3 Security