HexBrain

curl · Report #4124583

SSH (SCP/SFTP) host-key verification is skipped on connection reuse — a pinned/known_hosts transfer silently reuses a previously-unverified connection

N/Astatus
mediumseverity
3upvotes
20 Oct 2026disclosed

How it was found

Summary

For SCP/SFTP, curl verifies the server's host key once, during the initial connect state machine, in ssh_check_fingerprint() — which reads CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 / _MD5, the CURLOPT_SSH_KNOWNHOSTS file, and the CURLOPT_SSH_HOSTKEYFUNCTION callback. That function has exactly one caller, ssh_state_hostkey() (lib/vssh/libssh2.c:1461), reached only on a fresh connect.

The SSH connection-reuse gate, ssh_config_matches(), compares only the client identity key files:

/* lib/url.c:569-578 */
static bool ssh_config_matches(struct connectdata *one,
                               struct connectdata *two)
{
  struct ssh_conn *sshc1, *sshc2;
  sshc1 = Curl_conn_meta_get(one, CURL_META_SSH_CONN);
  sshc2 = Curl_conn_meta_get(two, CURL_META_SSH_CONN);
  return sshc1 && sshc2 && Curl_safecmp(sshc1->priv_key, sshc2->priv_key) &&
         Curl_safecmp(sshc1->pub_key, sshc2->pub_key);
}

None of the host-key verification inputs are part of this comparison (git grep for SSH_HOST_PUBLIC_KEY_SHA256|_MD5|SSH_KNOWNHOSTS|ssh_hostkeyfunc in lib/url.c returns 0 hits; the only ssh_auth_types hit at url.c:375 is default initialization, not a reuse comparison).

Finally, the reuse/attach operation performs no re-verification — it only re-points libssh2's abstract pointer at the new easy handle:

/* lib/vssh/libssh2.c:3910-3922 */
static void ssh_attach(struct Curl_easy *data, struct connectdata *conn)
{
  ...
    if(sshc && sshc->ssh_session) {
      void **abstract = libssh2_session_abstract(sshc->ssh_session);
      *abstract = data;   /* no ssh_check_fingerprint() here */
    }
}

Root cause

Host-key verification policy is treated as a connect-time action, not as part of the connection's security identity. Because the verification inputs are absent from ssh_config_matches() and ssh_attach() does not re-check, a connection established under one host-key policy can be handed to a later transfer that requested a stricter policy, and the stricter check never runs.

Attacker model / reachability

Two SCP/SFTP transfers in one process over a shared connection pool (one easy handle reused, a multi handle, or CURLSH with CURL_LOCK_DATA_CONNECT), to the same host/port/user with the same identity key files, where:

  • transfer #1 uses weak or no host-key verification (no known_hosts, no pin) — with no known_hosts configured, libssh2 accepts the presented host key (lib/vssh/libssh2.c:319-322); and
  • transfer #2 configures a host-key pin (CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256), a known_hosts file, or a host-key callback.

ssh_config_matches() returns TRUE (identity key files match), so transfer #2 reuses transfer #1's connection and ssh_check_fingerprint() is never called for it. If a man-in-the-middle substituted the server during transfer #1 (accepted because #1 did not verify), transfer #2 — which demanded a pin precisely to detect that — proceeds on the attacker-controlled connection. The host-key pin / known_hosts / callback is silently bypassed.

Live PoC (confirmed end-to-end)

Confirmed against curl built from this commit with the libssh2 backend (libssh2 1.11.2_DEV, built from source) talking to a paramiko SFTP server with a fixed host key. attachments/ssh_client.c performs two SFTP GETs on one easy handle (shared pool): transfer #1 with no host-key verification; transfer #2 with a deliberately wrong CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 pin. attachments/poc_ssh_hostkey.py drives it and compares the reuse case against a CURLOPT_FRESH_CONNECT control. Captured output (attachments/ssh_hostkey_transcript.txt):

################ EXPLOIT (fresh2=0): #2 reuses #1's pooled SFTP connection ################
[client] transfer 1  pin=(none)   fresh=0 -> rc=0 (No error)
[client] transfer 2  pin=AAAA...= fresh=0 -> rc=0 (No error)
  RESULT rc1=0 rc2=0
  >>> wrong host-key pin was NOT enforced on the reused connection

################ CONTROL (fresh2=1): #2 forced onto a FRESH connection ################
[client] transfer 1  pin=(none)   fresh=0 -> rc=0 (No error)
[client] transfer 2  pin=AAAA...= fresh=1 -> rc=60 (SSH remote key was not OK)
  RESULT rc1=0 rc2=60
  >>> on a fresh connection the wrong pin IS enforced

The exact same wrong SHA256 pin that correctly fails verification on a fresh connection (rc=60, CURLE_PEER_FAILED_VERIFICATION) is silently accepted when transfer #2 reuses transfer #1's connection (rc=0). That is the host-key verification bypass, demonstrated directly.

Reproduce

# build libssh2 (from source) and curl --with-libssh2, then:
gcc -I include attachments/ssh_client.c lib/.libs/libcurl.a \
    -L<libssh2-prefix>/lib -lssh2 -lssl -lcrypto -lbrotlidec -lbrotlicommon -lz -o ssh_client
pip install paramiko
python3 -I attachments/poc_ssh_hostkey.py   # edit SCRATCH path at top if needed

The pure-source verifier (attachments/verify.sh / verify-output.txt) remains included: it shows the single fingerprint-check call site, the fields compared for reuse, and the no-op attach path.

Suggested fix

Add the host-key verification inputs to the SSH reuse comparison — STRING_SSH_HOST_PUBLIC_KEY_SHA256, STRING_SSH_HOST_PUBLIC_KEY_MD5, STRING_SSH_KNOWNHOSTS, and the presence/identity of ssh_hostkeyfunc — in ssh_config_matches() (mirroring how priv_key/pub_key were added for CVE-2022-27782), or re-run ssh_check_fingerprint() on reuse in ssh_attach(). A connection must not be reused by a transfer whose host-key policy is stricter than the one the connection was established under.

Impact

Impact

A transfer that explicitly requests SSH host-key verification — a SHA256/MD5 host-key pin, a known_hosts file, or a host-key callback — can have that verification silently skipped when it reuses a pooled SCP/SFTP connection that was established by an earlier transfer under a weaker (or absent) host-key policy. Host-key verification is curl's defence against a man-in-the-middle on an SSH transfer; bypassing it on reuse defeats MITM detection for the transfer that asked for it.

Realistic shape: a process does an initial unverified SCP/SFTP operation (or one whose host-key was accepted with no known_hosts), then a second operation to the same host/user with a pin configured. The second operation inherits the first connection without re-checking the host key, so an attacker who was able to MITM the first (unverified) connection is not caught by the second transfer's pin.

What it is / is not

  • It is a host-key verification bypass on connection reuse — the SSH analogue of the TLS reuse tightening in CVE-2022-27782, whose SSH portion only added the identity key-file comparison and omitted the host-key verification inputs.
  • It is confirmed live end-to-end: a wrong host-key pin that rejects on a fresh connection (rc=60) is silently accepted on a reused connection (rc=0). Code trace, live PoC, and both controls are in attachments/.
  • It is not memory-unsafe.

Honest severity

Medium, matching the parent CVE-2022-27782. The effect is a security-control bypass (host-key verification / MITM detection), not merely a behavioural inconsistency, which is why I rate it above the other reuse-key gaps. It is now demonstrated at runtime, not only from the source.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original