HexBrain

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

N/Astatus
lowseverity
5upvotes
19 Oct 2026disclosed

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 curl invocation 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 when CURLOPT_FRESH_CONNECT/FORBID_REUSE are 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-589 ssh_check_fingerprint() — SHA256/MD5 pin comparison, CURLOPT_SSH_HOSTKEYFUNCTION callback;
  • libssh2.c:306- ssh_knownhost() — knownhosts file comparison (and at libssh2.c:319-322: with no knownhosts configured, the libssh2 backend accepts any host key by default);
  • libssh.c myssh_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_SHA256 and CURLOPT_SSH_HOST_PUBLIC_KEY_MD5 (presence and value),
  • presence of CURLOPT_SSH_HOSTKEYFUNCTION (callback identity if practical),
  • optionally CURLOPT_SSH_AUTH_TYPES and CURLOPT_SSH_COMPRESSION for 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.

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