TL;DR

CVE-2026-42189 is a pre-authentication DoS in russh, an actively maintained Rust SSH library. One malformed USERAUTH_INFO_RESPONSE packet (~50 bytes after encryption) triggers a multi-gigabyte allocation and OOM-kills any russh-based server running keyboard-interactive auth. No credentials needed.

CVSS 7.5. Affects russh ≤ 0.58.0 and Warpgate ≤ 0.23.0. Fixed in russh 0.60.1 and Warpgate 0.23.1.

I found it with cargo-fuzz. The harness design is what made it findable: the relevant attack surface lives behind key exchange, in a session state that has to be set up before any fuzzed bytes can reach it. Pointing libfuzzer at the parsers I could see from outside the library wasn’t going to find this. Pre-staging the session state inside the harness did, in about 20 minutes of run time.

Why russh

I’m working on ferrosync, a Rust implementation of rsync. The project is currently paused for a redesign, but the original goal is one I still care about: rsync that runs natively on Windows, without WSL or Cygwin in the way. SSH transport is a hard dependency for that, and russh was the option I went with.

Working on ferrosync, I hit a real bug. Windows’ VirtualLock was failing with ERROR_WORKING_SET_QUOTA on default systems, because the process minimum working set leaves no headroom for locked cryptographic pages. That turned into PR 661: Windows mlock hardening with working-set growth on demand, partial-rollback for failed locks, and a handful of overflow guards. Eugene merged it the day after I opened it.

So by the time I started looking at russh as a security target I’d already been reading the codebase carefully for weeks. I knew where the cipher and key exchange code lived. I knew the shape of the auth state machine.

Threat-modeling, not parser-spraying

Most “I added cargo-fuzz to project X” writeups follow the same recipe: find every public parser entry point, wrap each one in a fuzz target, press go. That produces a respectable harness, and it’s where I’d started in the past.

For russh, that approach hits a wall. The interesting bugs aren’t in the parsers reachable from outside the library. They’re behind the encrypted channel.

Here’s what an attacker actually does. They open a TCP connection. They complete the SSH key exchange using anonymous Diffie-Hellman, which costs them nothing because kex doesn’t authenticate the client. They’re now past the encryption layer and can send arbitrary post-kex packets to the server, and they haven’t presented credentials yet. The server’s auth state machine is the next thing they touch.

That’s the surface that matters, and it’s the surface fuzzing won’t reach without setup. libfuzzer’s mutator can’t manufacture a valid kex handshake on its own, so pointing it at the kex parser only finds bugs in the kex parser. Bugs that require being past kex stay invisible.

So I worked backwards from the goal: I wanted libfuzzer’s bytes to land in Session::process_packet, with the session already in the post-kex, pre-auth state, the cipher already set up, and the auth handler ready to receive input. A fuzzer at that point can explore most of the high-value attack surface in seconds.

The harness

The full branch is at coreyleavitt/russh tree/add-cargo-fuzz. It includes nine fuzz targets covering the obvious parser surfaces: kexinit on both sides, channel open messages, key parsing, certificate decoding, known_hosts pattern matching, ssh-id lines, agent responses, and russh-config parsing. Worth having. None of them are where this CVE came from.

The target that found CVE-2026-42189 is fuzz_server_packet. It’s two lines:

fuzz_target!(|data: &[u8]| {
    let _ = russh::fuzz_helpers::server_process_packet(data);
});

The work is in fuzz_helpers::server_process_packet, gated behind #[cfg(fuzzing)] so it doesn’t pollute the public API. It does one unobvious thing: it constructs a Session already in the post-kex, pre-auth state, with keyboard-interactive auth active and the cipher set to cipher::clear::Key. The bytes libfuzzer hands me go straight into the parser. There’s no encryption layer to negotiate.

fn make_session() -> Session {
    let encrypted = Encrypted {
        state: EncryptedState::WaitingAuthRequest(auth::AuthRequest {
            methods: MethodSet::all(),
            partial_success: false,
            current: Some(CurrentRequest::KeyboardInteractive {
                submethods: String::new(),
            }),
            rejection_count: 0,
        }),
        // ...cipher = clear, mac = NONE, kex = None...
    };
    Session {
        common: CommonSession {
            // ...all the fields process_packet expects...
        },
        // ...
    }
}

pub fn fuzz_server_process_packet(data: &[u8]) -> Result<(), crate::Error> {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_time()
        .build()?;
    rt.block_on(async {
        let mut session = make_session();
        let mut handler = FuzzHandler;  // returns Auth::Partial w/ prompts
        let _ = session.process_packet(&mut handler, data).await;
        Ok(())
    })
}

Two design choices made it work.

First, the cipher is set to cipher::clear::Key and the MAC to mac::NONE. russh has a no-encryption cipher implementation already. It has to: every real SSH connection starts in cleartext before kex completes. I borrowed that machinery so I didn’t have to write a fake encryption layer. The fuzzer’s bytes are processed as if they’re already valid post-decryption SSH packets.

Second, the session is staged in WaitingAuthRequest with a FuzzHandler whose auth_keyboard_interactive returns Auth::Partial. That’s the exact state a real server is in mid-2FA flow, after it’s sent the prompts to the client and is waiting for responses. Anyone running keyboard-interactive auth (the standard mechanism for TOTP and similar second-factor flows) hits this state on every login.

The fuzzer ran. Within twenty minutes, the container OOM-killed itself.

Triage

cargo-fuzz dropped a crash artifact into artifacts/fuzz_server_packet/. Six bytes: 3d ff ff ff ff ff. The first thing I did was check byte 0 against RFC 4252 and RFC 4256. 0x3d is decimal 61, which is SSH_MSG_USERAUTH_INFO_RESPONSE. That confirmed the fuzzer had found something in the auth path I’d staged for, not in some unrelated packet handler.

Next I needed to figure out what the remaining bytes meant in the context of that message type. RFC 4256 section 3.4 defines the wire format:

byte      SSH_MSG_USERAUTH_INFO_RESPONSE
int       num-responses
string    response[1]
...
string    response[num-responses]

So bytes 1 through 4 are the num-responses field. The artifact has ff ff ff ff there: 0xffffffff, or u32::MAX (4,294,967,295). Byte 5 is 0xff, trailing noise from the mutator that never gets read.

I pulled up read_userauth_info_response in russh/src/server/encrypted.rs and traced the path. u32::decode(r) reads the count, the cast to usize widens it, and Vec::with_capacity tries to reserve space for that many Option<Bytes> entries before the loop ever touches the packet body. ASAN’s output confirmed it: the allocator attempted 0x1fffffffe0 bytes. That’s u32::MAX times the 32-byte Option<Bytes> layout on x86-64, roughly 137 GB. The process died on the allocation, not on any subsequent parse.

The takeaway for triage was clean: the bug is a single unchecked client-controlled integer flowing into a capacity hint with no bound against the actual packet length. Everything after that, the loop, the response decoding, is irrelevant. The server is dead before it gets there.

The bug

The vulnerable code is in russh/src/server/encrypted.rs, in the function read_userauth_info_response:

let n = map_err!(u32::decode(r))?;
let mut responses = Vec::with_capacity(n as usize);
for _ in 0..n {
    responses.push(Bytes::decode(r).ok())
}

n is read from the client’s packet. It’s a u32, so up to 4.3 billion, and it’s passed straight to Vec::with_capacity with no bound on remaining packet size or any other ceiling.

A malicious client sends n = 0x10000000 (268 million) in a packet that’s otherwise empty. The server calls Vec::with_capacity(268_435_456). Each entry in the vector is an Option<Bytes>, which is roughly 24 bytes on a 64-bit platform. The allocation request is about 6.4 GB.

Most servers don’t have 6.4 GB of free RAM. The kernel OOM-killer fires, the russh process dies, every active SSH session on that server drops, and the attacker can reconnect faster than the server comes back up.

No credentials, no session. The n field is decoded before the auth handler ever validates anything.

Who’s affected

The default Handler::auth_keyboard_interactive in russh returns Auth::reject(), which means the vulnerable code path doesn’t trigger on a stock russh build. The bug only fires when a server actively returns Auth::Partial with prompts, which is the intended API for handlers implementing 2FA, TOTP, or any multi-step credential flow.

That’s why Warpgate is in the affected list. Warpgate is an SSH and HTTPS bastion built on russh by the same maintainer, and it uses keyboard-interactive prompts as part of its login UX. Anyone running Warpgate as a bastion host was vulnerable to a single-packet pre-auth crash of the bastion itself: the server you put in front of your real infrastructure can be killed by an unauthenticated attacker on a packet boundary.

Proof of concept

The repro is straightforward and was included in the private report:

  1. A minimal russh server with a Handler::auth_keyboard_interactive that returns Auth::Partial with prompts. Roughly 30 lines of code.
  2. A Python client that uses paramiko to do the SSH key exchange (so we get past kex without having to implement it ourselves), then sends a hand-crafted USERAUTH_INFO_RESPONSE with n = 0x10000000 and no response data after the count.
  3. A Docker container with a 512 MB memory cap. The russh server process inside the container exits 137 (SIGKILL from the OOM-killer) when the malformed packet arrives.

The full PoC is in the security advisory’s record and was provided to the maintainer as part of disclosure. I’m not publishing the runnable code here, since the public advisory already contains everything an operator needs to verify their patched version is actually patched.

The fix

The fix is a one-line bound on n:

let n = map_err!(u32::decode(r))?;
let max_responses = r.remaining_len().saturating_add(3) / 4;
let n = (n as usize).min(max_responses);
let mut responses = Vec::with_capacity(n);
for _ in 0..n {
    responses.push(Bytes::decode(r).ok())
}

Each response in the wire format needs at minimum a 4-byte length prefix, even when the response itself is empty. So if there are N bytes left in the packet, there can be at most N/4 responses no matter what the attacker claims. Bounding n against that gives a tight cap that doesn’t change behavior for well-formed packets, and defangs the malicious case to at most a packet-sized allocation.

The saturating_add(3) handles the edge case where remaining_len() isn’t a clean multiple of 4. Rounding up rather than down keeps the bound from truncating the last response in a borderline well-formed packet.

I implemented and tested this fix in a private fork while preparing the disclosure. It shipped in russh 0.60.1.

Disclosure

  • 2026-03-17: PR 661 opened.
  • 2026-03-18: PR 661 merged.
  • 2026-03-20: Vulnerability privately reported via GitHub Security Advisories, with a Docker-contained PoC and the proposed fix implemented.
  • 2026-04-20: Coordinated disclosure. Russh 0.60.1 and Warpgate 0.23.1 released. Advisory published.

Thirty-one days from private report to public advisory. That’s on the short end of “responsible” by industry convention, and it reflects how engaged Eugene Pankov was throughout. He responded quickly, evaluated the proposed fix on the merits, and shipped it without friction.

Solo-maintainer security disclosure has a reputation for being rough. Reports get ignored, argued with, patched in ways that introduce new bugs, or sat on for months. None of that happened here. The russh and Warpgate disclosure pipeline is in good hands.

What I’d take from this if I were you

A few things from this work that I think generalize:

Threat-model before you fuzz. The first question isn’t what parsers a library exposes. It’s what an attacker wants to do, and which code path gets them closest. The parsers behind that path are where the harness budget belongs.

Stage state past the wall. SSH, TLS, WireGuard, IPSec, anything with a handshake, has a phase where attackers pay no cost and a phase past it where the interesting bugs live. Building harnesses that pre-stage past the handshake is the difference between fuzzing 5% and 95% of the relevant code path. The pattern is generic: find the type that represents “this connection is set up,” construct one in test scaffolding, drive it from there.

Use the host project’s own machinery. russh already had cipher::clear for the pre-kex phase of real connections. I didn’t have to write a fake encryption layer. Most non-trivial protocol libraries have similar internal affordances if you read for them.

Maintainer relationships are a side effect, not a setup. PR 661 wasn’t strategic. I was fixing something I’d hit. But landing a substantive contribution before reporting a vulnerability changed the disclosure dynamic. Eugene already knew I’d read the code, and that my fix was reviewable. That trust matters when you’re asking a maintainer to take a security report seriously.

This is my first CVE. None of it required wizardry. It required picking a target with real motivation, reading the code carefully enough to threat-model it, designing a harness that puts the fuzzer where attackers actually live, and running the disclosure process the way the maintainer would want it run.

References

Thanks to Eugene Pankov for the maintainer-side work and responsiveness.