fix(deps): update rust crate russh to 0.63.0 [security] - autoclosed - #346
Closed
renovate[bot] wants to merge 1 commit into
Closed
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
renovate
Bot
force-pushed
the
renovate/crate-russh-vulnerability
branch
from
October 1, 2026 08:34
4f114d0 to
30ce244
Compare
renovate
Bot
force-pushed
the
renovate/crate-russh-vulnerability
branch
from
October 1, 2026 09:29
30ce244 to
1374b4d
Compare
vdavid
added a commit
that referenced
this pull request
Oct 1, 2026
…osing the hybrid ML-KEM zero-point downgrade (GHSA-w3jg-pjxf-73p4) - Bump `russh` 0.62.7 → 0.63.0 in `cmdr-sftp` (resolves to 0.63.3, 2026-09-09). Fixes GHSA-w3jg-pjxf-73p4 (a malicious server could zero the X25519 half of `mlkem768x25519-sha256`, first patched in 0.63.0), plus GHSA-47hw-gvq5-r2gm (client handler callbacks reachable with unopened channel IDs) and GHSA-p8qx-h547-fjw9 (MAC-less negotiation panicking the session) from 0.63.1. - `Handler::check_server_key` now takes `PublicKeyOrCertificate`. `presented` judges a certificate by the key inside it, the one russh verified the exchange signature against; we advertise no certificate algorithms, so in practice only bare keys arrive. New unit test `no_host_certificate_algorithm_is_advertised` guards that assumption, and `cmdr-sftp/DETAILS.md` says why. - `russh`'s vendored `internal-russh-num-bigint` became upstream `num-bigint` 0.5.1, so `THIRD-PARTY-NOTICES.md` and `third-party-packages.gen.json` are regenerated. - Supersedes Renovate PR #346.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR contains the following updates:
0.62.7→0.63.0Russh: Missing X25519 zero-point validation in hybrid ML-KEM key exchange
CVE-2026-102824 / GHSA-w3jg-pjxf-73p4
More information
Details
Vulnerability
The hybrid ML-KEM 768 + X25519 key exchange implementation in
russh/src/kex/hybrid_mlkem.rsdoes not validate that the remote peer's X25519 public key is not the zero point (all-zero 32-byte value). This allows a remote peer to force the X25519 contribution to the combined shared secret to zero, reducing the hybrid KEX to a single-algorithm exchange.Affected code at HEAD (v0.62.4, commit 0089c89):
Server-side (
server_dh, lines 92-93):Client-side (
compute_shared_secret, lines 154-155):Root Cause
Commit a7fc1eb (2026-07-22, "fix mpint encoding and validate curve25519 keys") added zero-point validation to the standalone Curve25519 KEX in
russh/src/kex/curve25519.rsat two locations:server_dhline 77:if client_pubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); }compute_shared_secretline 122:if remote_pubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); }The same X25519 scalar multiplication pattern appears in
hybrid_mlkem.rs, but the fix was not applied there.Proof of Concept
A malicious SSH client negotiating
mlkem768x25519-sha256can send a KEX_HYBRID_INIT message containing a valid ML-KEM 768 encapsulation key followed by 32 zero bytes as the X25519 component.When the server computes
k_cl = s_secret * c_pk1, the result is the zero point regardless of the server's secret scalar. The combined shared secretK = SHA-256(k_pq || k_cl)then depends only on the ML-KEM component. The same attack works in reverse against a client connecting to a malicious server.The zero X25519 public key passes all existing validation (the length check on line 78 succeeds since 32 bytes is correct). No panic or crash occurs - the exchange completes successfully with a weakened shared secret.
Impact
The purpose of hybrid key exchange is defense-in-depth: if either the classical algorithm (X25519) or the post-quantum algorithm (ML-KEM 768) is broken, the combined secret remains secure. By injecting a zero X25519 public key, an attacker eliminates the classical contribution entirely, reducing security to depend solely on ML-KEM.
This matters in two scenarios:
The severity is MEDIUM rather than HIGH because the ML-KEM component still provides strong security today, and an active attacker who can modify KEX messages can already perform other attacks unless strict KEX is negotiated.
Suggested Fix
Add zero-point checks in
hybrid_mlkem.rsmatching the ones incurve25519.rs:Ideally, also validate against the other small-subgroup points on Curve25519 (there are a small number of low-order points that also yield a zero shared secret), matching the comprehensive validation OpenSSH performs.
AI tooling
AI assistancewas used for the code audit and for drafting this report. The finding was manually verified against the project's source at the location cited above before reporting it, and the severity and impact assessment are my own.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
russh: Client-side channel-scoped Handler callbacks fire for channel IDs the client never opened
CVE-2026-102823 / GHSA-47hw-gvq5-r2gm
More information
Details
Summary
CVE-2026-68930 was fixed by adding
Session::is_established_channel()inrussh/src/server/encrypted.rs, which gates every channel-scoped SERVER-side message (CHANNEL_REQUEST, CHANNEL_DATA, CHANNEL_EOF, CHANNEL_CLOSE, CHANNEL_WINDOW_ADJUST, CHANNEL_EXTENDED_DATA) onenc.channels.get(&channel).is_some_and(|c| c.confirmed)before invoking anyHandlercallback. The identical validation was never added to the CLIENT side (russh/src/client/encrypted.rs), which processes channel-scoped messages sent by the SSH SERVER once the client has authenticated.Details
In
client_read_authenticated(russh/src/client/encrypted.rs, ~lines 431-757), for CHANNEL_DATA, CHANNEL_EXTENDED_DATA, CHANNEL_EOF, CHANNEL_CLOSE, CHANNEL_OPEN_FAILURE, CHANNEL_SUCCESS, CHANNEL_FAILURE, and the CHANNEL_REQUEST sub-types exit-status/exit-signal/xon-xoff, the code only optionally forwards the event to the internal per-channel mpsc sender viaif let Some(chan) = self.channels.get(&channel_num) { ... }(a no-op if the channel is unknown), but then unconditionally calls the corresponding publicHandlertrait method (client.data(...),client.exit_status(...),client.channel_close(...),client.channel_success(...), etc.) regardless of whetherchannel_numcorresponds to any channel the client ever opened or that was ever confirmed. Only CHANNEL_OPEN_CONFIRMATION (closes the connection withError::Inconsistentif unknown) and CHANNEL_WINDOW_ADJUST (returns early withOk(())if unknown) correctly validate channel existence before acting.Corroborating evidence this check was intended but never wired up:
crate::Errordefines a dedicatedWrongChannelvariant documented as "Message received/sent on unopened channel" (russh/src/lib_inner.rs, ~line 144-146), yet a repo-wide search shows this variant is never constructed or returned anywhere in the codebase — dead code left over from (or intended for) exactly this validation.Because
Session::new_channel_id()(russh/src/session.rs, ~line 708) allocates channel IDs sequentially starting at 1, a malicious or compromised SSH server can trivially predict the ID of the client's next channel and inject spoofed lifecycle events for it before or interleaved with the real channel-open exchange, or replay events for already-closed channel IDs.PoC
Many real-world consumers of russh-as-a-client (deployment/orchestration tools, CI runners connecting to build/bastion hosts, git-over-ssh style tooling, database/tunnel clients) implement the
client::Handlertrait directly and key their own state (e.g.HashMap<ChannelId, CommandState>, exit-code trackers, per-channel byte counters, completion futures) off the channel IDs the library hands them, trusting the documented contract that events like "The remote process has exited" (exit_status) or "Called when the server closes a channel" (channel_close) only fire for a channel the application itself opened.A malicious, MITM'd (via a compromised/rogue jump host the client is configured to trust), or simply hostile SSH server can send
SSH_MSG_CHANNEL_REQUEST(exit-status/exit-signal),SSH_MSG_CHANNEL_DATA,SSH_MSG_CHANNEL_CLOSE,SSH_MSG_CHANNEL_SUCCESS/FAILURE, orSSH_MSG_CHANNEL_OPEN_FAILUREfor an arbitrary/predicted/never-opened channel ID at any point after authentication completes. Because the library invokes theHandlercallback unconditionally, this reaches application code with an ID it never registered.Impact
(1) A reliable, purely protocol-level trigger for an application panic/DoS in any client that indexes per-channel state by
ChannelIdwithout itself re-checking channel validity — the exact class of bug CVE-2026-68930 fixed server-side; and (2) lets the server spoof exit-status/exit-signal/close/success/failure notifications for a channel the client has not yet opened or has already released, desynchronizing the client's command-completion bookkeeping (e.g. reporting a forged exit code 0 for a not-yet-run remote command, or a prematurechannel_closebefore real output/exit-status has arrived) — a business-logic-level integrity violation of the SSH channel lifecycle that automation built on russh implicitly relies on.Suggested fix: add the same
is_established_channel()-style gate already used inserver/encrypted.rstoclient/encrypted.rs'sclient_read_authenticated, checkingself.channels.get(&channel_num)before invoking anyHandlercallback (not just the mpsc forward), for every channel-scoped message type.For credit/changelog purposes, please use: Yazan Balawneh, Cystack.ps
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
russh: negotiating a MAC-requiring block cipher (CTR/CBC) with mac=none causes a slice-index-out-of-range panic
CVE-2026-102822 / GHSA-p8qx-h547-fjw9
More information
Details
Summary
SshBlockCipherimplementations (AES-CBC, AES-CTR, 3DES-CBC, etc.) reportneeds_mac() == true, meaning they are documented/intended to always be paired with a separate integrity MAC. However, key-exchange negotiation only checksneeds_mac()inside the fallback branch of MAC algorithm selection (used when no common MAC algorithm exists). If both peers' preferred MAC lists simply containnoneand it is successfully negotiated through the normal selection path, nothing rejects pairingnonewith a cipher that requires a MAC. Once negotiated, a single crafted packet from either peer causescipher::read()to shrink an already-allocated buffer below the number of bytes it is about to index, causing a Rust slice-index-out-of-range panic and killing that connection's task.Details
In
russh/src/cipher/mod.rs,read()for a block cipher:packet_length_to_read_for_block_length()bytes up front (16 bytes for anySshBlockCipher) intobuffer.buffer.len.buffer.len = len + cipher.tag_len().buffer.buffer.resize(buffer.len + 4, 0).buffer.buffer[16..](via the constant used for the first block read) to continue decrypting/reading the rest of the packet.When the negotiated MAC is
none,tag_len() == 0. If the attacker (or a MITM holding the session key, or simply the accepting peer testing a hostile client) sends a packet whose decrypted length field is0, thenbuffer.len = 0andresize(0 + 4, 0)shrinks the buffer that was already grown to 16 bytes in step 1 down to 4 bytes. The subsequent slice operationbuffer.buffer[16..]then panics withrange start index 16 out of range for slice of length 4.The file already defines a
MINIMUM_PACKET_LENconstant, but it is only consulted on the write/padding side, never on the read path — so nothing prevents an incoming packet from declaring a length shorter than the bytes already buffered.Negotiation gap:
negotiation.rs'sSelectMAC-selection logic only special-casesneeds_mac()when negotiation would otherwise fail (no common MAC), substitutingnoneonly if the cipher does not need one. It never re-validates the case wherenoneis a common/successfully-negotiated MAC on both sides regardless of what the chosen cipher requires. So an application (or a malicious peer, since negotiation is attacker-influenced on one side) that includesnonein its own preferred MAC list — while still allowing the default CTR/CBC cipher suite — ends up with an invalid, panic-inducing combination that the library itself should refuse.PoC
Preferredconfig to includemac::NONEin the MAC list (this is a supported, non-default configuration exposed by the crate's publicPreferredAPI — used e.g. for legacy/interop compatibility), while leaving the default cipher list (which includesaes256-ctr/aes256-cbc) untouched.{cipher: aes*-ctr (or -cbc), mac: none}becausenoneis present and preferred/common on both sides, and nothing during negotiation rejects this pairing.0(trivial to construct once the session keys are known to that peer, or for the peer that legitimately owns the connection to simply hand-craft, e.g. a modified client for testing).cipher::read()on the receiving side panics:range start index 16 out of range for slice of length 4.tokio::spawn'ed task (see the per-connectionselect!loop that calls intocipher::read()), the panic unwinds only that task by default, but it unconditionally terminates that SSH connection/session — a working, currently-unauthenticated, already-established connection is killed with no attacker interaction beyond the one crafted packet, and the check runs on every inbound packet including pre-auth ones.Impact
Denial of service: a remote peer that can influence MAC preference negotiation (or a MITM in possession of the session key) can crash any individual SSH connection/session that ends up negotiating a block cipher together with
mac=none, with a single crafted packet, pre-authentication. This does not affect the process as a whole (panic is scoped to that connection's task underpanic=unwind), and does require a non-default configuration that permitsnoneas a preferred MAC — hence Low severity.Suggested fix
In the MAC-selection logic in
negotiation.rs, reject (or force substitution of)mac::NONEwhenever the negotiated cipher'sneeds_mac()istrue, regardless of hownonecame to be selected — not only in the "no common MAC" fallback branch. Defensively,cipher::read()could also refuse to shrink a buffer below the number of bytes already consumed for the length-field block, returning a protocol error instead of resizing blindly.For credit/changelog purposes, please use: Yazan Balawneh, Cystack.ps
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Russh: Unbounded memory exhaustion via CHANNEL_OPEN flood during a client-stalled rekey
CVE-2026-102821 / GHSA-35g8-35p8-c8fw
More information
Details
Summary
A russh server can be driven to unbounded heap growth (process OOM / kill) by
a peer that speaks only standard SSH messages, in the default configuration.
The peer starts a key re-exchange (sends
SSH_MSG_KEXINIT) but never sends thefollow-up
SSH_MSG_KEX_ECDH_INIT, leaving the server's kex state machine inSessionKexState::InProgressindefinitely. While a rekey is in progress theserver's three message-drain paths are all gated off (
if !self.kex.active()),but the network-read path stays active, so every
SSH_MSG_CHANNEL_OPENthe peersends is processed inline and appends one reply to an unbounded internal
queue (
priority_receiver, anUnboundedReceiver) that is not dequeued untilthe rekey completes. Because the peer decides whether the rekey ever completes,
the queue — and the server's memory — grows without bound.
This is reproducible end-to-end against a real russh server over a real
encrypted transport; see "Proof of concept". A one-line negative control (same
flood, no rekey) keeps memory flat, isolating the rekey window as the sole
trigger.
Impact
CHANNEL_OPEN, driven entirely by the peer, until the process is OOM-killed.In the reproduction one connection pushed the server from ~4 MB to 2.57 GB
(and past 4.8 GB against the stock
echoserverexample) and it was stillclimbing when the flood was stopped.
affected, including one that rejects every channel (the reply enqueued on
rejection is exactly what accumulates). There is no per-connection cap on
in-flight channel opens or on the queue, and the queue's sender has no
backpressure.
inactivity timer never fires.
Affected component
russh/src/server/session.rs—Session::runtokio::select!loop. Thenetwork-read arm is ungated; the three drain paths are gated on
!self.kex.active().russh/src/server/mod.rs—reply()routes a non-kex message received duringa rekey straight to
server_read_encrypted(inline processing).russh/src/lib_inner.rs—ChannelOpenHandleInner'saccept/reject/Dropall
senda reply on anUnboundedSender.Verified on v0.63.1 (commit
d3ae702), which is the latest release. Thegating logic predates it.
Details
Session::run(russh/src/server/session.rs:631) drives atokio::select!(
:713). Three of its message-drain sites are gated on!self.kex.active():select!batch drain ofpriority_receiver/receiver(
session.rs:680),priority_receiver.recv()arm (session.rs:762),receiver.recv()arm (session.rs:770, which also holds the only otherpriority_receiverdrain at:777).The fourth arm,
r = &mut reading(session.rs:714), is ungated: it readsand processes one incoming packet every loop iteration regardless of rekey
state, calling
reply()(server/mod.rs:1128).During a rekey,
session.common.encrypted.is_some(), so the strict-kexmessage-ordering guard (
server/mod.rs:1143, which is additionally gated onencrypted.is_none()) does not apply. A non-kex message therefore fallsthrough
reply()tosession.server_read_encrypted(handler, pkt)(
server/mod.rs:1232) and is handled inline. ForSSH_MSG_CHANNEL_OPENthisreaches the channel-open handling, which hands the application a
ChannelOpenHandle.Whether the handler accepts or (the trait default) rejects, the handle's
accept/reject/DropallsendaMsg::ChannelOpenReplyon anUnboundedSender(russh/src/lib_inner.rs:560-603;DropsendsAdministrativelyProhibitedat:594-603). That sender feedspriority_receiver, declaredUnboundedReceiver<Msg>(session.rs:23) andcreated with
tokio::sync::mpsc::unbounded_channel()(session.rs:1522). Itsonly drain sites are the three arms gated off during the rekey. So each
CHANNEL_OPENprocessed during the rekey window appends one reply (carrying aPendingChannelOpen= channel params + mpscChannelRef+ ids, a few KBretained in practice) to a queue that is never dequeued.
Two facts make this unbounded and remote:
InProgressthe moment it receives the peer'sKEXINIT(
server/mod.rs:1153-1158,begin_rekey) and only leaves it upon receivingthe peer's
KEX_ECDH_INIT. The peer decides whether to ever send that,so the window is attacker-held.
sender is unbounded (no backpressure).
Root cause
The intended design was to buffer packets received during a rekey and replay
them afterwards: the fields
pending_reads: Vec<Vec<u8>>andpending_len: u32(
session.rs:26-27) exist and are drained at kex completion(
server/mod.rs:1194-1198). But nothing ever pushes topending_readsorincrements
pending_len(they are dead — confirmed by grep acrossrussh/src/). Instead of being buffered, channel messages received during arekey are processed inline, and their replies pile up in the unbounded
priority_receiver. The missing piece is a bound on — or bounded deferral of —channel processing while
kex.active().Proof of concept
Everything runs inside a container; nothing touches the host.
Lab.
poc/Dockerfilebuildsrussh-lab:headfrom Eugeny/russh @d3ae702(v0.63.1), default features (rust 1.91). A raw-SSH-client PoC
(
poc/poc_rekey_dos.rs) implements curve25519-sha256 / ssh-ed25519 /aes256-ctr / hmac-sha2-256 by hand, completes the handshake and a
publickeyauth, then:
SSH_MSG_KEXINIT(server enterskex.active()),KEX_ECDH_INIT(rekey stalls, attacker-held),SSH_MSG_CHANNEL_OPEN.A minimal server (
poc/poc_server.rs) uses the trait-defaultchannel_open_session(which rejects by dropping the handle), so the measuredgrowth is purely the undrained priority queue, not accepted-channel state.
poc/run.shruns the attack leg and an identical negative control with norekey. Fresh run inside the lab,
N = 800,000opens(
results/rerun-2026-08-29.log):4,208 KB → 2,566,256 KB(~2.57 GB) and retained after theflood ends — ~3.3 KB per
CHANNEL_OPEN, attacker-driven. (An earlier canonicalrun reached 2.7 GB at 800k opens, and past 4.8 GB against the stock
echoserverexample at 1.5 M opens — seeresults/canonical-run.log.)4,208 KB. Without the rekey window the replies aredrained normally; TCP backpressure (the client never reads the failure
replies) even throttles the flood.
The control isolates the rekey window as the sole trigger. Both legs exercise
the real server entry point (
Session::run→reply→server_read_encrypted)over a real encrypted transport.
Reproduce:
C=russh-lab N=800000 ./poc/run.sh(seepoc/POC-README.md).Remediation
The priority queue carries locally generated channel-open replies, which are
non-kex messages the server must not send during a rekey anyway (RFC 4253
§7.1). So the fix is to bound how much channel work is done during a rekey, not
to drain the queue mid-rekey. Any of:
rekey is in progress and disconnect a peer that exceeds it. A stalled/abusive
rekey is then torn down after a small constant instead of growing memory
without bound. See
patch/rekey-message-cap.patch(a ~10-line change local toSession::run, validated in the lab — the attack leg is disconnected after thecap and RSS stays flat; a normal rekey, which completes in one round trip, is
unaffected).
(currently dead)
pending_reads/pending_lenmachinery, with a hard cap.OpenSSH does not service new channels mid-kex; matching that intent closes the
whole family.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Release Notes
warp-tech/russh (russh)
v0.63.2Compare Source
Security fixes
GHSA-g4mp-vgx3-xrvm - out-of-bounds read in
pageantA malicious Pageant agent could cause an out-of-bounds read / oversized allocation in the
pageantlibrary user.GHSA-35g8-35p8-c8fw - unbounded memory allocation in server
An authenticated client could trigger unbounded memory allocation during rekey phase
Fixes
4206815: Fix pty-req terminal modes: deliver them unpadded, encode the right l… (#755) (tluyben) #755b1d3893: fixed #762 - redact sensitive data from debug logging (Eugene)66789f4: fixed #761 - data write split across a kex breaks (Eugene)a04e1b5: fixed #758 - fail RSA signing explicitly when RSA feature is not enabled (Eugene)422123c: dedup zlib compress loop into compress_into (Eugene)v0.63.1Compare Source
Security fixes
GHSA-47hw-gvq5-r2gm - client-side Handler callbacks reachable with invalid channel IDs
A mirror of GHSA-m65r-rprj-r5rg for the client side -
Handlerper-channel callbacks are called even when the server supplies an invalid (never opened) channel ID. Depending on what the handler does this can lead to a vulnerability.GHSA-p8qx-h547-fjw9 - MAC-requiring block cipher can be negotiated without MAC and panic
Two peers disagreeing on supported MACs can end up negotiating
noneMAC for a cipher that requires one, which leads to the session task panicking.v0.63.0Compare Source
Features
09f6582: Support host certificates on the client side (#752) (@biao29) #752Handler::check_server_keyto take a newPublicKeyOrCertificateenum instead of&PublicKeyd7601ae: Support host certificates on the server side (#641) (Georg von Zengen) #641Config::certificatesthat functions similarly toConfig::keysFixes
f2354c7: improve strict kex checks (Eugene)0363fde: fixed PKCS#8 parsing panicking on incorrect contents (Eugene)46c927a: use constant-time comparison for agent unlock (Eugene)8da8967: sanitize Curve25519 params (Eugene)Full Changelog: Eugeny/russh@v0.62.7...v0.63.0
Configuration
📅 Schedule: (in timezone Europe/Budapest)
🚦 Automerge: Enabled.
♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR was generated by Mend Renovate. View the repository job log.