HexBrain

curl · Report #3645415

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

N/Astatus
9upvotes
6 Apr 2026disclosed

How it was found

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

#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 Optionlibcurl OptionWhere It LivesChecked?
Known-hosts fileCURLOPT_SSH_KNOWNHOSTSdata->set.str[STRING_SSH_KNOWNHOSTS]❌ Never
Host key MD5 pinCURLOPT_SSH_HOST_PUBLIC_KEY_MD5data->set.str[STRING_SSH_HOST_PUBLIC_KEY_MD5]❌ Never
Host key SHA256 pinCURLOPT_SSH_HOST_PUBLIC_KEY_SHA256data->set.str[STRING_SSH_HOST_PUBLIC_KEY_SHA256]❌ Never
Allowed auth methodsCURLOPT_SSH_AUTH_TYPESdata->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):

#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):

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:

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:

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

/*
 * 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:

/* 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:

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:

FileLinesContent
lib/url.c705–714ssh_config_matches() — full function body
lib/url.c717–732struct url_conn_match — confirms m->data is in scope
lib/url.c982–1001url_match_proto_config() — call site
lib/url.c1068–1079url_match_ssl_config() — TLS equivalent that works correctly
lib/strcase.c119–124Curl_safecmp(NULL, NULL) returns TRUE
lib/vssh/libssh2.c1081sshc->rsa_pub = sshc->rsa = NULL
lib/vssh/libssh2.c303–454ssh_knownhost() — only runs during new session
lib/vssh/libssh2.c456–716ssh_check_fingerprint() — only runs during new session
lib/urldata.h963–967, 1142Confirms all verification options live on easy handle

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