Skip to content

fix(deps): update rust crate russh to 0.60 [security]#937

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-russh-vulnerability
Open

fix(deps): update rust crate russh to 0.60 [security]#937
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-russh-vulnerability

Conversation

@renovate
Copy link
Copy Markdown
Contributor

@renovate renovate Bot commented Apr 24, 2026

This PR contains the following updates:

Package Type Update Change
russh dependencies minor 0.570.60

Warning

Some dependencies could not be looked up. Check the Dependency Dashboard for more information.


russh has pre-auth DoS via unbounded allocation in its keyboard-interactive auth handler

CVE-2026-42189 / GHSA-f5v4-2wr6-hqmg

More information

Details

Summary

A pre-authentication denial-of-service vulnerability exists in the server's keyboard-interactive authentication handler. A malicious client can crash any russh-based server that implements keyboard-interactive auth (e.g., for 2FA/TOTP) with a single malformed packet, requiring no credentials.

Vulnerability Details

In russh/src/server/encrypted.rs, the function read_userauth_info_response decodes a u32 count from the client's SSH_MSG_USERAUTH_INFO_RESPONSE and passes it directly to Vec::with_capacity():

let n = map_err!(u32::decode(r))?;

// Bound both allocation and iteration by remaining packet data to
// prevent a malicious client from causing a multi-GB allocation or
// billions of loop iterations with a crafted count.
// Each response needs at least 4 bytes (length prefix).
let max_responses = r.remaining_len().saturating_add(3) / 4;
let n = (n as usize).min(max_responses);
let mut responses = Vec::with_capacity(n);
for _ in 0..n {
    responses.push(Bytes::decode(r).ok())
}

An attacker can send n = 0x10000000 (268M) or larger in a minimal packet (~50 bytes after encryption). The server attempts to allocate n * ~24 bytes (size of Option<Bytes>) = ~6.4GB, causing an OOM crash.

Attack Flow
  1. Attacker connects via TCP, completes key exchange (no credentials needed -- this is the anonymous DH handshake, not authentication)
  2. Sends USERAUTH_REQUEST with method keyboard-interactive
  3. Server handler returns Auth::Partial with prompts (standard for 2FA/TOTP)
  4. Attacker sends USERAUTH_INFO_RESPONSE with n = 0x10000000 and no response data
  5. Server calls Vec::with_capacity(268_435_456), OOM killed

No authentication is required. The allocation occurs before the handler validates any credentials. The attack is repeatable faster than the server can restart.

Affected Configurations

Any russh-based server where the Handler::auth_keyboard_interactive implementation returns Auth::Partial (i.e., sends prompts to the client). The default handler returns Auth::reject() and is not affected.

Source code review suggests that downstream projects using keyboard-interactive for multi-step auth (e.g., TOTP/2FA) follow the affected pattern, since returning Auth::Partial before credential verification is the intended API usage for prompting.

Confirmed End-to-End PoC

There is a complete Docker-contained PoC confirming the OOM kill:

  • Minimal russh server returning Auth::Partial for keyboard-interactive
  • Python client (paramiko for key exchange) sends malformed USERAUTH_INFO_RESPONSE
  • Container with 512MB memory limit; server is OOM-killed (exit code 137)

Available on request.

Proposed Fix

Cap the Vec::with_capacity allocation to what the remaining packet data can actually contain. Each response requires at least 4 bytes (length prefix), so:

let n = map_err!(u32::decode(r))?;

// Bound both allocation and iteration by remaining packet data to
// prevent a malicious client from causing a multi-GB allocation or
// billions of loop iterations with a crafted count.
// Each response needs at least 4 bytes (length prefix).
let max_responses = r.remaining_len().saturating_add(3) / 4;
let n = (n as usize).min(max_responses);
let mut responses = Vec::with_capacity(n);
for _ in 0..n {
    responses.push(Bytes::decode(r).ok())
}

This bounds the allocation to at most the packet size (~256KB), while preserving the existing behavior for well-formed packets. This fix has been implemented, tested, and contributed via the temporary private fork.

Severity

Pre-auth, remote, no credentials required, crashes the server process affecting all active sessions.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Russh: Unchecked CryptoVec allocation and growth handling is reachable

CVE-2026-46673 / GHSA-g9f8-wqj9-fjw5

More information

Details

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

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

warp-tech/russh (russh)

v0.60.3

Compare Source

Security fixes

CVE-2026-46673

When compression is negotiated, an attacker can craft a "ZIP bomb" style packet that would bypass the maximum packet size checks. This could allow the attacker to hit the OOM limit and either get the server process killed by the OS, or, prior to russh@0.58.0, aborted. A similar issue existed in the AgentClient as well, which could be triggered by a malformed SSH agent response.

v0.60.2

Compare Source

Changes

Fixes

v0.60.1

Compare Source

Security fixes

GHSA-f5v4-2wr6-hqmg in 6c3c80a

This DoS vulnerability allowed an unauthenticated user to trigger an out-of-memory condition in a russh based server if keyboard-interactive authentication is allowed. A malicious authentication packet could trigger a multi-GB memory allocation likely leading to the process getting killed by the OOM killer.

Fixes

v0.60.0

Compare Source

Changes

Fixes

v0.59.0

Changes

Fixes

Misc

v0.58.0

Compare Source

Changes

  • eliminate mlock/munlock overhead for non-secret buffers (~21% throughput improvement) (#​653) #​653 (Mika Cohen)

    • Non-sensitive data buffers are no longer wrapped in CryptoVec, reducing the performance overhead. A few public functions that took CryptoVec now take impl Into<Bytes> instead.
  • 6f70150: Remove heap allocations from SshId (#​656) (kpcyrd) #​656

    • SshId::Standard() now contains a Cow<'static, str> instead of a String.
  • 0f51860: Expose HostConfig fields to external consumers (#​652) (François Bernier) #​652

  • e75de5a: Add russh/serde feature to enable serde on russh::keys::PublicKey (#​655) (kpcyrd) #​655

  • replace memset with zeroize in resize() method (#​634) #​634 (Eric Rodrigues Pires)

  • bump thiserror to latest version (#​651) #​651 (Roger Knecht)

  • b7ce487: Remove Home Crate Dependency (#​667) (Roger Knecht) #​667

  • bebe8c0: fixed #​658 - make Handle::tcpip_forward and Handle::streamlocal_forward take &self (Eugene)

Fixes

v0.57.1

Compare Source

Fixes

Features


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • ""
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot changed the title fix(deps): update rust crate russh to 0.60 [security] fix(deps): update rust crate russh to 0.60 [security] - autoclosed Apr 27, 2026
@renovate renovate Bot closed this Apr 27, 2026
@renovate renovate Bot deleted the renovate/crate-russh-vulnerability branch April 27, 2026 19:35
@renovate renovate Bot changed the title fix(deps): update rust crate russh to 0.60 [security] - autoclosed fix(deps): update rust crate russh to 0.60 [security] Apr 27, 2026
@renovate renovate Bot reopened this Apr 27, 2026
@renovate renovate Bot force-pushed the renovate/crate-russh-vulnerability branch 2 times, most recently from 94a6f59 to 4535f34 Compare April 27, 2026 21:39
@renovate renovate Bot force-pushed the renovate/crate-russh-vulnerability branch from 4535f34 to 9d56e71 Compare May 18, 2026 11:47
@renovate renovate Bot force-pushed the renovate/crate-russh-vulnerability branch from 9d56e71 to 604622d Compare May 21, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants