Reports
BAJA[DENEGACIÓN DE SERVICIO]#3783438

DoS en curl: datagramas QUIC de longitud cero dejan colgado al cliente HTTP/3 (CVE-2026-11352)

Un servidor HTTP/3 malicioso podía dejar colgado a curl enviándole datagramas UDP vacíos: el receptor QUIC los descartaba sin contarlos contra su presupuesto de paquetes, así que ni siquiera saltaba el --max-time.

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 habla HTTP/3 sobre QUIC, que a su vez viaja sobre UDP. Para no quedarse leyendo del socket indefinidamente, la función que recibe paquetes QUIC (vquic_recv_packets(), en lib/vquic/vquic.c) trabaja con un presupuesto: lee como mucho max_pkts paquetes por llamada —1000 en la práctica— y después devuelve el control a quien la llamó.

El problema está en cómo se lleva esa cuenta. Las dos variantes del receptor usadas en Linux descartan los datagramas UDP de longitud cero antes de sumarlos al contador, de modo que esos datagramas vacíos no gastan presupuesto. Un igual (peer) QUIC malicioso puede entonces inundar a curl con datagramas vacíos y mantenerlo dando vueltas dentro del bucle de recepción sin que el contador avance nunca hasta max_pkts.

El fallo se clasificó como consumo incontrolado de recursos (CWE-400), con CVE-2026-11352 y severidad baja. El reporte se envió el 5 de junio de 2026 y se divulgó el 24 de junio de 2026; el estado en HackerOne es Resolved.

En la variante con recvmmsg() la condición de salida del bucle es pkts < max_pkts, pero el continue que salta los datagramas vacíos ocurre antes de incrementar pkts:

while(pkts < max_pkts) {
  ...
  if(!mmsg[i].msg_len)
    continue;
  ...
  pkts += (mmsg[i].msg_len + gso_size - 1) / gso_size;
}

La variante con recvmsg() tiene exactamente el mismo patrón de "no progreso":

for(pkts = 0; pkts < max_pkts;) {
  ...
  if(!nread)
    continue;
  ...
  pkts += (nread + gso_size - 1) / gso_size;
}

El contraste lo da la variante de reserva con recvfrom(), que sí cuenta el datagrama recibido antes de descartar el de longitud cero. Esa es la contabilidad segura que se buscaba:

++pkts;
if(!nread)
  continue;

Pasos de reproducción

El investigador aportó una prueba de concepto autocontenida en Linux que no modifica curl ni depende de ningún binario del repositorio. La secuencia es la siguiente:

  1. Levantar un servidor HTTP/3 real (basado en aioquic) que escucha en 127.0.0.1 en un puerto aleatorio con un certificado autofirmado para localhost.
  2. Lanzar curl con HTTP/3 forzado y un tiempo máximo corto contra ese servidor:
curl -q --http3-only --max-time 2 --connect-timeout 2 --noproxy '*' -k -sS https://127.0.0.1:<puerto>/
  1. Dejar que la conexión complete el handshake QUIC con normalidad; el servidor responde incluso con una cabecera HTTP/3 :status 200 y un primer byte de cuerpo, de modo que para curl es un igual legítimo.
  2. Una vez establecida la conexión, el servidor deja de comportarse como tal y, desde la misma dirección y puerto de ese igual, empieza a enviar de forma continua datagramas UDP de longitud cero hacia curl.

El criterio de la prueba es sencillo: curl se había invocado con --max-time 2, pero seguía vivo pasados 30 segundos y hubo que matarlo. El investigador indica que el mismo montaje también lo mantuvo colgado más de 60 segundos. La traza de la ejecución refleja el handshake completado y el inicio de la inundación justo antes de que curl quedara atascado:

requested_max_time=2
observed_seconds=30
curl_still_running_after_30s=1
curl_exit=killed
HANDSHAKE_COMPLETED alpn=h3
ZERO_FLOOD_STARTED helper=sendmmsg helpers=64

El punto clave es que el atacante actúa como un servidor QUIC/HTTP3 real primero y solo pasa a los datagramas vacíos después de que el cliente haya completado el handshake y empezado HTTP/3.

Versiones afectadas

Validado en Linux x86_64 con una compilación de curl con HTTP/3 habilitado (ngtcp2 + nghttp3). Según el reporte, las etiquetas curl-8_18_0, curl-8_19_0 y curl-8_20_0, así como la rama de desarrollo (8.21.0-DEV), contienen el descarte del datagrama vacío antes de avanzar pkts. La etiqueta curl-8_17_0 no contiene ese patrón.

Impacto

Un igual HTTP/3/QUIC malicioso puede provocar una denegación de servicio remota en el cliente curl o libcurl sin necesidad de corrupción de memoria ni de saltarse autenticación. Mientras llegan los datagramas vacíos, la llamada de recepción no devuelve el control a quien la invocó, así que:

  • Los mecanismos normales de escape por tiempo no llegan a ejecutarse: ni el --max-time de la línea de órdenes ni el CURLOPT_TIMEOUT de libcurl sirven de nada mientras el cliente está atrapado en el bucle.
  • La transferencia queda bloqueada y se consume CPU procesando datagramas que ni siquiera son paquetes QUIC válidos.
  • En aplicaciones que usan libcurl dentro de un bucle de eventos de un solo hilo, esto puede además dejar sin atender a otras transferencias o tareas que compartan ese mismo bucle.

En resumen, el efecto es agotamiento de recursos (CPU y bucle de eventos) y transferencias colgadas para clientes HTTP/3/QUIC.

Remediación

La solución pasa por que las variantes afectadas lleven la misma contabilidad que la de reserva con recvfrom(): contar cada datagrama recibido contra el presupuesto max_pkts antes de descartar los de longitud cero. Así, aunque el igual malicioso siga enviando datagramas vacíos, cada uno gasta presupuesto y el bucle alcanza su límite y devuelve el control al llamante, que ya puede aplicar sus tiempos máximos y demás lógica. El reporte figura como resuelto en HackerOne.

Qué aprender de este caso

  • En cualquier bucle de recepción con un presupuesto de iteraciones (while(pkts < max)), revisa que todo camino que llame a continue haya incrementado antes el contador: un continue colocado antes del ++ convierte las entradas "vacías" o descartadas en iteraciones gratis que rompen la garantía de terminación.
  • Cuando existen varias implementaciones del mismo receptor (recvmmsg, recvmsg, recvfrom), compáralas entre sí: aquí una de ellas (recvfrom_packets()) ya contaba bien, y esa discrepancia señalaba exactamente cuál era el comportamiento correcto y dónde estaba el fallo.
  • En protocolos sobre UDP, no supongas que un igual que completó el handshake seguirá comportándose bien: trata los datagramas de longitud cero o malformados posteriores como entrada hostil que también debe consumir cuota de tiempo o de paquetes, no como ruido inofensivo que se descarta sin coste.