Servidores

QPACK: una sola conexión HTTP/3 puede agotar la memoria de tu servidor

La primera vez que leí el reporte de FoxIO sobre QPACK, mi reacción fue la de siempre: «otro CVE más en una librería de bajo nivel que nunca voy a tocar». A los veinte minutos ya estaba revisando los tres servidores que administro, porque dos de ellos corren Caddy detrás de un contenedor. Un atacante sin cuenta, sin credenciales y sin enviar ni un megabyte de datos puede hacerle a tu servidor lo que un DDoS de 50 Gbps no lograría en un minuto, y sin que ningún filtro en el borde se entere.

La diferencia es que aquí no manda el ancho de banda: manda la memoria. El atacante manda 516 KB y tu servidor termina con 2 GB asignados, o queda clavado en un bucle infinito de CPU que no se recupera solo. FoxIO encontró la misma clase de fallo en siete de los diez stacks HTTP/3 que probó, y publicó el código de prueba. Este post es el mapa de qué está afectado, qué ya está parcheado, qué no tiene arreglo todavía y qué haces hoy en tu servidor sin importar cuál de los dos seas.

TL;DR — Lo que vas a tener al final

  • Tiempo de setup: 10 minutos para identificar tu stack y actualizar. Suma 5 minutos más si usas Bun o mitmproxy en la frontera.
  • Requisitos: acceso SSH al servidor y saber si tu proxy publica HTTP/3 en el puerto UDP 443.
  • Resultado: un servidor que ya no multiplica su memoria por 4000 ante una sola conexión sin autenticar, y un plan concreto para los tres stacks que siguen sin parche.
  • Troubleshooting: si parcheaste quic-go a 0.57.0 y crees que estás a salvo, todavía no lo estás: CVE-2026-40898 exige como mínimo 0.59.1, y hoy la estable es 0.64.1.

Qué es QPACK y por qué puede multiplicar tu memoria por 4000

HTTP/3 corre sobre QUIC, que corre sobre UDP. La diferencia con HTTP/2 no es solo el transporte: HTTP/3 usa QPACK (RFC 9204) para comprimir las cabeceras. En vez de mandar el texto completo de cada campo, el cliente manda una referencia: un índice a una tabla. Un encabezado como strict-transport-security: max-age=31536000; includesubdomains; preload ocupa 71 bytes, pero con QPACK se convierte en un solo byte, 0xFA, que apunta a la entrada 58 de la tabla estática.

Esa es la genialidad del protocolo y también el agujero. Hay dos tablas: la estática (definida por la especificación) y la dinámica (se construye en runtime entre cliente y servidor). Ambas permiten que un byte en el cable se convierta en kilobytes en memoria, y el RFC 9114, sección 4.2.2, pone la responsabilidad de limitarlo del lado del receptor: el tamaño de la lista de campos se calcula sobre el tamaño descomprimido, incluyendo nombre, valor y 32 bytes de sobrecarga por campo.

FoxIO encontró cinco formas distintas de incumplir esa regla en las implementaciones que probó:

  1. Verifican el tamaño del frame comprimido, no el del resultado descomprimido.
  2. Descomprimen todo primero y validan después (o nunca validan).
  3. Olvidan los 32 bytes de sobrecarga por campo en el cálculo.
  4. El valor por defecto es «ilimitado» cuando el parámetro no está configurado.
  5. Nunca aplican el límite, aunque esté configurado.

El resultado medido en sus pruebas no es un DoS teórico. Con un frame HEADERS de 1 MB, quic-go descomprimió 160.075 campos y retuvo unos 15 MiB en memoria viva: unas 15 veces lo que llegó por el cable. El propio advisory de quic-go estima hasta 50 veces. Y hay una segunda variante, más limpia, que usa la tabla dinámica: el atacante inserta una entrada de 4 KB y la referencia miles de veces con índices de un byte. Cada referencia reconstruye la entrada completa. FoxIO midió 4064:1 en Bun y entre 600 y 1800 veces en lsquic.

Stack Estado Versión corregida Identificador Ratio medido
quic-go (Caddy, Traefik, cloudflared) Corregido 0.57.0 CVE-2025-64702 ~15x (hasta 50x)
Cloudflare quiche Corregido 0.29.3 CVE-2026-12523 ~20x
lsquic (LiteSpeed, OpenLiteSpeed) Corregido 4.9.0 Sin CVE 600–1800x
Wireshark Corregido 4.6.2 / 4.4.12 CVE-2025-13945 Crash del disector
Bun Sin parche — Sin CVE ~4000x + bucle infinito
h3 (hyperium, Rust) Sin parche — Sin CVE Panic a 32.768 campos
aioquic / mitmproxy Sin parche — Sin CVE ~20–30x
Google quiche, IIS, Envoy, ngtcp2/nghttp3 No afectados — — Límite aplicado al decodificar

Nota sobre Nginx: no aparece en la tabla de arriba porque Nginx no se limita a «no hablar HTTP/3». Nginx tiene soporte nativo de QUIC y HTTP/3 desde la versión 1.25.0, mediante ngx_http_v3_module, y viene incluido en los paquetes binarios de Linux (nginx.org/en/docs/quic.html). Si lo tienes corriendo con listen 443 quic, estás exponiendo HTTP/3 y te toca el mismo análisis que el resto de stacks de esta tabla.

El alcance es amplio precisamente porque estas librerías están debajo de software que usas todos los días. quic-go es la base de Caddy, Traefik y cloudflared. lsquic es la de LiteSpeed Web Server y OpenLiteSpeed. Y el servidor HTTP/3 de Bun corre sobre lsquic.

Comprueba ahora mismo qué stack estás ejecutando

Antes de actualizar nada, identifica qué corre detrás de tu dominio. Si usas Caddy o Traefik en contenedor, casi seguro vas por quic-go. Si tu hosting usa LiteSpeed, vas por lsquic. Este comando te dice quién está escuchando en el puerto UDP 443, que es por donde entra HTTP/3:

# Quién escucha en UDP 443 (HTTP/3 / QUIC)
sudo ss -ulnp | grep ':443'
# Verificar si tu servidor realmente negocia HTTP/3
curl -sI --http3 https://tu-dominio.com | head -5
# Confirmar que el cliente soporta el protocolo
curl -V

El primer comando es el que importa: si no hay nada escuchando en UDP 443, tu servidor no habla HTTP/3 y este reporte no te aplica, por ahora. El segundo necesita un curl compilado con soporte QUIC; si te devuelve un error de opción desconocida, tu curl no lo tiene y es mejor usar el primero. Si `ss` no devuelve nada pero tu navegador sí negocia h3, hay un CDN o un proxy inverso delante tuya haciendo el trabajo de QUIC.

Para un proyecto en Go, la versión de quic-go que tienes es un dato del go.mod, no del binario compilado:

# Versión de quic-go en tu módulo
go list -m github.com/quic-go/quic-go
# Todas las dependencias QUIC del proyecto
go list -m all | grep -i quic

Esto importa porque el número de versión del binario final muchas veces no coincide con el que crees. Un contenedor hecho hace meses puede llevar quic-go 0.54 embebido aunque tu go.mod diga otra cosa. El go list te da la verdad del árbol de dependencias.

Actualizar lo que ya tiene parche: el caso de quic-go

Acá hay un detalle que los reportes de prensa pasan por alto y que te va a morder si no lo lees: quic-go tiene dos CVEs distintos y el segundo necesita una versión más nueva. CVE-2025-64702 (cabeceras) se corrigió en la 0.57.0, que salió el 21 de noviembre de 2025 —el advisory se publicó después, el 11 de diciembre de 2025—. Pero CVE-2026-40898, publicado a principios de junio de 2026 (el GHSA el 3 de junio y el registro de la NVD el 4 de junio), explota la misma falla por la otra puerta: los trailers HTTP en lugar de las cabeceras, y está corregido recién en la 0.59.1. Actualizar a 0.57.0 y celebrar el triunfo te deja expuesto.

El arreglo de fondo en ambos casos es el mismo: quic-go ahora envía SETTINGS_MAX_FIELD_SECTION_SIZE, decodifica QPACK de forma incremental comprobando el tamaño después de cada entrada, y aborta con HTTP 431 en el servidor o con reset del stream en el cliente. Eso corta el ataque en el momento en que se excede el presupuesto, en lugar de dejar que la memoria se llene sola.

# Caddy en Docker: actualizar a la última estable y recrear
docker compose pull caddy
docker compose up -d --force-recreate caddy
docker compose logs caddy --tail 20
# Traefik en Docker: mismo procedimiento
docker compose pull traefik
docker compose up -d --force-recreate traefik
# Verificar qué versión quedó efectivamente corriendo
docker compose exec caddy caddy version

Este bloque sube y recrea los contenedores, así que hay una interrupción de unos segundos mientras el proxy vuelve. Si no puedes tolerar ni un corte, levanta el contenedor nuevo en otro host y mueve el tráfico con DNS cuando esté listo. Lo que no debes hacer es reiniciar sobre la imagen vieja «para no tocar nada»: el punto del ejercicio es que la imagen nueva sea la nueva.

Para un servicio compilado desde código, el cambio también es en el go.mod, y conviene correr los tests de carga HTTP/3 si los tienes:

go get github.com/quic-go/[email protected]
go mod tidy
go test ./... 2>&1 | tail -20

Los casos sin parche: qué hacer mientras tanto

Tres stacks seguían abiertos al momento del reporte. Ninguno tiene fecha de arreglo confirmada, así que el trabajo es tuyo.

Bun: no expongas HTTP/3

Bun es el peor caso de todos, por dos razones. La primera es el ratio de 4000:1. La segunda es un bug de desbordamiento de entero en us_quic_hsi_prepare: cuando el conjunto de cabeceras descomprimido pasa de 2 GB, el contador de capacidad se desborda a cero y el bucle de crecimiento ncap *= 2 se queda en cero para siempre. Un solo stream con 516 KB en el cable deja el servidor al 100 % de CPU sin recuperación posible, bloqueando peticiones nuevas. La mitigación oficial es no exponer el servidor HTTP/3 de Bun. Su soporte sigue marcado como experimental y se activa explícitamente:

# Ver qué versión de Bun tienes
bun --version
# Verificar si tu servidor Bun expone HTTP/3
sudo ss -ulnp | grep ':443' | grep -i bun

Si tu Bun está detrás de un Nginx o Caddy que habla HTTP/3 hacia el exterior, el Bun solo habla HTTP/1.1 hacia adentro y no hay problema. Si Bun expone el 443 directamente, desactiva h3 hasta que haya parche.

mitmproxy y aioquic: apaga HTTP/3

aioquic define la constante MAX_FIELD_SECTION_SIZE, la parsea del SETTINGS del peer, la guarda… y nunca la comprueba durante el decodificado. Su control de flujo también se auto-duplica sin techo. FoxIO medió entre 20 y 30 veces de amplificación. En mitmproxy el impacto es mayor porque construye un objeto http.Request por stream y lo mantiene abierto durante toda la vida del stream. Mitígalo desactivando el protocolo:

# Ejecutar mitmproxy con HTTP/3 desactivado
mitmdump --set http3=false
# Verificar la versión instalada
mitmproxy --version

Un servidor aioquic mínimo que descarte las cabeceras después de procesarlas sí sobrevive. Si usas aioquic directamente, considera poner un límite de tamaño en tu propia aplicación antes de retener la lista.

h3 de Rust: configura el límite a mano

La librería h3 del proyecto hyperium decodifica todas las cabeceras a un Vec<HeaderField> y luego las mete en un http::HeaderMap. HeaderMap::append llama a try_append(...).expect("size overflows MAX_SIZE"), que entra en panic cuando el mapa supera su capacidad: 32.768 entradas en la configuración por defecto. El límite se configura en el builder del servidor (server::builder() seguido de max_field_section_size) y por defecto es ilimitado. Ajustarlo corta el reproducer antes de que llegue a ese camino:

use bytes::Bytes;
use h3::server;
// Quinn NO viene dentro de h3: es el crate aparte h3-quinn 0.0.10.
// h3_quinn::Connection::new(conn_quinn) es lo que implementa
// el trait h3::quic::Connection y se le pasa a builder.build().
async fn serve<C>(conn: C) -> Result<(), Box<dyn std::error::Error>>
where
    C: h3::quic::Connection<Bytes>,
{
    // El presupuesto por defecto de h3 es VarInt::MAX (ilimitado).
    let mut builder = server::builder();
    builder.max_field_section_size(64 * 1024);
    let mut h3_conn = builder.build(conn).await?;
    while let Some(resolver) = h3_conn.accept().await? {
        let (req, mut stream) = resolver.resolve_request().await?;
        // Bytes descomprimidos de la seccion de campos que quedan en memoria
        let decoded: usize = req
            .headers()
            .iter()
            .map(|(name, value)| name.as_str().len() + value.len())
            .sum();
        println!(
            "{} {} -> {} bytes decodificados",
            req.method(),
            req.uri(),
            decoded
        );
        let response = http::Response::builder()
            .status(http::StatusCode::OK)
            .body(())?;
        stream.send_response(response).await?;
        stream.send_data(Bytes::from_static(b"ok")).await?;
        stream.finish().await?;
    }
    Ok(())
}

Nótese que h3 se considera todavía una librería alfa y su mantenedor cerró el reporte anterior diciendo que cambiaría el valor por defecto. Mientras tanto, el límite lo pones tú.

Mitigaciones que sirven aunque no haya parche

Más allá de los parches, hay tres defensas que funcionan a nivel de sistema y que siguen teniendo sentido como capa de profundidad. FoxIO lo describe así: el memory.events del cgroup registrando más de 900 eventos de límite en menos de un segundo.

# Eventos de límite de memoria del cgroup actual
cat /sys/fs/cgroup/memory.events
# Ver el uso real de memoria del servicio
systemctl show caddy --property=MemoryCurrent
systemctl show caddy --property=MemoryMax

Poner un MemoryMax explícito en la unidad de systemd convierte un OOM silencioso del kernel —donde el proceso muere y el watchdog lo reinicia en bucle— en un oom_kill visible y un servicio que al menos vuelve a levantar limpio. Es contención de daños, no una solución.

La segunda defensa es la más simple y la más infravalorada: no escuchar en UDP 443. Si tu servidor no habla HTTP/3, todo este reporte es irrelevante para ti. Es una decisión válida para un servidor interno, para un backend detrás de un CDN, o para un homelab donde el beneficio de QUIC es marginal.

# Bloquear UDP 443 con UFW (desactiva HTTP/3)
sudo ufw deny 443/udp
# Verificar las reglas de UDP activas
sudo ufw status | grep 443

La tercera es poner un CDN delante. Si Cloudflare, Fastly o similar terminan la conexión QUIC en el borde, tu servidor origen solo recibe HTTP/1.1 o HTTP/2 sobre TCP, y los headers ya vienen validados. Pierdes el multiplexado en la última milla, que es justo donde QUIC deja más de lo que promete, pero conservas lo que importa en tráfico real.

Lo que esta vulnerabilidad dice del diseño

Lo interesante del reporte no es el número 4000. Es la pregunta que FoxIO deja al final: ¿de quién es el límite?

QUIC lleva la cuenta de los bytes que llegan por el stream, pero no tiene visibilidad sobre los objetos de cabecera en que esos bytes se convierten. El decodificador QPACK reconstruye los campos, y según su API puede imponer un presupuesto o delegar esa responsabilidad en quien lo llama. La capa HTTP/3 y la aplicación convierten esos campos en objetos de request y los retienen, y sus contenedores tienen sus propios costos y capacidades. Cada capa tiene un límite razonable y ninguna los compone.

El caso de nghttp3 hace la frontera explícita: su ajuste anuncia un límite, pero la aplicación tiene que aplicarlo. El caso de h3 muestra la frontera opuesta: un campo descomprimido pasa el decoder y hace panic más tarde, al insertarse en el HeaderMap. El caso de IIS es el ejemplo de cómo se hace bien: aplicación incremental del límite, tabla QPACK de 4 KB, y H3_EXCESSIVE_LOAD para cortar la conexión.

Y hay un detalle de contexto que vale la pena: este trabajo nació de la serie MadeYouReset de Gal Bar Nahum, una técnica de denegación de servicio contra HTTP/2 basada en cancelar peticiones. La pregunta que se hizo FoxIO fue si la compresión de cabeceras de HTTP/3 tenía una debilidad parecida. La respuesta fue que sí, y de una forma bastante más insidiosa: cualquiera que lea la especificación puede encontrar la divergencia entre lo que dice y lo que hace el código.

Vale la pena actuar ya o espero un parche más?

Mi lectura, sin adornos: hay que actuar, pero en orden y sin pánico.

Si usas Caddy, Traefik o cloudflared, actualiza hoy. No es opcional: el advisory tiene casi un año, el parche es una actualización de dependencia y estás corriendo una vulnerabilidad pública con PoC. Ve a 0.64.1, que es la estable de hoy, y no a 0.57.0, por lo del segundo CVE. Si usas LiteSpeed, tu hosting probablemente ya actualizó; verifica contra el release log que tu versión empaquetada de lsquic sea 4.10.2 o superior (la 4.9.0 fue la primera con el límite de memoria), porque el número de versión de OpenLiteSpeed no te dice directamente el de lsquic.

Si usas Bun sirviendo HTTP/3 directamente, apágalo. Si usas mitmproxy en la frontera, pasa el --set http3=false. Si usas h3, pon el límite. En los tres casos la mitigación toma menos de cinco minutos.

Y si tu servidor está detrás de Cloudflare, probablemente ya estás protegido, pero confirma que tu origen no expone UDP 443 al mundo: es la causa número uno de «pensaba que estaba cubierto».

No hay reportes de explotación en la naturaleza y el vector es solo denegación de servicio, sin robo de datos ni ejecución de código. Nadie te va a hackear por esto. Pero un servidor que se queda con 2 GB reservados en memoria o clavado en un bucle de CPU a las 3 de la mañana es un ticket que te cuesta el día entero, y la actualización cuesta diez minutos. Eso no es una decisión difícil.

Preguntas frecuentes

¿Esto me afecta si uso Nginx o Apache?

Con Nginx, sí te puede tocar. La documentación oficial de Nginx es explícita: «Support for QUIC and HTTP/3 protocols is available since 1.25.0, it is included in Linux binary packages», a través del módulo ngx_http_v3_module. No hay ningún módulo de terceros de por medio: si compilaste con --with-http_v3_module o usas los paquetes de Linux, tu Nginx habla HTTP/3 y queda dentro del alcance de este reporte. Apache es el que prácticamente no lo habla. Si tu servidor solo ofrece HTTP/1.1 y HTTP/2 sobre TCP, el ataque no te toca. La forma rápida de confirmarlo es nginx -V y buscar http_v3 en la salida. El riesgo aparece cuando habilitas QUIC en cualquier capa, incluido un CDN o un proxy inverso delante tuyo.

¿Basta con actualizar a quic-go 0.57.0?

No. CVE-2025-64702, que explota las cabeceras, se corrigió en 0.57.0. Pero CVE-2026-40898 explota la misma falla por la ruta de los trailers HTTP y se corrigió recién en 0.59.1. Actualizar a 0.57.0 deja la segunda vía abierta. Si tu proyecto depende de quic-go, apunta a 0.64.1 (el mínimo para el segundo CVE es 0.59.1), y corre go list -m github.com/quic-go/quic-go para confirmar qué versión quedó realmente en el árbol de dependencias.

¿El atacante puede robar datos o ejecutar código con esto?

No. El impacto documentado es exclusivamente denegación de servicio: agotamiento de memoria y, en Bun, un bucle de CPU del que el proceso no se recupera. No hay evidencia de exfiltración de datos ni de ejecución remota de código. Tampoco hay reportes de ataques en la naturaleza al momento del reporte de FoxIO, aunque el código de prueba es público y funciona sin necesidad de cuenta ni credenciales.

¿Cómo detecto si mi servidor es vulnerable ahora mismo?

Ejecuta sudo ss -ulnp | grep ':443' y mira si hay un proceso escuchando en UDP. Si está vacío, tu servidor no habla HTTP/3 y no aplica. Si hay uno, identifica el proceso: Caddy, Traefik, lshttpd de LiteSpeed, Bun o mitmproxy te dicen inmediatamente en qué stack estás. FoxIO además publicó huellas JA4Scan-QUIC por versión de librería, útiles si necesitas hacer un barrido sobre varios servidores de una flota.

¿Debería simplemente desactivar HTTP/3 hasta que todo se estabilice?

Es una decisión defendible si no dependes de QUIC. Para un homelab o un servidor interno detrás de un CDN, apagar UDP 443 elimina la superficie de ataque completa y cuesta muy poco en rendimiento. Para un sitio público con mucho tráfico desde redes móviles, HTTP/3 sigue aportando más de lo que la vulnerabilidad resta una vez parcheado el stack, así que prefiero actualizar y limitarse a cerrar UDP 443 donde no se necesita.

Lee también

Alfonso Moreno | Sysadmin Senior

Soy Alfonso, sysadmin senior con RHCE y más de 10 años administrando sistemas Linux para empresas y proyectos personales. Me especializo en infraestructura, redes, virtualización, monitoring, automation y self-hosting. Sobre SoyLinux.com SoyLinux.com es un blog técnico en español sobre Linux, software libre y self-hosting. Lo mantengo en mi tiempo libre. Áreas de expertise - Linux server administration: RHEL, CentOS, Rocky, Alma, Fedora, Debian, Ubuntu - Network services: Postfix, BIND, Samba, NFS, Squid - Virtualización y containers: KVM, Proxmox, Docker, Podman - Monitoring: Prometheus, Grafana, Zabbix - Automation: Ansible, Bash scripting - Self-hosting: Vaultwarden, Nextcloud, Jellyfin, Immich, Homelab Contacto - Email: [email protected]

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *