russh: peer-declared zero maximum packet size permanently stalls channel writes (silent AsyncWrite hang)
Exact source revision recorded 30 Oct 2026 23:47:35 UTC. It does not establish the source's current position. Latest evidence.
osv · GHSA-fw5c-8223-6h42
Published## Summary A peer can advertise `maximum packet size = 0` in its channel-open message (`CHANNEL_OPEN` when attacking a server, `CHANNEL_OPEN_CONFIRMATION` when attacking a client). russh accepts the value without validation and copies it into the channel write state, where it caps the size of every outgoing data chunk. In the `AsyncWrite` code path (`ChannelTx::poll_writable`, used by `Channel::data()`, `Channel::make_writer()` and `Channel::into_stream()`), a cap of 0 makes every write future stay `Poll::Pending` forever: `writable = max_packet_size.min(window_size).min(buf_len)` is always 0, and no future event can ever raise it (a static maximum packet size of 0 can never be satisfied by any amount of peer-granted window). The write neither completes nor errors, so the application task hangs silently. Every other write path in russh already treats `max_packet_size == 0` as an error (`Error::Inconsistent`): `Channel::reserve_writable_chunk` (`russh/src/channels/mod.rs:363-365`) and the session packetizers (`russh/src/session.rs:489-492` and `549-552`). The `AsyncWrite` path is the only one that lacks the guard - it contradicts the crate's own invariant. ## Root cause (1) Peer value accepted without validation: - `russh/src/parsing.rs:44` (`OpenChannelMessage::parse`): `let maxpacket = map_err!(u32::decode(r))?;` stored as `recipient_maximum_packet_size` (`:25-30`); no range check. Same for `ChannelOpenConfirmation::decode` at `russh/src/parsing.rs:194-207`. - The only packet-size validation anywhere is an upper bound on the *local* config that merely logs (`russh/src/client/mod.rs:1189-1194`, `russh/src/server/mod.rs:912-917`). Nothing validates the peer's value - 0 passes silently. (2) The value flows into the channel write state verbatim: - Server: `russh/src/server/encrypted.rs:1877-1900` - `ChannelParams { recipient_maximum_packet_size: msg.recipient_maximum_packet_size, .. }`, then `Channel::new(..., channel_params.recipient_maximum_packet_size, ...)`. - Client: `russh/src/client/encrypted.rs:471-488` - `parameters.confirm(&msg)` (`russh/src/lib_inner.rs:519-523`) and `ChannelMsg::Open { max_packet_size: msg.maximum_packet_size, .. }`, consumed by `wait_channel_confirmation` (`russh/src/client/mod.rs:698-722`), which builds `ChannelWriteHalf { max_packet_size, .. }`. - The write half hands the value to the writer: `make_writer_ext` (`russh/src/channels/mod.rs:433-441`) and `into_stream` (`russh/src/channels/mod.rs:661-670`) pass `self.max_packet_size` to `ChannelTx::new`. (3) The hang - `russh/src/channels/io/tx.rs:82-108` (current v0.64.1, byte-identical in v0.60.0): ```rust fn poll_writable(&mut self, cx: &mut Context<'_>, buf_len: usize) -> Poll<NonZeroUsize> { ... let writable = (self.max_packet_size).min(*window_size).min(buf_len as u32) as usize; match NonZeroUsize::try_from(writable) { Ok(w) => { *window_size -= writable as u32; if *window_size > 0 { self.notify.notify_one(); } Poll::Ready(w) } Err(_) => { drop(window_size); ready!(self.window_size_notication.poll_unpin(cx)); self.window_size_notication = WatchNotification::new(Arc::clone(&self.notify)); cx.waker().wake_by_ref(); Poll::Pending } } } ``` With `max_packet_size == 0`, `writable` is always 0 regardless of `window_size` and `buf_len`; the task parks on the window notification, gets re-polled on every `CHANNEL_WINDOW_ADJUST`, recomputes 0, and parks again - forever. (4) The guard exists everywhere else: ```rust // russh/src/channels/mod.rs:363-365 (used by send_bytes -> data_bytes / extended_data_bytes) if self.max_packet_size == 0 { return Err(Error::Inconsistent); } // russh/src/session.rs:490-492 and :550-552 (packetizer) if max_packet_size == 0 { return Err(crate::Error::Inconsistent); } ``` Note that `Channel::data(reader)` (`russh/src/channels/mod.rs:321-323`) goes through `send_data` -> `tokio::io::copy(&mut data, &mut tx)` (`:350-360`) into the same `ChannelTx`, so the primary documented data API hangs as well; only the `*_bytes` variants reach the guard. ## Attack scenarios - **Server (authenticated remote DoS)**: with the default configuration, an authenticated client opens a session channel advertising `maximum packet size = 0`. Whenever the server application writes on that channel through `AsyncWrite` (echo, shell prompts, SFTP - russh's own examples echo via `into_stream()`), the write future never completes. The handler task is stranded and no error surfaces, so application cleanup paths do not trigger. Repeat per channel and per connection; keep-alive traffic resets the server `inactivity_timeout` (default 600 s), so stranded resources persist as long as the attacker wants. - **Client (malicious server)**: a malicious or buggy server confirms an opened channel with `maximum packet size = 0`; the client application's writes on that channel stall forever. The client `inactivity_timeout` defaults to `None`, so nothing ever tears the session down. - This is distinct from a `window_size == 0` stall: a zero window is dynamic flow control that a well-behaved peer releases as soon as its buffers drain, while `maximum packet size` is a static peer declaration where 0 can never be satisfied by any future packet. RFC 4254 section 5.1 recommends a minimum of 32768 - legitimate peers do not send 0. ## Impact Permanent, silent denial of service for the affected writes, with stranded tasks/channels/connections that accumulate for the attacker - resource exhaustion of a server or of a client application. Because the failure mode is `Poll::Pending` instead of `Err`, ordinary application mitigations (error handling, timeouts on writes, connection teardown) do not engage on their own. ## Reproducer Two stock russh instances are enough (no raw SSH crafting required): 1. Server: default config plus a host key; `channel_open_session` accepts and echoes using `tokio::io::copy` into `channel.into_stream()` (the pattern from russh's own examples). 2. Client: default config except `config.maximum_packet_size = 0` (accepted silently; see above), then `authenticate_publickey`, `channel_open_session().await`, `channel.data(&b"hello"[..]).await`. The client's `CHANNEL_OPEN` goes out with `maximum packet size = 0` (`russh/src/client/session.rs:21-44` encodes `self.common.config.maximum_packet_size` verbatim). All three scenarios below were verified end-to-end against a stock russh v0.64.1 build (ring backend), default settings otherwise: - control (both sides default, 32768): the server echoes and the client receives `hello`; - client `maximum_packet_size = 0`: the server prints the received bytes and then the echo write never completes - no data, no error, the client times out; - server `maximum_packet_size = 0`: the client's `channel.data(...)` future never completes - no error, it times out. Observed output (abridged): ``` === SCENARIO A (control): both sides default (maxpacket 32768) === [server] read 5 bytes; echoing via AsyncWrite ... [server] echo write completed RESULT A: echo received: "hello" === SCENARIO B: client advertises maximum_packet_size = 0 === [server] read 5 bytes; echoing via AsyncWrite ... RESULT B: TIMEOUT after 6s - server-side echo write is permanently stalled (no error, no data) === SCENARIO C: server advertises maximum_packet_size = 0 === RESULT C: TIMEOUT after 6s - client-side channel.data() is permanently stalled ``` The server task stays parked inside `ChannelTx::poll_writable`; nothing is logged and nothing is returned to the handler. The harness source (server: `into_stream()` echo; client: one config toggle) can be attached on request. Unit-level sketch of the core defect: ```rust let mut tx = ChannelTx::<Msg>::new( sender, ChannelId(0), Arc::new(Mutex::new(4096)), // window_size Arc::new(Notify::new()), // notify 0, // max_packet_size None, ); // poll_write(&mut tx, cx, b"x") stays Poll::Pending forever, // even after CHANNEL_WINDOW_ADJUST raises `window_size`. ``` ## Suggested fix - **Validate at parse time (preferred)**: reject `maximum_packet_size == 0` with `Error::Inconsistent` in `OpenChannelMessage::parse` and `ChannelOpenConfirmation::decode` (`russh/src/parsing.rs`), consistent with the existing guards; optionally clamp any value below the RFC 4254 recommended minimum (32768) instead. - **Defense in depth**: in `ChannelTx::poll_writable` (`russh/src/channels/io/tx.rs:82`), when `self.max_packet_size == 0`, surface an `io::Error` from `poll_write` instead of returning `Poll::Pending`. Both match the treatment PR #695 already applied to the other write paths in v0.61.0 (the `reserve_writable_chunk` and session packetizer guards). ## Affected versions Verified by inspecting the release artifacts: **0.60.0 through 0.64.1 (current) are affected** - `poll_writable` is byte-identical in v0.60.0 and the peer-value plumbing is present in all of them. The `ChannelTx` write logic and the peer-value flow predate 0.60 (they go back to the `channels::io` refactor), so we recommend treating all releases that ship `channels::io::ChannelTx` as affected. The zero-guards in the other write paths were only added in 0.61.0, so on 0.60.x those paths do not even fail with an error. ## Reporter Tshellauth
- Source published
- 30 Oct 2026 22:58:53 UTC
- Source modified
- 30 Oct 2026 23:15:05 UTC
- Observed 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
This view projects the publisher's source fields for reading; the original JSON retains additional fields and CVE enrichment containers. Attribution: osv · GitHub Advisory Database · CC-BY-4.0. Source text is retained; field grouping and change labels are provided by ChangeIntel.
CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L
Publisher rating · MODERATE