# SCURLOPT_SSH_KNOWNHOSTS and host fingerprint pins are silently bypassed when an SSH connection is reused from the connection pool
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 HackerOneReport Stats
- Report ID: 3645415
- State: Closed
- Substate: not-applicable