Resumen
El fallo, registrado como CVE-2026-9545, está en el backend HTTP/3 de curl basado en ngtcp2 (lib/vquic/curl_ngtcp2.c) y afecta a la versión de desarrollo curl 8.21.0-DEV. Tiene que ver con la early data (0-RTT): la posibilidad de enviar datos de la petición en el primer vuelo de una conexión que reanuda una sesión TLS ya conocida, sin esperar a que termine el handshake.
Cuando curl reutiliza una sesión TLS guardada que anuncia soporte de early data, la función cf_ngtcp2_on_session_reuse() inicializa el estado HTTP/3 y marca el filtro de conexión como conectado antes de que el nuevo handshake QUIC/TLS haya terminado. A partir de ahí, la petición llega a cf_ngtcp2_send() aunque la verificación del servidor todavía esté pendiente.
El problema es que cf_ngtcp2_send() solo comprueba, al entrar, si ya existe un error de verificación guardado en ctx->tls_vrfy_result. No exige que el handshake esté completo (ctx->tls_handshake_complete) ni que el certificado del par se haya validado con éxito. Así que abre el stream HTTP/3, mete los bytes de la petición en el búfer y los envía por la red:
/* handshake verification failed in callback, do not send anything */
if(ctx->tls_vrfy_result) {
result = ctx->tls_vrfy_result;
goto denied;
}
(void)eos; /* use for stream EOF and block handling */
result = cf_progress_ingress(cf, data, &pktx);
if(result)
goto out;
if(!stream || stream->id < 0) {
result = h3_stream_open(cf, data, buf, len, pnwritten);
}
...
result = Curl_bufq_write(&stream->sendbuf, buf, len, pnwritten);
...
if(*pnwritten > 0 && !ctx->tls_handshake_complete && ctx->use_earlydata)
ctx->earlydata_skip += *pnwritten;
DEBUGASSERT(!result);
result = cf_progress_egress(cf, data, &pktx);
Referencia al código afectado: https://github.com/curl/curl/blob/f2692b54f74b8bb6058ecd3cf4abcc96e8ab36ba/lib/vquic/curl_ngtcp2.c#L1732-L1824
El resultado: curl termina detectando que el certificado no corresponde al dominio y aborta, pero para entonces la petición, con sus cabeceras y su cuerpo, ya ha salido hacia un servidor que no se ha verificado.
El reporte indica que el hallazgo inicial lo hizo una IA, que todas las afirmaciones técnicas las revisaron expertos humanos y que se envió en nombre del equipo Autonomous Code Security (ACS) de Microsoft. El programa de curl no paga recompensas, solo da crédito.
Pasos de reproducción
Entorno usado en la prueba:
curl commit: f2692b54f74b8bb6058ecd3cf4abcc96e8ab36ba
curl subject: docs: note CURLOPT_PINNEDPUBLICKEY has no effect on legacy LDAP backend
curl 8.21.0-DEV (Linux) libcurl/8.21.0-DEV quictls/3.1.7 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 nghttp2/1.59.0 ngtcp2/1.8.1 nghttp3/1.6.0
Features: alt-svc AsynchDNS brotli Debug HSTS HTTP2 HTTP3 HTTPS-proxy IDN IPv6 Largefile libz PSL SSL SSLS-EXPORT threadsafe TLS-SRP UnixSockets zstd
La prueba de concepto es un script de bash que monta todo en local con el servidor de ejemplo de ngtcp2 (qtlsserver):
- Crear certificados. Se genera una CA de prueba y, firmados por ella, dos certificados: uno válido para
target.example.comy otro paraevil.example.com. También se crean dos claves de ticket distintas (good.ticketyevil.ticket) con 80 bytes aleatorios cada una.
- Arrancar el servidor legítimo con el certificado de
target.example.com, su clave de ticket y la opción--early-response:
``bash env TICKET_KEY_FILE="$ticket" "$server" --early-response -d "$docroot" '*' 4433 \ "$tmp/$cert-key.pem" "$tmp/$cert-cert.pem" > "$log" 2>&1 & ``
- Primera petición, legítima, para que curl guarde en
sessions.txtuna sesión TLS de HTTP/3 que admite early data:
``bash "$curl_bin" --http3-only --tls-earlydata \ --ssl-sessions "$tmp/sessions.txt" \ --cacert "$tmp/ca.pem" \ --connect-to "$connect_to" \ -sS -o /dev/null "$url" ``
con url=https://target.example.com:4433/index.html y connect_to=target.example.com:4433:127.0.0.1:4433.
- Sustituir el servidor: se para el legítimo y se arranca en el mismo puerto otro con el certificado de
evil.example.comy la otra clave de ticket. Hace el papel del atacante que desvía la conexión.
- Segunda petición, ahora con datos sensibles en una cabecera y en el cuerpo, reutilizando la sesión guardada:
``bash "$curl_bin" --http3-only --tls-earlydata \ --ssl-sessions "$tmp/sessions.txt" \ --cacert "$tmp/ca.pem" \ --connect-to "$connect_to" \ -H "Authorization: Bearer $secret" \ -d "secret=$secret" \ -sS -o /dev/null "$url" > "$tmp/evil-client.log" 2>&1 ``
($secret es un valor ficticio generado para la prueba).
- Comprobar los logs: se busca la cabecera
Authorizationen el log del servidor falso y el error de verificación en la salida de curl. Resultado obtenido:
``text http: stream 0x0 [authorization: Bearer [REDACTADO]] curl: (60) SSL: no alternative certificate subject name matches target hostname 'target.example.com' TEST_RESULT request_seen=yes verify_failed=yes curl_exit=60 ``
Las dos cosas pasan a la vez: curl falla con el código 60 porque el certificado no es válido para target.example.com, pero el servidor falso ya ha recibido la cabecera Authorization.
Impacto
Un atacante con posición de intermediario en la red (MITM) podía ver los datos de la petición enviada como early data: cookies, tokens bearer o el cuerpo de la petición. La prueba demuestra solo esa divulgación; curl no acepta la respuesta del atacante, porque devuelve el error de verificación y no entrega ningún cuerpo.
El investigador lo calificó de moderado para las configuraciones afectadas, con requisitos muy concretos (en HackerOne figura con severidad baja). Hacen falta todas estas condiciones a la vez:
- curl/libcurl compilado con el backend HTTP/3 de ngtcp2 y un backend TLS que soporte early data.
- La aplicación activa early data para la transferencia (en la prueba, con
--tls-earlydata). - El cliente tiene en caché una sesión TLS de HTTP/3 para ese host que permite early data.
- La primera petición de la nueva conexión lleva material sensible (cookies, token o cuerpo).
- Un atacante activo puede llevar la nueva conexión a un servidor TLS cuyo certificado no es válido para el dominio.
- La verificación de certificados sigue activada.
Qué aprender de este caso
- Si auditas un cliente con 0-RTT o reanudación de sesión, busca el punto donde la conexión pasa a "conectada" (aquí
cf_ngtcp2_on_session_reuse()) y comprueba qué rutas de envío quedan abiertas antes de que acabe la verificación del certificado. - Una comprobación de error solo al entrar en la función (
if(ctx->tls_vrfy_result)) no basta cuando la verificación aún no ha ocurrido: "no hay error todavía" no es lo mismo que "verificado". El envío de datos sensibles debería depender de un estado positivo de verificación completada. - Una prueba útil para este tipo de fallo: guardar una sesión contra un servidor legítimo, cambiarlo por otro con certificado ajeno y comprobar en el log del servidor si llegan cabeceras antes de que el cliente aborte. Que el cliente termine con error no significa que no haya filtrado nada.
- Al activar early data en tu aplicación, evita que la primera petición de una conexión reanudada lleve credenciales o cuerpos sensibles si no puedes garantizar que la biblioteca espera a la verificación.