curl · Report #4126768
SSH host-key policy is missing from the connection-reuse tuple: strict pins and knownhosts are silently skipped on reused SFTP/SCP connections
How it was found
(The core effect is a silent host-authentication bypass: a transfer whose
configured SSH host-key policy would reject the server is served anyway, with
exit code 0 and no warning, because it reuses a pooled connection established
under a weaker or absent policy. curl may assess this lower because it
requires two SSH transfers on one connection pool with differing policies —
but that is exactly what --next and every multi-transfer libcurl application
do.)
Affected version
curl 8.23.0, tag curl-8_23_0, commit 3163ec065643ba20709f190111f42c3e1fe95273.
The gap is also present in current master, 7ab9cad703 (8.23.1-DEV), and is
old: ssh_config_matches() has compared only the client key-pair paths since
it was introduced by the CVE-2022-27782 fix (1645e9b445, released 7.82.0),
and no host-key policy parameter has ever been part of the SSH reuse
identity. The host-key pin/knownhosts options themselves are much older
(CURLOPT_SSH_KNOWNHOSTS 7.19.6, CURLOPT_SSH_HOST_PUBLIC_KEY_MD5 7.16.4,
CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 7.79.0, CURLOPT_SSH_HOSTKEYFUNCTION
7.79.0), so the md5/knownhosts variants of this report predate 7.82.0.
Affected interface
Both the libcurl API and the curl command-line frontend.
- CLI: all transfers of one
curlinvocation share one multi handle and therefore one connection pool.curl sftp://host/a [policy A] --next sftp://host/b [policy B]reproduces the bypass with no unusual options. - API: two easy handles on one multi handle, or one easy handle reused
across
curl_easy_perform()calls (connection cache is retained whenCURLOPT_FRESH_CONNECT/FORBID_REUSEare not set).
Silently skipped on reuse: CURLOPT_SSH_KNOWNHOSTS,
CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, CURLOPT_SSH_HOST_PUBLIC_KEY_MD5,
CURLOPT_SSH_HOSTKEYFUNCTION (CLI: --knownhosts, --hostpubsha256,
--hostpubmd5, and a -k first transfer). Protocols: sftp:// and
scp://. The matcher lives in protocol-agnostic code (lib/url.c), so both
SSH backends (libssh2 and libssh) are affected; the PoC below uses libssh2
1.11.1.
Summary
For SFTP and SCP, libcurl verifies the server host key only inside the SSH
connect state machine. When a transfer reuses an existing pooled connection,
that state machine never runs — and the connection-reuse matcher for SSH
(ssh_config_matches()) compares only the client private/public key paths.
None of the host-key policy options (knownhosts file, SHA256 pin, MD5 pin,
host-key callback) participate in the reuse decision.
The result: if transfer A on a pool accepts the host under a lax policy (an
accepting knownhosts file, -k, or — in the API — no policy at all, which the
libssh2 backend documents as accepted: "SSH: no knownhosts file configured"),
and transfer B to the same host:port with the same credentials and the same
client key configures a strict policy that would reject that same host key,
B silently rides A's connection. B's pin/knownhosts check is never evaluated,
the transfer succeeds, and curl exits 0.
This is the host-key half of the tuple that CVE-2022-27782 started fixing in
7.82.0; that fix covered only the client key pair (priv_key/pub_key).
Root cause
lib/url.c:569-579 — the entire SSH identity comparison applied when
deciding whether a pooled connection can be reused:
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);
}
It is consumed by the reuse matcher at lib/url.c:853-859
(url_match_proto_config()), which calls it for every SSH-family needle and
otherwise only checks the HTTP version. The host-key policy options live on
the easy handle (data->set.*) and are consumed exclusively inside the
connect state machines:
libssh2.c:481-589ssh_check_fingerprint()— SHA256/MD5 pin comparison,CURLOPT_SSH_HOSTKEYFUNCTIONcallback;libssh2.c:306-ssh_knownhost()— knownhosts file comparison (and atlibssh2.c:319-322: with no knownhosts configured, the libssh2 backend accepts any host key by default);libssh.cmyssh_is_known()— the libssh equivalents.
On reuse these never execute: Curl_connect() sets conn->bits.reuse
(lib/url.c:2358) and then skips Curl_conn_setup() — which owns the SSH
filter chain and its state machine — for reused connections
(lib/url.c:2508-2518). SFTP/SCP connections are pooled in the first place
because Curl_scheme_sftp/Curl_scheme_scp carry PROTOPT_CONN_REUSE
(lib/protocol.c).
The asymmetry with TLS is by design, not by accident: on TLS connections the
reuse matcher compares pinned_key, CAfile, CApath, ca_info_blob,
verifypeer, verifyhost, verifystatus, and more
(match_ssl_primary_config(), lib/vtls/vtls_config.c) — every parameter
that changes how the peer is authenticated. SSH compares none of its
equivalents. Function history: 1645e9b445 (CVE-2022-27782, client keys only)
→ removal in 3e9817cd1b → reinstated unchanged in 0618ffe50d (June 2026);
the 2026 connection-matching hardening commits (7c11f1a1bd, d37d79df1e,
c5f9b8b405, 7541ae569d, 72352daba0) each added a parameter to the
TLS/FTP/generic tuples and left ssh_config_matches() untouched.
Reproduction
I used an attached pair of harnesses with a one-shot loopback SFTP listener:
t1_sftp_server.py (a paramiko-based SFTP server with a fixed ed25519 host
key, public-key-only client auth, serving /a.txt and /b.txt, logging every
SSH session establishment) and t1_matrix.sh (the 8-mode matrix, one
command, full rerun), plus t1_api_reuse.c for the libcurl API leg.
t1_gen_keys.py creates the key material: a client key pair, a second client
key pair, a knownhosts file containing the real host key (kh_lax), one
containing a different key (kh_wrong), and the SHA256/MD5 pins of the real
and of the wrong key.
Build the client against curl 8.23.0 with an SSH backend:
git worktree add curl-8230 curl-8_23_0
cmake -S curl-8230 -B curl-8230/build -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo -DCURL_USE_OPENSSL=ON \
-DCURL_USE_LIBSSH2=ON -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF
cmake --build curl-8230/build -j
In terminal 1:
python3 t1_sftp_server.py
In terminal 2, the core exploit — transfer A verifies against a knownhosts file containing the real host key, then transfer B in the same invocation carries a SHA256 pin of a different key (guaranteed mismatch on a fresh connection):
./curl-8230/build/src/curl -v \
sftp://[email protected]:21032/a.txt \
--key t1keys/client_key --pubkey t1keys/client_key.pub \
--knownhosts t1keys/kh_lax -o /dev/null \
--next \
sftp://[email protected]:21032/b.txt \
--key t1keys/client_key --pubkey t1keys/client_key.pub \
--hostpubsha256 U1eyPFUfFCiydwk06rrVV0fzJZt+NFnxgMBFKM3LGHc=
Observed (verbose output, abridged):
* SSH: found host '127.0.0.1' in '.../kh_lax'
* SSH: knownhost entry matches host key
* SSH: authenticated via publickey
* Connection #0 to host 127.0.0.1:21032 left intact
* SSH: public key file '.../client_key.pub'
* SSH: private key file '.../client_key'
* Reusing existing sftp: connection with host 127.0.0.1
B-file: contents of b.txt
Exit code 0. Note what is absent from the second transfer's log: the lines
SSH: SHA256 public key '...' / fingerprint comparison that
ssh_check_fingerprint() always prints when it runs. B's pin was never even
looked at.
Control — the identical strict transfer on a fresh connection (same command
without the --next part):
curl: (60) Denied establishing ssh session: mismatch SHA256 fingerprint. Remote +GYld9L+5Tx7MghowSBPRGosG2dbWbNZb62imlzhUjs= is not equal to U1eyPFUfFCiydwk06rrVV0fzJZt+NFnxgMBFKM3LGHc=
Full 8-mode matrix (./t1_matrix.sh, one command):
M1-reuse-wrongSHA256 rc=0 reuse=1 A:knownhosts(real) B:--hostpubsha256 WRONG
M2-reuse-wrongMD5 rc=0 reuse=1 A:knownhosts(real) B:--hostpubmd5 WRONG
M3-reuse-wrongknownhosts rc=0 reuse=1 A:knownhosts(real) B:--knownhosts WRONG-key
M4-reuse-A-insecure rc=0 reuse=1 A:-k B:--hostpubsha256 WRONG
--- controls ---
M5-fresh-wrongSHA256 rc=60 reuse=0 single transfer, wrong pin
M6-fresh-wrongknownhosts rc=60 reuse=0 single transfer, wrong knownhosts
M7-fresh-correctSHA256 rc=0 reuse=0 single transfer, correct pin (sanity)
M8-reuse-diffclientkey rc=60 reuse=0 A lax, B wrong pin AND different client key
M1-M4 all print * Reusing existing sftp: connection with host 127.0.0.1
and deliver B-file: contents of b.txt with exit 0: every strict policy is
silently skipped on reuse. M5-M7 prove the same policies reject/accept
correctly on fresh connections. M8 proves the CVE-2022-27782 client-key
comparison still works (a different client key blocks reuse, forcing a fresh
connection, on which the pin then correctly fails the transfer) — isolating
the host-key policy gap as the only missing tuple element.
libcurl API leg (t1_api_reuse, two easy handles on one multi handle; A
sets no host-key policy at all, B sets the wrong SHA256 pin):
E2 fresh wrong-pin control: result=60 (peer verification) -- expect 60
E1 transfer A (no host-key policy): result=0 newconn=1
E1 transfer B (wrong sha256 pin): result=0 newconn=0 -- pin silently skipped
CURLINFO_NUM_CONNECTS == 0 for B is the reuse proof at API level; the
wrong pin returns success.
Suggested fix
Extend the SSH reuse identity with the host-key policy parameters, mirroring
what match_ssl_primary_config() does for TLS. In practice: store the
resolved policy on struct ssh_conn when ssh_setup_connection() builds the
connection metadata (it already stores priv_key/pub_key there), and
compare in ssh_config_matches():
CURLOPT_SSH_KNOWNHOSTS(path string, safecmp),CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256andCURLOPT_SSH_HOST_PUBLIC_KEY_MD5(presence and value),- presence of
CURLOPT_SSH_HOSTKEYFUNCTION(callback identity if practical), - optionally
CURLOPT_SSH_AUTH_TYPESandCURLOPT_SSH_COMPRESSIONfor completeness.
A mismatch should simply prevent reuse (the strict transfer then opens its own connection and runs its own verification), which is the same outcome the client-key comparison already produces.
A regression test can extend the existing SFTP pool-reuse coverage in the
python test suite (tests/http/test_51*, the suite that already asserts
total_connects == 1 for SFTP reuse): transfer A with an accepting policy,
transfer B with a mismatching CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256; assert
that B fails with CURLE_PEER_FAILED_VERIFICATION (and that a new connection
was made), matching the M1-vs-M5 contrast of the matrix above.
Impact
Any application that performs more than one SSH transfer per process (or per
connection pool) where the host-key policy differs between transfers gets the
weaker policy for all of them: the first transfer's policy decision is
irreversibly attached to the pooled connection, and later transfers'
CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256/_MD5, CURLOPT_SSH_KNOWNHOSTS and
CURLOPT_SSH_HOSTKEYFUNCTION settings are silently ignored. Concretely, an
attacker who controls the channel during transfer A (which may have run with
no verification, -k, an accept-all callback, or a knownhosts entry the
attacker satisfied) keeps that channel for transfer B even though B pinned
the genuine host key: B's uploads, downloads and any credentials B applies
flow over the attacker's connection, with curl reporting complete success.
Deployments that use SSH host-key pinning for compliance or because they assume hostile networks are the ones relying on exactly the guarantee that is skipped. The failure is silent on both ends: no log line, no error, exit. code 0.