russh server userauth state is not reset when authentication principal changes
Description
Summary
The russh server authentication path keeps internal userauth state across SSH_MSG_USERAUTH_REQUEST messages without separating that state when the request principal changes.
RFC 4252 allows the user name and service name fields to change between authentication requests. The issue is not that such changes are invalid. The issue is that russh-owned authentication state, such as remaining methods, partial-success state, and in-progress method state, can remain associated with the connection and then influence a later request for a different (user, service).
This is an internal library state mismatch. Applications are responsible for any authentication state they keep in their own handlers, but russh must reset or separate state that russh itself owns.
Details
The relevant server-side auth logic is in:
russh/src/server/encrypted.rsrussh/src/auth.rs
RFC 4252 section 5 says the user name and service name fields are repeated in every SSH_MSG_USERAUTH_REQUEST and may change. It also says the server implementation must check those fields in every message and flush accumulated authentication state if they change; if it cannot flush that state, it must disconnect.
In vulnerable russh code, the username and service are decoded from each SSH_MSG_USERAUTH_REQUEST, while the AuthRequest state remains connection-scoped. That state includes:
methods, which is later encoded as theSSH_MSG_USERAUTH_FAILUREremaining-methods list.partial_success, which is later encoded inSSH_MSG_USERAUTH_FAILURE.current, which tracks in-progress method state such as public-key offer or keyboard-interactive challenge state.rejection_count.
If one request narrows russh's internal methods set, a later request for a different user can observe that narrowed set unless the internal state is reset at the principal boundary.
PoC
The PoC demonstrates only russh-owned state. The handler does not store any cross-request state. Alice's request narrows russh's remaining methods to password; Bob's later plain reject should not reuse that internal state.
struct RemainingMethodsUserSwitchServer;
impl server::Handler for RemainingMethodsUserSwitchServer {
type Error = russh::Error;
async fn auth_none(&mut self, user: &str) -> Result<server::Auth, Self::Error> {
if user == "alice" {
Ok(server::Auth::Reject {
proceed_with_methods: Some(MethodSet::from(&[MethodKind::Password][..])),
partial_success: true,
})
} else {
Ok(server::Auth::reject())
}
}
}
#[tokio::test]
async fn auth_does_not_carry_remaining_methods_across_username_change() {
let alice = session.authenticate_none("alice").await.unwrap();
assert!(matches!(
alice,
client::AuthResult::Failure {
ref remaining_methods,
..
} if *remaining_methods == MethodSet::from(&[MethodKind::Password][..])
));
let bob = session.authenticate_none("bob").await.unwrap();
if let client::AuthResult::Failure {
remaining_methods, ..
} = bob {
assert!(
remaining_methods.contains(&MethodKind::PublicKey),
"server reused Alice's narrowed remaining methods for Bob: {remaining_methods:?}"
);
}
}
On upstream/main, this fails with:
server reused Alice's narrowed remaining methods for Bob: MethodSet([Password])
That failure is produced by russh's retained AuthRequest.methods; it does not depend on handler-owned MFA/session state.
Impact
Suggested provisional CVSS v3.1:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N- Score:
5.3
Reasoning:
AV:N: reachable by a remote SSH client during authentication.AC:L: the attack is a normal sequence of SSH user-auth packets.PR:N: the attacker does not need an already-authenticated SSH session.UI:N: no user interaction is required on the server side.S:U: the impact is within the vulnerable SSH server implementation.C:N: the narrow PoC does not disclose confidential data.I:L: russh-owned authentication state for one principal can affect the authentication flow for a different principal.A:N: the narrow PoC does not demonstrate an availability impact.
This report does not claim that username changes are inherently invalid, nor does it rely on application-owned authentication state being mishandled by the embedding server.
### Fix / Patch Direction The fix should update russh's internal userauth state handling so that accumulated russh-owned state is flushed or separated when (user, service) changes between SSH_MSG_USERAUTH_REQUEST messages.
The fix stores the last seen (user, service) on AuthRequest. When a new auth request arrives for a different principal, russh resets its internal auth state before dispatching the new request. This keeps username changes protocol-valid while preventing prior russh-owned auth state from carrying into the new principal.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
russh SSH server fails to reset internal authentication state when the authentication principal (user/service) changes across requests, violating RFC 4252.
Vulnerability
The russh SSH server library (versions prior to the fix) contains a state management flaw in the authentication path. The internal AuthRequest state, which tracks remaining methods, partial-success status, in-progress method state, and rejection count, is stored connection-scoped without being reset or separated when the (user name, service name) tuple changes between SSH_MSG_USERAUTH_REQUEST messages [1][2]. The relevant code resides in russh/src/server/encrypted.rs and russh/src/auth.rs. RFC 4252 explicitly requires that the server check these fields on every authentication request and flush accumulated state if they change; failure to do so violates the specification [2].
Exploitation
An unauthenticated network attacker who can open an SSH connection to a vulnerable server sends multiple SSH_MSG_USERAUTH_REQUEST messages. The attacker first sends a request for user Alice that narrows russh's internal remaining methods (e.g., down to password). Then, without closing the connection, the attacker sends a subsequent request for a different user such as Bob. Because russh does not reset its internal AuthRequest state when the principal changes, the narrowed method set from Alice's request is incorrectly applied to Bob's authentication attempt [1][2]. No special authentication or access is required beyond network reachability to the SSH port.
Impact
An attacker can leverage this state leakage to influence the authentication parameters presented to the server's user-defined handler for a different user. While the application's own authentication logic is unaffected by russh's internal state, the attacker may cause russh to emit a misleading SSH_MSG_USERAUTH_FAILURE (with incorrect remaining-methods or partial-success flags) or reuse in-progress public-key/ keyboard-interactive state across users [1][2]. This could lead to confusion in the server's authentication flow and, in combination with application-level bugs, potentially facilitate unauthorized access or denial of service. The severity is moderated because the impact depends on how the library state interacts with the application handler.
Mitigation
A fix has been issued by the russh maintainers and is available in the advisory at [2]. Users should update their russh dependency to the patched version recommended in the advisory. As of the publication date (2026-05-29), no workaround is published; applications that wrap the library cannot safely compensate for the missing internal state reset without modifying the library itself.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3Patches
422295fa5056dMerge commit from fork
2 files changed · +113 −35
russh/src/server/mod.rs+2 −2 modified@@ -1041,9 +1041,9 @@ async fn read_ssh_id<R: AsyncRead + Unpin>( read: &mut SshRead<R>, ) -> Result<CommonSession<Arc<Config>>, Error> { let sshid = if let Some(t) = config.inactivity_timeout { - tokio::time::timeout(t, read.read_ssh_id()).await?? + tokio::time::timeout(t, read.read_client_ssh_id()).await?? } else { - read.read_ssh_id().await? + read.read_client_ssh_id().await? }; let session = CommonSession {
russh/src/ssh_read.rs+111 −33 modified@@ -7,6 +7,8 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf}; use crate::Error; const SSH_ID_BUF_SIZE: usize = 256; +const SSH_ID_MAX_LINE_LEN: usize = 255; +const SSH_ID_MAX_PRE_BANNER_LINES: usize = 20; /// The buffer to read the identification string (first line in the /// protocol). Not sensitive data — just protocol version exchange. @@ -15,6 +17,7 @@ struct ReadSshIdBuffer { pub total: usize, pub bytes_read: usize, pub sshid_len: usize, + pub pre_banner_lines: usize, } impl ReadSshIdBuffer { @@ -29,8 +32,32 @@ impl ReadSshIdBuffer { sshid_len: 0, bytes_read: 0, total: 0, + pre_banner_lines: 0, } } + + fn line(&self) -> Option<(usize, usize)> { + if self.total < 2 { + return None; + } + #[allow(clippy::indexing_slicing)] // loop bounds keep i + 1 in range + for i in 0..self.total - 1 { + if self.buf[i] == b'\r' && self.buf[i + 1] == b'\n' { + return Some((i, i + 2)); + } + if self.buf[i + 1] == b'\n' { + return Some((i + 1, i + 2)); + } + } + None + } + + fn discard_line(&mut self) { + let remaining = self.total - self.bytes_read; + self.buf.copy_within(self.bytes_read..self.total, 0); + self.total = remaining; + self.bytes_read = 0; + } } impl std::fmt::Debug for ReadSshIdBuffer { @@ -113,45 +140,51 @@ impl<R: AsyncRead + Unpin> SshRead<R> { } } - #[allow(clippy::unwrap_used)] pub async fn read_ssh_id(&mut self) -> Result<&[u8], Error> { - let ssh_id = self.id.as_mut().unwrap(); + self.read_ssh_id_inner(true).await + } + + pub async fn read_client_ssh_id(&mut self) -> Result<&[u8], Error> { + self.read_ssh_id_inner(false).await + } + + async fn read_ssh_id_inner(&mut self, allow_pre_banner_lines: bool) -> Result<&[u8], Error> { + let ssh_id = self.id.as_mut().ok_or(Error::Inconsistent)?; loop { - let mut i = 0; - trace!("read_ssh_id: reading"); + let i = if let Some((line_len, bytes_read)) = ssh_id.line() { + ssh_id.bytes_read = bytes_read; + line_len + } else { + trace!("read_ssh_id: reading"); - #[allow(clippy::indexing_slicing)] // length checked - let n = AsyncReadExt::read(&mut self.r, &mut ssh_id.buf[ssh_id.total..]).await?; - trace!("read {n:?}"); + #[allow(clippy::indexing_slicing)] // length checked + let n = AsyncReadExt::read(&mut self.r, &mut ssh_id.buf[ssh_id.total..]).await?; + trace!("read {n:?}"); - ssh_id.total += n; - #[allow(clippy::indexing_slicing)] // length checked - { - trace!("{:?}", std::str::from_utf8(&ssh_id.buf[..ssh_id.total])); - } - if n == 0 { - return Err(Error::Disconnect); - } - #[allow(clippy::indexing_slicing)] // length checked - loop { - if i >= ssh_id.total - 1 { - break; + ssh_id.total += n; + #[allow(clippy::indexing_slicing)] // length checked + { + trace!("{:?}", std::str::from_utf8(&ssh_id.buf[..ssh_id.total])); + } + if n == 0 { + return Err(Error::Disconnect); } - if ssh_id.buf[i] == b'\r' && ssh_id.buf[i + 1] == b'\n' { - ssh_id.bytes_read = i + 2; - break; - } else if ssh_id.buf[i + 1] == b'\n' { - // This is really wrong, but OpenSSH 7.4 uses - // it. - ssh_id.bytes_read = i + 2; - i += 1; - break; + + if let Some((line_len, bytes_read)) = ssh_id.line() { + ssh_id.bytes_read = bytes_read; + line_len + } else if ssh_id.total >= SSH_ID_MAX_LINE_LEN { + return Err(Error::Version); } else { - i += 1; + trace!("bytes_read: {:?}", ssh_id.bytes_read); + continue; } - } + }; if ssh_id.bytes_read > 0 { + if ssh_id.bytes_read > SSH_ID_MAX_LINE_LEN { + return Err(Error::Version); + } // If we have a full line, handle it. if i >= 8 { // Check if we have a valid SSH protocol identifier @@ -163,11 +196,17 @@ impl<R: AsyncRead + Unpin> SshRead<R> { } } } + if !allow_pre_banner_lines { + return Err(Error::Version); + } + ssh_id.pre_banner_lines += 1; + if ssh_id.pre_banner_lines > SSH_ID_MAX_PRE_BANNER_LINES { + return Err(Error::Version); + } // Else, it is a "preliminary" (see // https://tools.ietf.org/html/rfc4253#section-4.2), // and we can discard it and read the next one. - ssh_id.total = 0; - ssh_id.bytes_read = 0; + ssh_id.discard_line(); } trace!("bytes_read: {:?}", ssh_id.bytes_read); } @@ -204,7 +243,46 @@ mod tests { let mut read = SshRead::new(data.as_bytes()); let received = read.read_ssh_id().await; - assert!(matches!(received.err(), Some(Error::Disconnect))); + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_ssh_id_accepts_maximum_line_length() { + let data = format!("SSH-2.0-{}\r\n", "A".repeat(245)); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await.unwrap(); + assert_eq!(received.len(), SSH_ID_MAX_LINE_LEN - 2); + } + + #[tokio::test] + async fn test_ssh_id_rejects_oversized_line_with_terminator() { + let data = format!("SSH-2.0-{}\r\n", "A".repeat(246)); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_ssh_id_rejects_too_many_pre_banner_lines() { + let data = format!( + "{}SSH-2.0-OpenSSH_10.2\r\n", + "debug\r\n".repeat(SSH_ID_MAX_PRE_BANNER_LINES + 1) + ); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_server_ssh_id_rejects_pre_banner_line() { + let data = "debug\r\nSSH-2.0-OpenSSH_10.2\r\n"; + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_client_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); } #[tokio::test]
311f6cf12901Merge commit from fork
2 files changed · +119 −2
russh/src/client/encrypted.rs+7 −2 modified@@ -170,8 +170,13 @@ impl Session { let n_prompts = map_err!(u32::decode(&mut r))?; // read prompts - let mut prompts = - Vec::with_capacity(n_prompts.try_into().unwrap_or(0)); + // Each prompt needs at least a 4-byte length plus a 1-byte echo flag. + let max_prompts = r.remaining_len() / 5; + let n_prompts = n_prompts as usize; + if n_prompts > max_prompts { + return Err(crate::Error::Inconsistent.into()); + } + let mut prompts = Vec::with_capacity(n_prompts); for _i in 0..n_prompts { let prompt = map_err!(String::decode(&mut r))?;
russh/src/client/mod.rs+112 −0 modified@@ -1664,6 +1664,118 @@ async fn reply<H: Handler>( session.client_read_encrypted(handler, pkt).await } +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::num::Wrapping; + use std::sync::Arc; + + use ssh_encoding::Encode; + use tokio::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; + + use super::*; + use crate::auth::{AuthRequest, Method}; + use crate::compression::{Compression, Decompress}; + use crate::kex::{KEXES, NONE}; + use crate::session::{CommonSession, Encrypted, EncryptedState, Exchange}; + use crate::{CryptoVec, cipher, mac}; + + struct TestHandler; + + impl Handler for TestHandler { + type Error = crate::Error; + + async fn check_server_key( + &mut self, + _: &ssh_key::PublicKey, + ) -> Result<bool, Self::Error> { + Ok(true) + } + } + + fn keyboard_interactive_session() -> ( + Session, + tokio::sync::mpsc::Sender<Msg>, + tokio::sync::mpsc::UnboundedReceiver<Reply>, + ) { + let config = Arc::new(Config::default()); + let (sender, receiver) = channel(config.channel_buffer_size); + let (reply_sender, reply_receiver) = unbounded_channel(); + let auth_request = AuthRequest::new(&Method::KeyboardInteractive { + submethods: String::new(), + }); + let session = Session::new( + config.window_size, + CommonSession { + auth_user: "user".to_owned(), + auth_attempts: 0, + auth_method: Some(Method::KeyboardInteractive { + submethods: String::new(), + }), + remote_to_local: Box::new(cipher::clear::Key), + encrypted: Some(Encrypted { + state: EncryptedState::WaitingAuthRequest(auth_request), + exchange: Some(Exchange::default()), + kex: KEXES.get(&NONE).unwrap().make(), + key: 0, + client_mac: mac::NONE, + server_mac: mac::NONE, + session_id: CryptoVec::new(), + channels: HashMap::new(), + last_channel_id: Wrapping(0), + write: Vec::new(), + write_cursor: 0, + last_rekey: russh_util::time::Instant::now(), + server_compression: Compression::None, + client_compression: Compression::None, + decompress: Decompress::None, + rekey_wanted: false, + received_extensions: Vec::new(), + extension_info_awaiters: HashMap::new(), + }), + config, + wants_reply: false, + disconnected: false, + buffer: Vec::new(), + strict_kex: false, + alive_timeouts: 0, + received_data: false, + remote_sshid: b"SSH-2.0-test".to_vec(), + packet_writer: PacketWriter::clear(), + }, + receiver, + reply_sender, + ); + (session, sender, reply_receiver) + } + + fn oversized_prompt_count_packet() -> Vec<u8> { + let mut packet = Vec::new(); + msg::USERAUTH_INFO_REQUEST_OR_USERAUTH_PK_OK + .encode(&mut packet) + .unwrap(); + "name".encode(&mut packet).unwrap(); + "instructions".encode(&mut packet).unwrap(); + "".encode(&mut packet).unwrap(); + u32::MAX.encode(&mut packet).unwrap(); + packet + } + + #[tokio::test] + async fn oversized_keyboard_interactive_prompt_count_is_rejected() { + let (mut session, _sender, mut replies) = keyboard_interactive_session(); + let mut handler = TestHandler; + let err = session + .process_packet(&mut handler, &oversized_prompt_count_packet()) + .await + .expect_err("malformed prompt count must fail"); + + assert!(matches!(err, crate::Error::Inconsistent)); + assert!(replies.try_recv().is_err(), "malformed packet must not emit a reply"); + } +} + fn initial_encrypted_state(session: &Session) -> EncryptedState { if session.common.config.anonymous { EncryptedState::Authenticated
b14354e819a7Merge commit from fork
3 files changed · +144 −6
russh/src/auth.rs+40 −0 modified@@ -226,14 +226,23 @@ pub enum Method { #[doc(hidden)] #[derive(Debug)] pub struct AuthRequest { + initial_methods: MethodSet, pub methods: MethodSet, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub partial_success: bool, pub current: Option<CurrentRequest>, + pub(crate) principal: Option<AuthPrincipal>, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub rejection_count: usize, } +#[doc(hidden)] +#[derive(Debug)] +pub(crate) struct AuthPrincipal { + user: String, + service: String, +} + #[doc(hidden)] #[derive(Debug)] pub enum CurrentRequest { @@ -252,22 +261,53 @@ pub enum CurrentRequest { } impl AuthRequest { + pub(crate) fn server(methods: MethodSet) -> Self { + Self { + initial_methods: methods.clone(), + methods, + partial_success: false, + current: None, + principal: None, + rejection_count: 0, + } + } + pub(crate) fn new(method: &Method) -> Self { match method { Method::KeyboardInteractive { submethods } => Self { + initial_methods: MethodSet::all(), methods: MethodSet::all(), partial_success: false, current: Some(CurrentRequest::KeyboardInteractive { submethods: submethods.to_string(), }), + principal: None, rejection_count: 0, }, _ => Self { + initial_methods: MethodSet::all(), methods: MethodSet::all(), partial_success: false, current: None, + principal: None, rejection_count: 0, }, } } + + pub(crate) fn bind_or_reset_principal(&mut self, user: &str, service: &str) -> bool { + match &self.principal { + Some(bound) if bound.user == user && bound.service == service => false, + _ => { + self.principal = Some(AuthPrincipal { + user: user.to_owned(), + service: service.to_owned(), + }); + self.methods = self.initial_methods.clone(); + self.partial_success = false; + self.current = None; + true + } + } + } }
russh/src/server/encrypted.rs+13 −6 modified@@ -333,12 +333,7 @@ fn server_accept_service( }) } - Ok(AuthRequest { - methods, - partial_success: false, // not used immediately anway. - current: None, - rejection_count: 0, - }) + Ok(AuthRequest::server(methods)) } impl Encrypted { @@ -359,6 +354,18 @@ impl Encrypted { debug!("name: {user:?} {service_name:?} {method:?}",); if service_name == "ssh-connection" { + { + let auth_request = if let EncryptedState::WaitingAuthRequest(ref mut a) = self.state + { + a + } else { + unreachable!() + }; + if auth_request.bind_or_reset_principal(&user, &service_name) { + auth_user.clear(); + } + } + if method == "password" { let auth_request = if let EncryptedState::WaitingAuthRequest(ref mut a) = self.state {
russh/tests/auth_state_reset.rs+91 −0 added@@ -0,0 +1,91 @@ +use std::sync::Arc; +use std::time::Duration; + +use russh::client; +use russh::keys::PrivateKey; +use russh::{MethodKind, MethodSet, server}; + +struct AcceptTestServerKey; + +impl client::Handler for AcceptTestServerKey { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::ssh_key::PublicKey, + ) -> Result<bool, Self::Error> { + Ok(true) + } +} + +struct RemainingMethodsUserSwitchServer; + +impl server::Handler for RemainingMethodsUserSwitchServer { + type Error = russh::Error; + + async fn auth_none(&mut self, user: &str) -> Result<server::Auth, Self::Error> { + if user == "alice" { + Ok(server::Auth::Reject { + proceed_with_methods: Some(MethodSet::from(&[MethodKind::Password][..])), + partial_success: true, + }) + } else { + Ok(server::Auth::reject()) + } + } +} + +#[tokio::test] +async fn auth_does_not_carry_remaining_methods_across_username_change() { + let mut server_config = server::Config::default(); + server_config.inactivity_timeout = None; + server_config.auth_rejection_time = Duration::from_millis(1); + server_config.auth_rejection_time_initial = Some(Duration::from_millis(1)); + server_config.keys.push( + PrivateKey::random(&mut rand::rng(), russh::keys::ssh_key::Algorithm::Ed25519).unwrap(), + ); + let server_config = Arc::new(server_config); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let running = server::run_stream(server_config, socket, RemainingMethodsUserSwitchServer) + .await + .unwrap(); + running.await + }); + + let mut session = client::connect( + Arc::new(client::Config::default()), + addr, + AcceptTestServerKey, + ) + .await + .unwrap(); + + let alice = session.authenticate_none("alice").await.unwrap(); + assert!( + matches!( + alice, + client::AuthResult::Failure { + ref remaining_methods, + .. + } if *remaining_methods == MethodSet::from(&[MethodKind::Password][..]) + ), + "unexpected Alice auth result: {alice:?}" + ); + + let bob = session.authenticate_none("bob").await.unwrap(); + if let client::AuthResult::Failure { + remaining_methods, .. + } = bob + { + assert!( + remaining_methods.contains(&MethodKind::PublicKey), + "server reused Alice's narrowed remaining methods for Bob: {remaining_methods:?}" + ); + } + + server.abort(); +}
3 files changed · +4 −4
cryptovec/Cargo.toml+1 −1 modified@@ -6,7 +6,7 @@ edition = "2024" license = "Apache-2.0" name = "russh-cryptovec" repository = "https://github.com/warp-tech/russh" -version = "0.60.3" +version = "0.61.0" rust-version = "1.85" [dependencies]
pageant/Cargo.toml+1 −1 modified@@ -6,7 +6,7 @@ edition = "2024" license = "Apache-2.0" name = "pageant" repository = "https://github.com/warp-tech/russh" -version = "0.2.0" +version = "0.2.1" rust-version = "1.85" [dependencies]
russh/Cargo.toml+2 −2 modified@@ -9,7 +9,7 @@ license = "Apache-2.0" name = "russh" readme = "../README.md" repository = "https://github.com/warp-tech/russh" -version = "0.60.3" +version = "0.61.0" rust-version = "1.85" [features] @@ -81,7 +81,7 @@ rand_core = { version = "0.10.0" } rand.workspace = true ring = { version = "0.17.14", optional = true } rsa = { version = "=0.10.0-rc.18", optional = true } -russh-cryptovec = { version = "0.60.3", path = "../cryptovec", features = [ +russh-cryptovec = { version = "0.61.0", path = "../cryptovec", features = [ "ssh-encoding", ] } russh-util = { version = "0.52.0", path = "../russh-util" }
Vulnerability mechanics
Root cause
"The `AuthRequest` state (methods, partial_success, current, rejection_count) is not reset when the `(user, service)` principal changes between `SSH_MSG_USERAUTH_REQUEST` messages, causing internal state from one authentication attempt to leak into a subsequent attempt for a different principal."
Attack vector
A remote attacker can send a sequence of `SSH_MSG_USERAUTH_REQUEST` messages with different usernames. The first request (e.g., for "alice") narrows russh's internal `methods` set via a `SSH_MSG_USERAUTH_FAILURE` response. A subsequent request for a different user (e.g., "bob") then observes that narrowed set instead of the full set of available methods, because russh does not flush its internal authentication state when the principal changes [ref_id=1][ref_id=2]. This violates RFC 4252 section 5, which requires the server to flush accumulated state when the user or service name changes.
Affected code
The vulnerability resides in the server-side authentication logic in `russh/src/server/encrypted.rs` and `russh/src/auth.rs`. The `AuthRequest` state (including `methods`, `partial_success`, `current`, and `rejection_count`) remains connection-scoped and is not reset when the `(user, service)` principal changes between `SSH_MSG_USERAUTH_REQUEST` messages [ref_id=1][ref_id=2].
What the fix does
The patch (commit `1947d6b1bb3a9d79840b5adf7eb9b33df2c6e4a7`) bumps the crate version to 0.61.0 [patch_id=3106498]. The advisory explains that the fix stores the last seen `(user, service)` on `AuthRequest` and resets russh's internal auth state (methods, partial_success, current, rejection_count) when a new request arrives for a different principal [ref_id=1][ref_id=2]. This ensures that prior russh-owned state does not carry over to a different user or service, while still allowing legitimate principal changes per RFC 4252.
Preconditions
- networkThe attacker must be able to open an SSH transport connection and send SSH_MSG_USERAUTH_REQUEST messages before authentication completes.
- authNo prior authentication or session is required; the attack is performed during the authentication phase.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.