# SCURLOPT_SSH_KNOWNHOSTS and host fingerprint pins are silently bypassed when an SSH connection is reused from the connection pool

Disclosed: 2026-04-06 09:46:26 By spiderchan26 To curl
Unknown
Vulnerability Details
## Product **libcurl** (all versions, all platforms, compiled with `USE_SSH`) Protocols affected: `sftp://`, `scp://` --- ## Summary libcurl's connection pool reuse logic for SSH-based protocols (SFTP, SCP) contains a security gap that allows a transfer's server-verification policy to be completely ignored. When an application sets `CURLOPT_SSH_KNOWNHOSTS`, `CURLOPT_SSH_HOST_PUBLIC_KEY_MD5`, or `CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256`, it is telling libcurl: *"Before you trust this server, verify its identity against this file or fingerprint."* libcurl stores this instruction on the easy handle. The problem is that when libcurl looks for an existing connection to reuse, it runs a compatibility function called `ssh_config_matches()` (`lib/url.c`, lines 705–714). This function only receives **connection-level** structs — it never receives the easy handle. So it has no way to see the verification policy. It checks only which client key files were used (which are `NULL` in both connections in the most common case, making the check a guaranteed pass), then declares the connections compatible and reuses the old one. The SSH handshake — the only moment where `CURLOPT_SSH_KNOWNHOSTS` is actually checked — already happened on the old connection. It does not happen again. The security-critical transfer goes through an unverified channel and returns `CURLE_OK`. The developer did everything right. libcurl silently discarded their instruction. --- ## Vulnerability Details ### The broken function **File:** `lib/url.c` **Lines:** 705–714 ```c #ifdef USE_SSH 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); } #endif ``` This function is the sole gate that decides whether an SSH connection from the pool is compatible with a new transfer's security requirements. It checks exactly two things: the client's private key file path (`sshc->rsa`) and public key file path (`sshc->rsa_pub`). It checks **nothing** about server verification: | Security Option | libcurl Option | Where It Lives | Checked? | |---|---|---|---| | Known-hosts file | `CURLOPT_SSH_KNOWNHOSTS` | `data->set.str[STRING_SSH_KNOWNHOSTS]` | ❌ Never | | Host key MD5 pin | `CURLOPT_SSH_HOST_PUBLIC_KEY_MD5` | `data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5]` | ❌ Never | | Host key SHA256 pin | `CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256` | `data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256]` | ❌ Never | | Allowed auth methods | `CURLOPT_SSH_AUTH_TYPES` | `data->set.ssh_auth_types` | ❌ Never | All four live on `struct Curl_easy` (the easy handle). `ssh_config_matches` only receives `struct connectdata` pointers. The easy handle is not in its parameter list. --- ### The easy handle is right there — just not passed in `ssh_config_matches` is called from `url_match_proto_config()` (`lib/url.c`, lines 988–992): ```c #ifdef USE_SSH if(get_protocol_family(m->needle->scheme) & PROTO_FAMILY_SSH) { if(!ssh_config_matches(m->needle, conn)) return FALSE; } #endif ``` The caller's parameter is `struct url_conn_match *m`, which is defined as (`lib/url.c`, lines 717–732): ```c struct url_conn_match { struct connectdata *found; struct Curl_easy *data; /* ← the easy handle, with the full security policy */ struct connectdata *needle; ... }; ``` `m->data` — the easy handle carrying the application's verification instructions — is available at this call site. It is simply never forwarded into `ssh_config_matches`. --- ### Why this is a guaranteed bypass in the typical case `Curl_safecmp` is defined in `lib/strcase.c`, lines 119–124: ```c bool Curl_safecmp(const char *a, const char *b) { if(a && b) return !strcmp(a, b); return !a && !b; /* NULL, NULL → TRUE */ } ``` In the typical case, neither handle specifies explicit client key files. As a result, `sshc->rsa` and `sshc->rsa_pub` are both `NULL` on both connections (confirmed: `lib/vssh/libssh2.c` line 1081, `sshc->rsa_pub = sshc->rsa = NULL`). `Curl_safecmp(NULL, NULL)` → `TRUE`. `ssh_config_matches` returns `TRUE`. Connection is reused. --- ### Why reusing the connection silently skips verification Server verification happens in `ssh_check_fingerprint()` and `ssh_knownhost()` (`lib/vssh/libssh2.c`, lines 303–716). These functions are called during the `SSH_HOSTKEY` state — a state that only runs when a **new** SSH session is being established. When a connection is pulled from the pool, it is already fully negotiated. The state machine picks up at a later stage. `SSH_HOSTKEY` is never reached. Neither `ssh_knownhost` nor `ssh_check_fingerprint` is ever called. The application's `CURLOPT_SSH_KNOWNHOSTS` value is read from the easy handle, copied into local variables at the start of these functions, and then... those functions never run. --- ### The TLS stack does this correctly — SSH does not For HTTPS, `url_match_ssl_config()` (`lib/url.c`, lines 1068–1079) handles the same problem: ```c static bool url_match_ssl_config(struct connectdata *conn, struct url_conn_match *m) { if((m->needle->scheme->flags & PROTOPT_SSL) && !Curl_ssl_conn_config_match(m->data, conn, FALSE)) { /* m->data IS passed */ ... return FALSE; } return TRUE; } ``` `Curl_ssl_conn_config_match` receives the full easy handle, reads `verifypeer`, `verifyhost`, certificate pins, CA bundles — everything — and rejects mismatched connections. SSH has no equivalent. --- ## Steps to Reproduce ### Environment - libcurl compiled with `USE_SSH` / `USE_LIBSSH2` - Any OS (Linux, macOS, Windows) ### Steps 1. Initialize a `CURLSH` shared connection cache 2. **Transfer 1:** Make an SFTP request with no `CURLOPT_SSH_KNOWNHOSTS` set. This opens an unverified connection and puts it in the pool. 3. **Transfer 2:** Make a second SFTP request to the same host, same credentials, but with `CURLOPT_SSH_KNOWNHOSTS` pointing to a file that should cause rejection (either a nonexistent file, or a file that does not contain the server's key). 4. Observe: Transfer 2 returns `CURLE_OK` and `CURLINFO_NUM_CONNECTS` reports `0` new connections — meaning the unverified connection was reused and the knownhosts policy was never enforced. --- ## Proof of Concept ```c /* * libcurl CURLOPT_SSH_KNOWNHOSTS bypass via connection pool reuse * Compile: gcc -o poc poc.c -lcurl * * VULNERABLE output: * [h1] Transfer result: CURLE_OK (0) * [h2] Transfer result: CURLE_OK (0) ← should NOT be OK * [h2] New connections made: 0 ← means it reused h1's connection * STATUS: VULNERABLE — known_hosts policy was bypassed silently. * * PATCHED output: * [h2] Transfer result: CURLE_PEER_FAILED_VERIFICATION (60) * [h2] New connections made: 1 * STATUS: SAFE — connection reuse was correctly denied. */ #include <curl/curl.h> #include <stdio.h> static size_t discard_cb(void *p, size_t sz, size_t n, void *u) { (void)p; (void)u; return sz * n; } int main(void) { CURL *h1, *h2; CURLSH *share; CURLcode res; long new_conn_count = 0; curl_global_init(CURL_GLOBAL_DEFAULT); /* Shared connection pool — simulates any multi-request application */ share = curl_share_init(); curl_share_setopt(share, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT); h1 = curl_easy_init(); h2 = curl_easy_init(); /* ── Handle 1: relaxed — no known_hosts check ───────────────────────── */ curl_easy_setopt(h1, CURLOPT_URL, "sftp://demo:[email protected]/readme.txt"); curl_easy_setopt(h1, CURLOPT_WRITEFUNCTION, discard_cb); curl_easy_setopt(h1, CURLOPT_SHARE, share); /* Intentionally no CURLOPT_SSH_KNOWNHOSTS — accepts any host key */ printf("[h1] Performing relaxed transfer (no host verification)...\n"); res = curl_easy_perform(h1); printf("[h1] Transfer result: %s (%d)\n\n", curl_easy_strerror(res), res); /* ── Handle 2: strict — demands host verification ───────────────────── */ curl_easy_setopt(h2, CURLOPT_URL, "sftp://demo:[email protected]/readme.txt"); curl_easy_setopt(h2, CURLOPT_WRITEFUNCTION, discard_cb); curl_easy_setopt(h2, CURLOPT_SHARE, share); /* * Point to a nonexistent known_hosts file. * A correct implementation must fail — it cannot verify the host. * A vulnerable implementation reuses h1's connection and returns CURLE_OK, * never consulting this file at all. */ curl_easy_setopt(h2, CURLOPT_SSH_KNOWNHOSTS, "/nonexistent/known_hosts"); printf("[h2] Performing strict transfer (CURLOPT_SSH_KNOWNHOSTS enforced)...\n"); res = curl_easy_perform(h2); printf("[h2] Transfer result: %s (%d)\n", curl_easy_strerror(res), res); curl_easy_getinfo(h2, CURLINFO_NUM_CONNECTS, &new_conn_count); printf("[h2] New connections made: %ld\n\n", new_conn_count); if(new_conn_count == 0 && res == CURLE_OK) { puts("STATUS: VULNERABLE — known_hosts policy was bypassed silently."); puts(" CURLOPT_SSH_KNOWNHOSTS had no effect."); } else if(res != CURLE_OK) { puts("STATUS: SAFE — connection reuse was correctly denied."); } else { printf("STATUS: Inconclusive (new_conns=%ld, res=%d)\n", new_conn_count, res); } curl_easy_cleanup(h1); curl_easy_cleanup(h2); curl_share_cleanup(share); curl_global_cleanup(); return 0; } ``` --- ## Impact ## Impact This vulnerability breaks the security contract of three explicitly documented libcurl options: `CURLOPT_SSH_KNOWNHOSTS`, `CURLOPT_SSH_HOST_PUBLIC_KEY_MD5`, and `CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256`. An application that sets these options trusts libcurl to enforce them. In a shared connection pool — which is the standard setup for any server-side application, proxy, or pipeline — that trust is broken. ### Who is affected - **Any server-side application** using libcurl for SFTP/SCP that handles more than one request per process lifetime. PHP-FPM workers, Node.js applications using native libcurl bindings, Python apps using pycurl, C/C++ server software, CI/CD runners, deployment agents. - **Any application using `CURLSH`** to share connection caches between easy handles. - **Any multi-handle application** using `CURLM` where multiple transfers may access the same SSH host. ### What an attacker can do An attacker who can perform a Man-in-the-Middle attack between the application server and the SFTP server needs to satisfy one prerequisite: ensure at least one transfer (even a low-value, background task) makes an unverified SFTP connection to the target host first. After that: - Every subsequent security-critical transfer that relies on `CURLOPT_SSH_KNOWNHOSTS` will silently reuse the poisoned connection. - The attacker can **read** all data transferred (confidentiality breach). - The attacker can **inject** arbitrary SFTP responses — fake file contents, directory listings, or error codes (integrity breach). - The application returns `CURLE_OK` and has no mechanism to detect that anything went wrong. ### Why this is especially dangerous The attack is **invisible**. There is no log entry. No error callback fires. No return code changes. The security-focused developer who carefully set `CURLOPT_SSH_KNOWNHOSTS` has no indication that their precaution had zero effect. Automated monitoring and alerting will not catch it. The only way to discover the bypass post-facto is to have full network traffic capture and notice the absence of a new TCP connection. --- ## Recommended Fix ### Immediate: Pass the easy handle into `ssh_config_matches` The one-line caller change: ```c /* lib/url.c, line 990 — before */ if(!ssh_config_matches(m->needle, conn)) /* lib/url.c, line 990 — after */ if(!ssh_config_matches(m->data, m->needle, conn)) ``` Updated function: ```c static bool ssh_config_matches(struct Curl_easy *data, 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); if(!sshc1 || !sshc2) return FALSE; if(!Curl_safecmp(sshc1->rsa, sshc2->rsa)) return FALSE; if(!Curl_safecmp(sshc1->rsa_pub, sshc2->rsa_pub)) return FALSE; /* Deny reuse if the new transfer requests server verification * that the pooled connection may not have been established with. */ if(data->set.str[STRING_SSH_KNOWNHOSTS]) return FALSE; if(data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5]) return FALSE; if(data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256]) return FALSE; return TRUE; } ``` ### Long-term: Snapshot policy at connect time (mirrors the TLS approach) Store the verification fields used at connection establishment in `struct ssh_conn`. Compare those snapshots during pool lookup — the same pattern used by `Curl_ssl_conn_config_match` for TLS. This enables fine-grained matching (e.g., two transfers with the same `CURLOPT_SSH_KNOWNHOSTS` file can still share a connection) instead of the conservative deny-all above. --- ## Supporting References All code references verified against the current `main` branch of the curl repository: | File | Lines | Content | |---|---|---| | `lib/url.c` | 705–714 | `ssh_config_matches()` — full function body | | `lib/url.c` | 717–732 | `struct url_conn_match` — confirms `m->data` is in scope | | `lib/url.c` | 982–1001 | `url_match_proto_config()` — call site | | `lib/url.c` | 1068–1079 | `url_match_ssl_config()` — TLS equivalent that works correctly | | `lib/strcase.c` | 119–124 | `Curl_safecmp(NULL, NULL)` returns `TRUE` | | `lib/vssh/libssh2.c` | 1081 | `sshc->rsa_pub = sshc->rsa = NULL` | | `lib/vssh/libssh2.c` | 303–454 | `ssh_knownhost()` — only runs during new session | | `lib/vssh/libssh2.c` | 456–716 | `ssh_check_fingerprint()` — only runs during new session | | `lib/urldata.h` | 963–967, 1142 | Confirms all verification options live on easy handle |
Actions
View on HackerOne
Report Stats
  • Report ID: 3645415
  • State: Closed
  • Substate: not-applicable
Share this report