curl · Report #3640932
Missing server identity policy enforcement in SSH connection reuse allows host key verification bypass via pool poisoning
How it was found
Missing server identity policy enforcement in SSH connection reuse allows host key verification bypass via pool poisoning
Summary
ssh_config_matches() in lib/url.c decides whether an existing SSH connection can be reused by a new transfer handle. It checks client key paths (rsa, rsa_pub) but never inspects the three options that control server identity verification: STRING_SSH_KNOWNHOSTS, STRING_SSH_HOST_PUBLIC_KEY_MD5, and STRING_SSH_HOST_PUBLIC_KEY_SHA256. A handle configured with strict host key pinning reuses a pooled connection that was established with zero server verification — the pinning check never fires because the SSH handshake is skipped entirely.
This is the same bug class as CVE-2022-27782 and CVE-2023-27538. Both fixed client-side key options. The server-side identity options were never added.
Vulnerability Details
- Vulnerability Type: Authentication Bypass (Connection Pool Poisoning)
- CVSS 3.1 Score: 7.5 (High) —
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N - Affected Component:
lib/url.c→ssh_config_matches() - Affected Protocols: SFTP, SCP
Root Cause
The full function body at lib/url.c:
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->rsa, sshc2->rsa) &&
Curl_safecmp(sshc1->rsa_pub, sshc2->rsa_pub);
}
Three data->set.str[] entries are absent from this comparison:
| Option | String Key | Purpose |
|---|---|---|
CURLOPT_SSH_KNOWNHOSTS | STRING_SSH_KNOWNHOSTS | Path to known_hosts file for server fingerprint validation |
CURLOPT_SSH_HOST_PUBLIC_KEY_MD5 | STRING_SSH_HOST_PUBLIC_KEY_MD5 | Expected MD5 hash of server's public key |
CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 | STRING_SSH_HOST_PUBLIC_KEY_SHA256 | Expected SHA-256 hash of server's public key |
For contrast, the TLS backend enforces full parity on security-relevant fields during connection reuse — verifypeer, verifyhost, pinned certificates are all compared. The SSH backend has no equivalent enforcement.
Steps to Reproduce
Environment:
- libcurl built with libssh2 (default SSH backend)
- Two easy handles sharing one connection pool via
CURLSH - Target: any SFTP server (e.g.
test.rebex.net, public demo)
./poc
Source (poc.c):
#include <curl/curl.h>
#include <stdio.h>
int main(void) {
CURL *relaxed, *strict;
CURLSH *pool;
long new_connections = 0;
curl_global_init(CURL_GLOBAL_DEFAULT);
pool = curl_share_init();
curl_share_setopt(pool, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
relaxed = curl_easy_init();
strict = curl_easy_init();
if(relaxed && strict && pool) {
/* --- Transfer 1: no host verification --- */
curl_easy_setopt(relaxed, CURLOPT_URL, "sftp://demo:[email protected]/readme.txt");
curl_easy_setopt(relaxed, CURLOPT_SHARE, pool);
// Silencing stderr for clean output
curl_easy_setopt(relaxed, CURLOPT_NOBODY, 1L);
fprintf(stderr, "[*] Transfer 1 — no host key checks\n");
curl_easy_perform(relaxed);
/* --- Transfer 2: strict host key pinning requested --- */
curl_easy_setopt(strict, CURLOPT_URL, "sftp://demo:[email protected]/readme.txt");
curl_easy_setopt(strict, CURLOPT_SHARE, pool);
curl_easy_setopt(strict, CURLOPT_SSH_KNOWNHOSTS, "/non/existent/file");
curl_easy_setopt(strict, CURLOPT_NOBODY, 1L);
fprintf(stderr, "[*] Transfer 2 — CURLOPT_SSH_KNOWNHOSTS set to non-existent file\n");
curl_easy_perform(strict);
curl_easy_getinfo(strict, CURLINFO_NUM_CONNECTS, &new_connections);
if(new_connections == 0) {
fprintf(stderr, "\n[!] VULNERABLE — Transfer 2 reused the unverified connection.\n"
" CURLOPT_SSH_KNOWNHOSTS was silently ignored.\n"
" No new TCP or SSH handshake occurred.\n");
} else {
fprintf(stderr, "\n[+] NOT VULNERABLE — new connection was established.\n");
}
}
curl_easy_cleanup(relaxed);
curl_easy_cleanup(strict);
curl_share_cleanup(pool);
curl_global_cleanup();
return 0;
}
Output on vulnerable build:
[*] Transfer 1 — no host key checks
[*] Transfer 2 — CURLOPT_SSH_KNOWNHOSTS set to non-existent file
[!] VULNERABLE — Transfer 2 reused the unverified connection.
CURLOPT_SSH_KNOWNHOSTS was silently ignored.
No new TCP or SSH handshake occurred.
{F5648299}
Expected behavior: Transfer 2 opens a new connection, attempts to read /tmp/does_not_exist_known_hosts, fails, and returns CURLE_SSH error.
Actual behavior: Transfer 2 reuses the pooled connection from Transfer 1. CURLINFO_NUM_CONNECTS returns 0 — no handshake, no host key check, no error.
Impact
In any environment where multiple transfer handles share a connection pool — CURLM multi-handle applications, CURLSH shared caches, PHP-FPM worker pools, proxy daemons — a connection established without server identity verification is reusable by handles that explicitly require it.
An attacker who performs a MITM on the initial unverified connection inherits access to every subsequent transfer on that pool, including those configured with host key pinning or known_hosts validation. Sensitive SFTP/SCP data (credentials, files, configuration) flows over the attacker-controlled channel. The developer's explicit security policy is discarded without error or warning.
This is not theoretical — CURLSH shared pools exist precisely for multi-tenant connection reuse. The TLS backend already prevents this exact pattern. The SSH backend does not.
Prior art:
- CVE-2022-27782 — fixed TLS + partial SSH reuse checks ($2,400)
- CVE-2023-27538 — fixed remaining client key options ($480)
- This report — server identity options remain unchecked
Recommended Fix
Add three comparisons to ssh_config_matches():
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->rsa, sshc2->rsa) &&
Curl_safecmp(sshc1->rsa_pub, sshc2->rsa_pub) &&
Curl_safecmp(one->data->set.str[STRING_SSH_KNOWNHOSTS],
two->data->set.str[STRING_SSH_KNOWNHOSTS]) &&
Curl_safecmp(one->data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5],
two->data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5]) &&
Curl_safecmp(one->data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256],
two->data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256]);
}
Impact
Summary:
An attacker who can perform a MITM against an initial unverified SFTP/SCP connection gains persistent access to all subsequent transfers on the same shared connection pool — including those from handles that explicitly require host key pinning via CURLOPT_SSH_KNOWNHOSTS or CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256. The strict handle's SSH handshake never occurs; it inherits the compromised connection silently, with no error returned to the application. Sensitive file contents, credentials, and configuration transferred over SFTP/SCP are exposed to the attacker despite the developer having configured server identity verification.