Where
-Infinity
0
Severity
4.3
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, the client Session::processpacket path in russh/src/client/encrypted.rs accepts repeated packets encoded with russh's private msg::EXTINFO = 7 before authentication, while the standards-compliant EXTINFO message number 82 does not reach the affected handler. The handleextinfo function appends every parsed extension name to receivedextensions 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.

First published (updated )
Severity
3.3
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to 0.65.0, russh::keys::agent::server::serve in russh/src/keys/agent/server.rs accepts repeated SSHAGENTCONSTRAINCONFIRM records while adding a key and appends every Constraint::Confirm record to the key's retained constraint vector without a count or memory limit. A process that can connect to the agent socket, including the remote side of a forwarded agent session, can repeatedly add constrained keys and cause renewable memory growth that persists for the process lifetime because keys without a lifetime constraint do not expire. This issue is fixed in version 0.65.0.

First published (updated )
Severity
4.3
AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

Summary

russhkeys::agent::server::addkey (russh/src/keys/agent/server.rs:271-319) spawns a dedicated tokio task for every 5-byte SSHAGENTCONSTRAINLIFETIME record attached to an added key, each cloning the key blob and sleeping for the declared lifetime (up to ~136 years). The loop has no per-request task budget, and spawned tasks are not rolled back when the overall request fails. Repeating one constrained add-key message over an agent connection converts wire bytes into resident tasks and heap at roughly 141x amplification: in our measurement 10.2 MiB of protocol traffic produced ~2,096,120 live tasks and +1,447,856 KiB RSS, while the same traffic sent as unconstrained add-key requests cost +268 KiB. An attacker reaching the agent socket (a local process, or a remote SSH server the user connects to with agent forwarding, since russh clients accept the auth-agent@openssh.com channel by default at russh/src/client/mod.rs:2686) can exhaust the agent process's memory and task capacity with a renewable, cheap request stream. Verified on russh HEAD efa9a03 (v0.63.3-10, 2026-10-01).

Details

rust // russh/src/keys/agent/server.rs:271-319 (addkey, constraint loop) while let Ok(t) = u8::decode(r) { match t { SSHAGENTCONSTRAINLIFETIME => { let lifetime = u32::decode(r)?; ... tokio::spawn({ let key = key.clone; async move { tokio::time::sleep(...lifetime...).await; / remove key / } }); } ...

Each lifetime record clones the key blob into a new task regardless of how many records the same request already spawned, and a failure later in the request leaves earlier tasks alive. Data flow: agent connection -> SSHAGENTCADDIDENTITYCONSTRAINED -> constraint decode loop -> unbounded tokio::spawn per 5 bytes. The control measurement (identical wire volume as unconstrained adds) isolates the constraint path as the amplifier.

Proof of concept

Prerequisites: Linux host with cargo and git. The archive's deploy script provisions russh at the pinned revision and the PoC crate (rewriting the path dependency); the PoC runs a real russh agent server on loopback and speaks the agent protocol to it.

bash $ POCHOME=$HOME/russh-poc RUSSHREPO=/home/ubuntu/HuntTargets/russh ./deployment/deploy.sh russh at efa9a03 -> /home/ubuntu/russh-poc/poc

$ POCHOME=$HOME/russh-poc ./poc/poc.sh 40 [] round 29: reply code=6 RSS=1090272 KiB (delta +1087580 KiB) [] round 39: reply code=6 RSS=1450548 KiB (delta +1447856 KiB) [] final RSS = 1450548 KiB (delta +1447856 KiB, x141 amplification by wire bytes) [] ~2096120 tasks now sleep for ~136 years holding their blob + timer. [] fds = 19

The broker acks every request (code 6 = SUCCESS) while RSS grows to +1.45 GB from ~10 MiB of input. The harness's control mode (same wire volume, unconstrained adds) measured +268 KiB. The automated archive run reproduces exactly this trace.

Manual reproduction, step by step (no archive required)

1. Provision russh at the pinned revision and build the PoC crate (it hosts a real russh agent server in-process):

bash git clone https://github.com/Eugeny/russh && cd russh && git checkout efa9a03e541b69505d5e6ec0d9213d011c9f6310 cargo build --release # in the PoC crate directory; the path dependency points at the clone

2. Run the PoC: it starts the agent on loopback, then sends repeated SSHAGENTCADDIDENTITYCONSTRAINED messages whose constraint sections contain ~52,000 five-byte SSHAGENTCONSTRAINLIFETIME records each:

bash ./target/release/poc-agent-addkey 40 [] round 29: reply code=6 RSS=1090272 KiB (delta +1087580 KiB) [] round 39: reply code=6 RSS=1450548 KiB (delta +1447856 KiB) [] final RSS = 1450548 KiB (delta +1447856 KiB, x141 amplification by wire bytes) [] ~2096120 tasks now sleep for ~136 years holding their blob + timer.

3. Control: the same wire volume sent as UNCONSTRAINED add-key requests measures +268 KiB (MODE=control), isolating the constraint-record task spawn as the amplifier.

4. Reachability: any local process with access to the agent socket, or a remote SSH server the user connects to with agent forwarding (russh clients accept auth-agent@openssh.com channels by default, russh/src/client/mod.rs:2686), can drive this. Each spawned task clones the key blob and sleeps for the declared lifetime (up to ~136 years); tasks are never rolled back when a request fails.

Impact

An attacker able to speak the agent protocol to a russh-based agent (local process on the socket, or a remote SSH server the user connects to with agent forwarding enabled) can drive gigabyte-scale memory growth and millions of leaked tasks per session with megabytes of traffic, renewable until the agent process is starved or OOM-killed, taking the user's SSH key agent down. The trust boundary is the agent protocol peer versus the agent process's resources. No confidentiality or integrity impact.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Vulnerability

The hybrid ML-KEM 768 + X25519 key exchange implementation in russh/src/kex/hybridmlkem.rs does 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 (serverdh, lines 92-93): rust let mut cpk1 = MontgomeryPoint([0; 32]); cpk1.0.copyfromslice(cpk1bytes); // No zero-point check - proceeds to: let kcl = ssecret cpk1;

Client-side (computesharedsecret, lines 154-155): rust let mut spk1 = MontgomeryPoint([0; 32]); spk1.0.copyfromslice(spk1bytes); // No zero-point check - proceeds to: let kcl = x25519secret spk1;

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.rs at two locations: - serverdh line 77: if clientpubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); } - computesharedsecret line 122: if remotepubkey.0 == [0u8; 32] { return Err(crate::Error::Kex); }

The same X25519 scalar multiplication pattern appears in hybridmlkem.rs, but the fix was not applied there.

Proof of Concept

A malicious SSH client negotiating mlkem768x25519-sha256 can send a KEXHYBRIDINIT message containing a valid ML-KEM 768 encapsulation key followed by 32 zero bytes as the X25519 component.

When the server computes kcl = ssecret cpk1, the result is the zero point regardless of the server's secret scalar. The combined shared secret K = SHA-256(kpq || kcl) 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: 1. If ML-KEM 768 is later found to have a weakness (the explicit threat model hybrid KEX is designed to mitigate), sessions where the X25519 component was zeroed out lose their fallback protection. 2. An active network attacker who can intercept KEX could downgrade the hybrid exchange to effectively single-algorithm security without either peer detecting it.

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 hybridmlkem.rs matching the ones in curve25519.rs:

rust // In serverdh, after line 93: let mut cpk1 = MontgomeryPoint([0; 32]); cpk1.0.copyfromslice(cpk1bytes); if cpk1.0 == [0u8; 32] { return Err(Error::Kex); }

// In computesharedsecret, after line 155: let mut spk1 = MontgomeryPoint([0; 32]); spk1.0.copyfromslice(spk1bytes); if spk1.0 == [0u8; 32] { return Err(Error::Kex); }

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.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203