Slow JSON Stream: DoS con 64 conexiones y menos de 1 kbps
Llevo un tiempo investigando una clase de ataque que me parece especialmente interesante porque combina algo que ya existe (los ataques slow HTTP) con algo que nadie había estudiado bien: el body reader de los frameworks HTTP modernos.
El resultado se llama Slow JSON Stream, y el TL;DR es este: con 64 conexiones simultáneas enviando 1 byte por segundo desde un solo ordenador, puedes dejar un servidor PHP/Laravel sin responder en menos de 2 minutos. Sin herramientas especiales. Sin conocimiento previo. Con menos de 1 kbps de ancho de banda total.
Y el 90% de los frameworks que probé son vulnerables por defecto.
Cómo funciona?
La idea es sencilla. Cuando un servidor HTTP recibe un request con Content-Type: application/json, el framework tiene que leer el body completo antes de poder procesarlo. El JSON parser necesita el } o ] de cierre para saber que el documento está completo.
Slow JSON Stream abusa de esto: abrimos una conexión, enviamos un prefijo JSON válido, por ejemplo {"items":[{"a":1},, y lo mandamos a 1 byte por segundo usando Transfer-Encoding: chunked. Nunca enviamos el token de cierre.
El servidor espera. Y espera. Y espera.
Cada conexión de esas ocupa un worker thread, una goroutine, o un slot del event loop. Abrimos 64 conexiones así al mismo tiempo y el pool de workers del servidor queda saturado. Las peticiones legítimas se van acumulando en la cola, se quedan sin respuesta y empiezan a fallar.
POST /api/data HTTP/1.1
Host: target.com
Content-Type: application/json
Transfer-Encoding: chunked
1\r\n
{\r\n
1\r\n
"\r\n
... (1 byte por segundo, para siempre)
El payload es JSON sintácticamente válido en todo momento. El parser no puede rechazarlo porque cada byte que ha recibido hasta ahora es un prefijo legal de un documento JSON completo. Está bloqueado esperando el token de cierre que nunca llega.
En qué se diferencia de Slowloris?
Slowloris (2009) es el abuelo de los ataques slow HTTP. Ataca la fase de cabeceras: nunca envía el \r\n final que termina los headers. Pero ese ataque lleva 15 años mitigado. nginx, Caddy, Apache… todos tienen client_header_timeout configurado por defecto en 10-60 segundos.
Slow JSON Stream ataca el body reader, que es una capa que prácticamente ningún framework protege. El equivalente a client_header_timeout para el body (client_body_timeout, MinRequestBodyDataRate, ReadTimeout) no existe o está configurado en minutos en la config por defecto de 30 de los 32 frameworks que probé.
El más cercano en la historia es R.U.D.Y. / Slow POST (2011), pero hay diferencias importantes:
- R.U.D.Y. usa
Content-Lengthdeclarado. Slow JSON Stream usaTransfer-Encoding: chunked, así que no hay tamaño declarado, y el servidor espera el chunk terminator0\r\n\r\nque nunca llega. - R.U.D.Y. envía bytes arbitrarios. Slow JSON Stream envía un prefijo JSON válido, bloqueando simultáneamente el HTTP layer y el JSON parser.
- R.U.D.Y. es detectable por WAFs inspeccionando el
Content-Length. Slow JSON Stream no tiene tamaño declarado y es indistinguible de un cliente móvil lento subiendo datos.
Los resultados (spoiler: mal panorama)
Probé 41 targets: 32 frameworks de aplicación y 9 componentes de infraestructura (proxies, WAFs, API gateways). Los resultados:
- 37 de 41 (90%) son vulnerables por defecto
- Solo 4 resisten: go-fiber, python-fastapi con streaming, Traefik y Apache ModSecurity
Los peores casos
PHP/Laravel es el más dramático. Con 64 conexiones, el RSS (memoria residente) crece a 258 MB/minuto. Un contenedor de 512 MB cae en menos de 2 minutos. Impacto inmediato desde un solo ordenador.
Python/Flask y Ruby/Rails son peor en otro sentido: no se degradan gradualmente, directamente se caen. Flask tiene un 94% de error rate a C=64. Rails tiene un 100%. El servicio simplemente deja de responder a todo.
.NET/ASP.NET Core (Kestrel) acumula entre 66-120 MB/minuto de RSS. El motivo: MinRequestBodyDataRate está deshabilitado por defecto (valor null).
El grupo de “vulnerables latentes”
Los frameworks async (Node.js, Go, Rust, Python async…) son una historia diferente. A C=64 casi no se nota nada: las goroutines y las corrutinas son baratas, consumen ~2 KB por conexión bloqueada vs ~1 MB de un thread.
Pero no tienen ningún mecanismo para cerrar esas conexiones. Nunca. Las goroutines se acumulan indefinidamente. En producción, donde los pools se saturan a C=100-500, el mismo ataque de 64 conexiones puede tumbar el servicio.
| Framework | Tier | Evidencia |
|---|---|---|
| PHP/Laravel | 1 | 258 MB/min RSS, caída en <2 min |
| .NET/Kestrel | 1 | 66-120 MB/min RSS |
| Python/Flask | 3 (crash) | 94% error rate a C=64 |
| Ruby/Rails | 3 (crash) | 100% error rate a C=64 |
| Node.js (todos) | 2 | Sin body timeout, goroutines acumuladas |
| Go (gin, echo…) | 2 | Sin body timeout, goroutines acumuladas |
| Rust (axum, actix) | 2 | Sin body timeout, tasks acumuladas |
| go-fiber | RESISTENTE | Buffer de 4 KB, mata conexiones en ~1s |
Qué funciona como defensa y qué no?
Muchas cosas que parecen defensas no funcionan. El client_body_timeout 60s de nginx por defecto no ayuda: el atacante envía 1 byte cada 59 segundos y el timeout se resetea con cada chunk. Los límites de tamaño de body (client_max_body_size, WAF size limits) solo se aplican cuando el body completo ha llegado, y este nunca llega. El rate limiting por IP limita escala pero no para una sola conexión lenta.
Lo que sí cierra el agujero:
nginx:
client_body_timeout 10s;
limit_req zone=one burst=10;
Apache httpd:
RequestReadTimeout body=10,MinRate=100
El MinRate=100 B/s mata directamente una conexión a 1 B/s.
ASP.NET Core / Kestrel (el más crítico, porque MinRequestBodyDataRate viene a null por defecto):
options.Limits.MinRequestBodyDataRate = new MinDataRate(
bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(10));
Go:
srv := &http.Server{ReadTimeout: 10 * time.Second}
Node.js >= 18:
server.requestTimeout = 10_000;
Spring Boot:
server.tomcat.connection-timeout: 10000
Por qué go-fiber y fastapi-streaming resisten
Los únicos dos frameworks de aplicación que resisten por defecto usan mecanismos distintos.
go-fiber usa fasthttp internamente, que tiene un límite de buffer de lectura de 4 KB. Cualquier body que no complete en 4 KB es descartado. Las conexiones del atacante mueren en ~1 segundo. Simple y efectivo.
python-fastapi con ijson usa un parser JSON en streaming: el handler se invoca por cada token, no al final del documento completo. Puede devolver respuesta (o error) sin haber leído todo el body. También mata las conexiones en ~1 segundo.
Si tu framework necesita el body completo antes de hacer nada, eres vulnerable salvo que pongas un timeout de ancho de banda explícito.
CVSS: 6.5 a 8.6 HIGH
El score varía según el deployment:
- Microservicio / Kubernetes / API gateway: CVSS 8.6 HIGH (Scope: Changed, porque tirar un servicio cascadea a todo lo que depende de él)
- Despliegue standalone: CVSS 7.5 HIGH
- Endpoint autenticado: CVSS 6.5 MEDIUM
El vector base es el mismo para todos:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H
Ningún privilegio necesario, ninguna interacción del usuario, acceso solo de red. El AC:L (complejidad baja) es lo que más me preocupa: no necesitas conocimiento especializado ni herramientas custom. Chunked transfer encoding es HTTP estándar.
El código y el testbed
He publicado todo en GitHub: el CLI de ataque, los 41 servidores dockerizados para reproducir los experimentos, y el paper completo.
Repo: github.com/cr0hn/slowjson
Para probarlo contra un servidor propio:
git clone https://github.com/cr0hn/slowjson
cd slowjson
pip install -e cli/
# Levantar todos los servidores de prueba
make up
# Ataque a un target específico (solo servidores propios)
slowjson attack http://localhost:8001/ingest \
--payload A --bytes-per-tick 1 --tick-interval-ms 1000 \
--connections 64 --duration 90 \
--probe-url http://localhost:8001/healthz \
--docker-container slowjson-python-fastapi \
--output results/test.json \
--i-own-this-server
El flag --i-own-this-server es obligatorio. Úsalo solo contra servidores que sean tuyos.
Conclusión
Lo que más me llama la atención de esta investigación no es el ataque en sí (que es conceptualmente simple) sino que lleva años sin estudiarse de forma sistemática. El body reader de los frameworks HTTP es un punto ciego. La comunidad hardened client_header_timeout en 2009 y se olvidó del body.
El fix en la mayoría de casos es una línea de configuración. Pero hay que saber que existe el problema.
Si usas alguno de los frameworks de la lista y no has configurado explícitamente un timeout de ancho de banda para el body de los requests, probablemente eres vulnerable.
El paper completo está en el repo si quieres todos los detalles metodológicos, las tablas de resultados y el análisis estadístico: paper/paper.pdf.