GHSA-fw5c-8223-6h42 CVE-2026-116774
russh: peer-declared zero maximum packet size permanently stalls channel writes (silent AsyncWrite hang)
- Severity
- 4.3 / 10
- Medium · CVSS 3.1
- Exploit chance (EPSS)
- 0.26%
- Chance of exploitation in the next 30 days. Higher than 17.69% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-400 Uncontrolled Resource Consumption
- CWE-835 Loop with Unreachable Exit Condition ('Infinite Loop')
- Fix
0.65- Upgrade russh to this version or later
Description
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 asrecipient_maximum_packet_size(:25-30); no range check. Same forChannelOpenConfirmation::decodeatrussh/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, .. }, thenChannel::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) andChannelMsg::Open { max_packet_size: msg.maximum_packet_size, .. }, consumed bywait_channel_confirmation(russh/src/client/mod.rs:698-722), which buildsChannelWriteHalf { max_packet_size, .. }. - The write half hands the value to the writer:
make_writer_ext(russh/src/channels/mod.rs:433-441) andinto_stream(russh/src/channels/mod.rs:661-670) passself.max_packet_sizetoChannelTx::new.
(3) The hang - russh/src/channels/io/tx.rs:82-108 (current v0.64.1, byte-identical in v0.60.0):
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:
// 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 throughAsyncWrite(echo, shell prompts, SFTP - russh's own examples echo viainto_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 serverinactivity_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 clientinactivity_timeoutdefaults toNone, so nothing ever tears the session down. - This is distinct from a
window_size == 0stall: a zero window is dynamic flow control that a well-behaved peer releases as soon as its buffers drain, whilemaximum packet sizeis 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):
- Server: default config plus a host key;
channel_open_sessionaccepts and echoes usingtokio::io::copyintochannel.into_stream()(the pattern from russh's own examples). - Client: default config except
config.maximum_packet_size = 0(accepted silently; see above), thenauthenticate_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'schannel.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:
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 == 0withError::InconsistentinOpenChannelMessage::parseandChannelOpenConfirmation::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), whenself.max_packet_size == 0, surface anio::Errorfrompoll_writeinstead of returningPoll::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
Affected versions
crates.io
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:L/UI:N/S:U/C:N/I:N/A:L
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- Low
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- None
- Integrity
- None
- Availability
- Low
Weakness
CWE-400: Uncontrolled Resource Consumption
The product does not properly control the allocation and maintenance of a limited resource.
How to prevent it
- 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. 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.
- 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, or 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 is simply difficult to effectively institute -- and even when properly done, it does not provide a full solution. It simply makes the attack require more resources on the part of the attacker.
- Architecture and Design: Ensure that protocols have specific limits of scale placed on them.
- Implementation: Ensure that all failures in resource allocation place the system into a safe posture.
CWE-835: Loop with Unreachable Exit Condition ('Infinite Loop')
The product contains an iteration or loop with an exit condition that cannot be reached, i.e., an infinite loop.
References
Advisories
Fixes and patches
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why 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-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-4p9f-7p9m-5vfg russh's SSH client is susceptible to unbounded allocations via EXT_INFO packets | Medium | 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 |
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