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
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), aknown_hostsfile, 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.