VYPR
Medium severity6.5NVD Advisory· Published Jun 10, 2026

CVE-2026-48107

CVE-2026-48107

Description

Russh SSH client vulnerable to denial-of-service via crafted USERAUTH_INFO_REQUEST from malicious server.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Russh SSH client vulnerable to denial-of-service via crafted USERAUTH_INFO_REQUEST from malicious server.

Vulnerability

Versions of the russh SSH client from 0.37.0 up to, but not including, 0.61.0 are vulnerable. A malicious SSH server can send a USERAUTH_INFO_REQUEST packet with an attacker-controlled prompt count. The client then uses this raw count directly in Vec::with_capacity without validating if enough prompt data is actually present in the packet [1].

Exploitation

An attacker must control the SSH server to which the russh client connects. The malicious server sends a USERAUTH_INFO_REQUEST packet specifying an excessively large number of prompts. The russh client will attempt to allocate a large vector based on this count before validating the packet's contents, potentially leading to resource exhaustion [1].

Impact

Successful exploitation of this vulnerability can lead to a denial-of-service condition on the russh client. The client may crash or become unresponsive due to excessive memory allocation attempts triggered by the malicious server's crafted packet [1].

Mitigation

This vulnerability has been patched in russh version 0.61.0, released on 2024-05-20. Users should upgrade to version 0.61.0 or later to address this issue [1].

AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Eugeny/Russhinferred2 versions
    >=0.37.0,<0.61.0+ 1 more
    • (no CPE)range: >=0.37.0,<0.61.0
    • (no CPE)range: >=0.37.0 <0.61.0

Patches

2
4186cf209e5b

Refactor block-cipher packet-length probing to avoid unsafe state duplication (#706)

https://github.com/Eugeny/russhMika CohenMay 16, 2026Fixed in 0.61.0via llm-release-walk
3 files changed · +206 11
  • russh/Cargo.toml+1 1 modified
    @@ -44,7 +44,7 @@ bytes.workspace = true
     cbc = { version = "0.1" }
     cbc_0_2 = { package = "cbc", version = "0.2.0" }
     cipher = "0.5.1" # only pinned due to a cargo-minimal-versions failure in 0.5.0
    -ctr = "0.9"
    +ctr = "0.9.2"
     ctr_0_10 = { package = "ctr", version = "0.10.0" }
     curve25519-dalek = "=5.0.0-pre.6"
     crypto-bigint = { version = "0.7.3", features = ["alloc"] }
    
  • russh/src/cipher/block.rs+134 9 modified
    @@ -34,10 +34,12 @@ fn new_cipher_from_slices<C: KeyIvInit>(k: &[u8], n: &[u8]) -> C {
         )
     }
     
    -pub struct SshBlockCipher<C: BlockStreamCipher + KeySizeUser + IvSizeUser>(pub PhantomData<C>);
    +pub struct SshBlockCipher<C: BlockStreamCipher + PacketLengthProbe + KeySizeUser + IvSizeUser>(
    +    pub PhantomData<C>,
    +);
     
    -impl<C: BlockStreamCipher + KeySizeUser + IvSizeUser + KeyIvInit + Send + 'static> super::Cipher
    -    for SshBlockCipher<C>
    +impl<C: BlockStreamCipher + PacketLengthProbe + KeySizeUser + IvSizeUser + KeyIvInit + Send + 'static>
    +    super::Cipher for SshBlockCipher<C>
     {
         fn key_len(&self) -> usize {
             C::key_size()
    @@ -78,7 +80,7 @@ impl<C: BlockStreamCipher + KeySizeUser + IvSizeUser + KeyIvInit + Send + 'stati
         }
     }
     
    -pub struct OpeningKey<C: BlockStreamCipher> {
    +pub struct OpeningKey<C: BlockStreamCipher + PacketLengthProbe> {
         pub(crate) cipher: C,
         pub(crate) mac: Box<dyn Mac + Send>,
     }
    @@ -88,7 +90,9 @@ pub struct SealingKey<C: BlockStreamCipher> {
         pub(crate) mac: Box<dyn Mac + Send>,
     }
     
    -impl<C: BlockStreamCipher + KeySizeUser + IvSizeUser> super::OpeningKey for OpeningKey<C> {
    +impl<C: BlockStreamCipher + PacketLengthProbe + KeySizeUser + IvSizeUser> super::OpeningKey
    +    for OpeningKey<C>
    +{
         fn packet_length_to_read_for_block_length(&self) -> usize {
             16
         }
    @@ -108,10 +112,7 @@ impl<C: BlockStreamCipher + KeySizeUser + IvSizeUser> super::OpeningKey for Open
                 #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
                 encrypted_packet_length[..4].try_into().unwrap()
             } else {
    -            // Work around uncloneable Aes<>
    -            let mut cipher: C = unsafe { std::ptr::read(&self.cipher as *const C) };
    -
    -            cipher.decrypt_data(&mut first_block);
    +            self.cipher.decrypt_packet_length_block(&mut first_block);
     
                 // Fine because of self.packet_length_to_read_for_block_length()
                 #[allow(clippy::unwrap_used, clippy::indexing_slicing)]
    @@ -213,6 +214,10 @@ pub trait BlockStreamCipher {
         fn decrypt_data(&mut self, data: &mut [u8]);
     }
     
    +pub(crate) trait PacketLengthProbe {
    +    fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]);
    +}
    +
     impl<T: StreamCipher> BlockStreamCipher for T {
         fn encrypt_data(&mut self, data: &mut [u8]) {
             self.apply_keystream(data);
    @@ -222,3 +227,123 @@ impl<T: StreamCipher> BlockStreamCipher for T {
             self.apply_keystream(data);
         }
     }
    +
    +impl<T: StreamCipher + Clone> PacketLengthProbe for T {
    +    fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) {
    +        let mut cipher = self.clone();
    +        cipher.apply_keystream(first_block);
    +    }
    +}
    +
    +#[cfg(test)]
    +mod tests {
    +    use aes::cipher::KeyIvInit;
    +    use aes::cipher::StreamCipher;
    +    use aes::Aes128;
    +    use aes::cipher::{IvSizeUser, KeySizeUser};
    +    use ctr::Ctr128BE;
    +    use digest::typenum::U16;
    +    use tokio::io::AsyncWriteExt;
    +
    +    use super::{BlockStreamCipher, OpeningKey, PacketLengthProbe};
    +    use crate::mac::MacAlgorithm;
    +    use crate::sshbuffer::SSHBuffer;
    +
    +    #[test]
    +    fn stream_cipher_probe_does_not_advance_cipher_state() {
    +        let plaintext = *b"0123456789ABCDEF";
    +        let key = fixture_bytes::<16>(7);
    +        let iv = fixture_bytes::<16>(3);
    +
    +        let mut encryptor = Ctr128BE::<Aes128>::new(&key.into(), &iv.into());
    +        let mut ciphertext = plaintext;
    +        encryptor.apply_keystream(&mut ciphertext);
    +
    +        let cipher = Ctr128BE::<Aes128>::new(&key.into(), &iv.into());
    +        let mut probed_block = ciphertext;
    +        cipher.decrypt_packet_length_block(&mut probed_block);
    +        assert_eq!(probed_block, plaintext);
    +
    +        let mut decrypted = ciphertext;
    +        let mut cipher_after_probe = cipher;
    +        cipher_after_probe.decrypt_data(&mut decrypted);
    +        assert_eq!(decrypted, plaintext);
    +    }
    +
    +    #[test]
    +    fn decrypt_packet_length_uses_independent_cipher_state() -> std::io::Result<()> {
    +        let runtime = tokio::runtime::Builder::new_current_thread()
    +            .enable_all()
    +            .build()?;
    +        let opening = OpeningKey {
    +            cipher: OwnedStateCipher::new(),
    +            mac: crate::mac::_NONE.make_mac(&[]),
    +        };
    +        let mut opening = opening;
    +        let mut buffer = SSHBuffer::new();
    +        let bytes_read = runtime.block_on(async {
    +            let (mut writer, mut reader) = tokio::io::duplex(64);
    +            writer.write_all(&[0; 17]).await?;
    +            drop(writer);
    +            crate::cipher::read(&mut reader, &mut buffer, &mut opening).await
    +        })
    +        .map_err(std::io::Error::other)?;
    +
    +        assert_eq!(bytes_read, 16);
    +        Ok(())
    +    }
    +
    +    struct OwnedStateCipher {
    +        packet_length: Box<[u8; 4]>,
    +    }
    +
    +    impl OwnedStateCipher {
    +        fn new() -> Self {
    +            Self {
    +                packet_length: Box::new([0, 0, 0, 13]),
    +            }
    +        }
    +    }
    +
    +    impl Clone for OwnedStateCipher {
    +        fn clone(&self) -> Self {
    +            Self {
    +                packet_length: Box::new([0, 0, 0, 12]),
    +            }
    +        }
    +    }
    +
    +    impl KeySizeUser for OwnedStateCipher {
    +        type KeySize = U16;
    +    }
    +
    +    impl IvSizeUser for OwnedStateCipher {
    +        type IvSize = U16;
    +    }
    +
    +    impl BlockStreamCipher for OwnedStateCipher {
    +        fn encrypt_data(&mut self, _data: &mut [u8]) {}
    +
    +        fn decrypt_data(&mut self, data: &mut [u8]) {
    +            if let Some(prefix) = data.get_mut(..4) {
    +                prefix.copy_from_slice(&self.packet_length[..]);
    +            }
    +        }
    +    }
    +
    +    impl PacketLengthProbe for OwnedStateCipher {
    +        fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) {
    +            if let Some(prefix) = first_block.get_mut(..4) {
    +                prefix.copy_from_slice(&[0, 0, 0, 12]);
    +            }
    +        }
    +    }
    +
    +    fn fixture_bytes<const N: usize>(seed: u8) -> [u8; N] {
    +        let mut bytes = [0; N];
    +        for (i, byte) in bytes.iter_mut().enumerate() {
    +            *byte = seed.wrapping_add(i as u8);
    +        }
    +        bytes
    +    }
    +}
    
  • russh/src/cipher/cbc.rs+71 1 modified
    @@ -7,7 +7,7 @@ use digest::crypto_common::InnerUser;
     #[allow(deprecated)]
     use digest::generic_array::GenericArray;
     
    -use super::block::BlockStreamCipher;
    +use super::block::{BlockStreamCipher, PacketLengthProbe};
     
     // Allow deprecated generic-array 0.14 usage until RustCrypto crates (cipher, cbc, etc.)
     // upgrade to generic-array 1.x. Remove this when dependencies no longer use 0.14.
    @@ -50,6 +50,20 @@ impl<C: BlockEncrypt + BlockCipher + BlockDecrypt> BlockStreamCipher for CbcWrap
         }
     }
     
    +impl<C: BlockEncrypt + BlockCipher + BlockDecrypt + Clone> PacketLengthProbe for CbcWrapper<C>
    +where
    +    C: BlockDecryptMut,
    +{
    +    fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) {
    +        let mut decryptor = self.decryptor.clone();
    +        for chunk in first_block.chunks_exact_mut(C::block_size()) {
    +            let mut block = generic_array_from_slice(chunk);
    +            decryptor.decrypt_block_mut(&mut block);
    +            chunk.copy_from_slice(&block);
    +        }
    +    }
    +}
    +
     impl<C: BlockEncrypt + BlockCipher + BlockDecrypt + Clone> InnerIvInit for CbcWrapper<C>
     where
         C: BlockEncryptMut + BlockCipher,
    @@ -62,3 +76,59 @@ where
             }
         }
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use aes::cipher::KeyIvInit;
    +    use aes::Aes128;
    +    #[cfg(feature = "des")]
    +    use des::TdesEde3;
    +
    +    use super::{BlockStreamCipher, CbcWrapper, PacketLengthProbe};
    +
    +    #[test]
    +    fn packet_length_probe_does_not_advance_cbc_decryptor_state() {
    +        let plaintext = *b"0123456789ABCDEF";
    +        let key = fixture_bytes::<16>(11);
    +        let iv = fixture_bytes::<16>(5);
    +
    +        let mut encryptor = CbcWrapper::<Aes128>::new(&key.into(), &iv.into());
    +        let mut ciphertext = plaintext;
    +        encryptor.encrypt_data(&mut ciphertext);
    +
    +        let cipher = CbcWrapper::<Aes128>::new(&key.into(), &iv.into());
    +        let mut probed_block = ciphertext;
    +        cipher.decrypt_packet_length_block(&mut probed_block);
    +        assert_eq!(probed_block, plaintext);
    +
    +        let mut decrypted = ciphertext;
    +        let mut cipher_after_probe = cipher;
    +        cipher_after_probe.decrypt_data(&mut decrypted);
    +        assert_eq!(decrypted, plaintext);
    +    }
    +
    +    #[cfg(feature = "des")]
    +    #[test]
    +    fn packet_length_probe_respects_3des_block_size() {
    +        let plaintext = *b"0123456789ABCDEF";
    +        let key = fixture_bytes::<24>(11);
    +        let iv = fixture_bytes::<8>(5);
    +
    +        let mut encryptor = CbcWrapper::<TdesEde3>::new(&key.into(), &iv.into());
    +        let mut ciphertext = plaintext;
    +        encryptor.encrypt_data(&mut ciphertext);
    +
    +        let cipher = CbcWrapper::<TdesEde3>::new(&key.into(), &iv.into());
    +        let mut probed_block = ciphertext;
    +        cipher.decrypt_packet_length_block(&mut probed_block);
    +        assert_eq!(probed_block, plaintext);
    +    }
    +
    +    fn fixture_bytes<const N: usize>(seed: u8) -> [u8; N] {
    +        let mut bytes = [0; N];
    +        for (i, byte) in bytes.iter_mut().enumerate() {
    +            *byte = seed.wrapping_add(i as u8);
    +        }
    +        bytes
    +    }
    +}
    
1947d6b1bb3a

v0.61.0

https://github.com/Eugeny/russhEugeneMay 20, 2026Fixed in 0.61.0via release-tag
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 russh client improperly handles an attacker-controlled prompt count in the keyboard-interactive authentication path, leading to excessive memory allocation."

Attack vector

A malicious SSH server can send a USERAUTH_INFO_REQUEST packet with an attacker-controlled prompt count. The client then uses this count directly in `Vec::with_capacity` before validating the actual prompt data present in the packet. This can lead to a denial-of-service or resource exhaustion on the client side [ref_id=1].

Affected code

The vulnerability exists in the `russh/src/client/encrypted.rs` file within the client's keyboard-interactive authentication path. Specifically, the code handling the `SSH_MSG_USERAUTH_INFO_REQUEST` message is affected, where the `n_prompts` value is read and used for vector allocation without sufficient validation [ref_id=1].

What the fix does

The patch introduces a check to ensure the declared number of prompts does not exceed the available data in the packet. Specifically, it calculates a maximum allowed prompt count based on the remaining packet length and rejects the packet if the declared count is greater than this maximum. This prevents the client from attempting to allocate excessive memory for prompts that are not actually provided [patch_id=5531340].

Preconditions

  • networkThe attacker must control an SSH server.
  • authThe victim must initiate a connection and proceed into keyboard-interactive authentication.

Reproduction

I added an in-tree regression test: client::tests::oversized_keyboard_interactive_prompt_count_is_rejected

The test builds a client session in WaitingAuthRequest(KeyboardInteractive) state, feeds it a synthetic USERAUTH_INFO_REQUEST packet with:

normal name normal instructions empty language tag n_prompts = u32::MAX no prompt bodies

On the fixed code, the client rejects the packet with Error::Inconsistent and does not emit a reply to the caller. For old-code impact verification, I also checked the pre-fix path separately with a constrained-memory repro. On unfixed upstream/main, the same malformed packet attempted a very large allocation and failed with: memory allocation of 137438953440 bytes failed

Relevant verification commands: cargo test -p russh oversized_keyboard_interactive_prompt_count_is_rejected -- --nocapture cargo test -p russh --lib --no-default-features --features ring oversized_keyboard_interactive_prompt_count_is_rejected -- --nocapture [ref_id=1]

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.