Infraestructura 8 min de lectura

Lo que se cuela del WAF: un ataque DDoS visto desde PHP-FPM

Un DDoS con 650 procesos de PHP y unos 20 GB de RAM sirve para explicar por qué filtrar tráfico en Cloudflare es solo una parte de la defensa, y qué capas frenan lo que se cuela del WAF antes de que llegue a PHP.

El 29 de octubre, la cuenta de elhacker.net en X publicó unas gráficas de su servidor en mitad de un ataque DDoS. Las presentaba con humor:

Cuando recibes un ataque DDoS y el WAF de CloudFlare, que aunque filtra la mayoría de peticiones, cuando deja pasar algunos miles de peticiones, te encuentras con gráficas de Monitorix muy divertidas.

Debajo venían las cifras: PHP consumiendo 20 GB de RAM, casi 2.000 consultas SELECT por segundo, y ProxySQL —un intermediario que se pone delante de MySQL o MariaDB y que, entre otras cosas, puede cachear resultados— respondiendo 3 de cada 4 desde memoria. Y la medida que habían tomado: pasar PHP-FPM a pm = static con pm.max_children = 650, es decir, 650 procesos de PHP siempre arrancados y unos 18-20 GB de RAM ocupados de forma permanente.

Me parece un caso muy bueno para explicar algo que suele entenderse al revés. Cuando pones Cloudflare delante de una web, lo normal es pensar que el ataque se queda fuera. Y se queda fuera casi todo. El problema es lo poco que entra, porque esas peticiones llegan a tu servidor con todo el derecho, y si cada una es cara de servir, unos pocos miles bastan para tumbarlo.

No conozco la configuración completa de su servidor. Lo tomo como punto de partida para explicar qué pasa con lo que se cuela y qué capas pueden frenarlo.

Ilustración de un torrente de peticiones rojas que choca contra un muro de cristal azul; la mayoría se deshace en chispas y un hilo fino lo atraviesa hasta un servidor con el rótulo «PHP» que brilla al rojo, con el texto «Lo que se cuela del WAF»

Ilustración generada con IA para este artículo. No representa el servidor de elhacker.net.

Por qué unos pocos miles de peticiones pesan tanto

Una petición a una web en PHP no es gratis. Cuando no hay caché, cada visita a una página arranca un trabajo completo: un proceso de PHP la recibe, ejecuta el código, hace sus consultas a la base de datos, monta el HTML y lo devuelve. Mientras dura todo eso, ese proceso está ocupado y no atiende a nadie más.

PHP-FPM, que es lo que ejecuta PHP detrás de nginx en la mayoría de servidores, tiene un número máximo de procesos: el pm.max_children. Cuando todos están ocupados, las peticiones nuevas esperan en cola. Y aquí entra una cuenta sencilla que conviene tener presente: los procesos que necesitas son las peticiones por segundo multiplicadas por lo que tarda cada una.

  • Si cada petición tarda 100 milisegundos, 650 procesos podrían atender unas 6.500 por segundo. Es la cuenta ideal, suponiendo que la CPU y la base de datos aguanten ese ritmo.
  • Si la base de datos se satura y cada petición pasa a tardar un segundo, esos mismos 650 procesos solo atienden 650 por segundo.

Por eso los ataques hacen daño de forma tan desproporcionada. No hace falta que lleguen millones de peticiones: basta con que las que llegan hagan que todas tarden más. La base de datos se ralentiza, cada proceso de PHP tarda más en terminar, se llenan, la cola crece, y las visitas legítimas esperan detrás del tráfico del ataque.

Lo que hicieron, y lo que cuesta

El cambio a pm = static tiene sentido. En el modo dynamic, que es el habitual, PHP-FPM arranca procesos cuando sube el tráfico y los mata cuando baja. Arrancar un proceso cuesta poco, pero en mitad de un pico cada milisegundo cuenta, y con static los 650 están ya preparados. Uno de los comentarios al tuit lo resumía bien: el modo estático mantiene ocupada la memoria de todos esos procesos, pero elimina la espera de arrancarlos justo cuando más carga hay.

Lo que no resuelve es el fondo. Si cada proceso ocupa unos 30 MB (20 GB entre 650), la RAM pone un primer techo al número de procesos. Pero no es el único: más procesos de PHP no hacen más rápida la base de datos, hacen que lleguen más consultas a la vez. Si esas 2.000 por segundo son las que recibe ProxySQL y responde el 75 % desde memoria, todavía llegan a MariaDB unas 500, y son justo las que la caché no tenía. Subir procesos es comprar margen con hardware. Está bien como parche en mitad de un incidente, pero no detiene nada.

Los dos primeros comentarios, de Vítor y de Buswe, apuntaban a lo mismo: poner un límite de peticiones en nginx antes de que la petición llegue a PHP. Lo que se cuela del WAF, decía el segundo, «ya no ocupa un child de FPM». Ese es el cambio de enfoque: no se trata de aguantar más peticiones caras, sino de que una petición del ataque cueste lo menos posible.

La defensa va en capas

Lo útil del caso es que se ve muy bien que la protección no es un único producto, sino varias capas. Cada una para lo que puede, y la siguiente se encarga de lo que se escapa. De fuera hacia dentro:

1. Cloudflare: que no llegue

El WAF de Cloudflare es la primera capa y la que más tráfico para, pero se puede afinar. Las reglas de limitación de velocidad (rate limiting) cortan a quien hace demasiadas peticiones en poco tiempo. Y en las páginas caras —la búsqueda, el login, cualquier cosa que no se pueda cachear— se puede poner un desafío (managed challenge), que a la mayoría de los visitantes no les pide nada o casi nada y a muchos bots les cuesta.

Hay un detalle que se olvida a menudo: si tu servidor acepta conexiones de cualquiera, Cloudflare se puede esquivar. Quien conozca la IP real del servidor puede atacarla directamente y saltarse todo el filtro. El servidor solo debería aceptar tráfico web que venga de las IP de Cloudflare.

2. nginx: que no llegue a PHP

El límite que proponían los comentarios es limit_req, y se configura en pocas líneas:

# En el bloque http: 10 peticiones por segundo por IP
limit_req_zone $binary_remote_addr zone=php:10m rate=10r/s;

# En el location que pasa a PHP
location ~ \.php$ {
    limit_req zone=php burst=20 nodelay;
    limit_req_status 429;
    fastcgi_pass php:9000;
    # ...
}

nginx devuelve un error 429 a quien se pasa del límite y lo hace sin tocar PHP, así que cuesta casi nada. Pero tiene una trampa importante detrás de Cloudflare: todas las peticiones llegan desde las IP de Cloudflare, no desde las de los visitantes. Si no le dices a nginx dónde está la IP real, el límite «por IP» se aplica a unos pocos servidores de Cloudflare y acabas bloqueando a visitantes legítimos. Hay que configurar el módulo real_ip con las IP de Cloudflare y la cabecera CF-Connecting-IP.

Y un límite por IP tiene un alcance limitado contra un ataque distribuido, donde cada máquina hace pocas peticiones. Por eso conviene combinarlo con lo siguiente.

3. Una caché de un segundo

La capa que más rinde en un ataque suele ser la más sencilla: una microcaché en nginx, con fastcgi_cache, que guarda cada página durante un segundo o unos pocos. Parece poco, pero cambia la cuenta por completo. Si llegan mil peticiones por segundo a la portada, PHP puede pasar de generarla mil veces a generarla una, y nginx sirve el resto desde la caché. Para eso hay que activar fastcgi_cache_lock: sin él, mientras la página se está generando por primera vez, todas las peticiones que llegan a la vez pasan también a PHP. Para quien visita la web, un segundo de retraso en ver una actualización no se nota.

Solo vale para lo que es igual para todo el mundo: las páginas de visitantes anónimos. En cuanto hay sesión, carrito o cualquier cookie que cambie la página, hay que saltársela. Y los ataques lo saben: es habitual que añadan parámetros aleatorios a la URL (?x=83721) para que cada petición parezca una página distinta y no esté en caché. Ahí la defensa es construir la clave de la caché solo con los parámetros que la web usa de verdad, o volver a la capa anterior.

4. PHP-FPM: dimensionarlo, no inflarlo

Con las capas anteriores, PHP solo recibe lo que de verdad hay que generar, y entonces sí tiene sentido ajustarlo. Para el pm.max_children, una primera estimación sale de dividir la RAM que puedes dedicarle entre lo que ocupa un proceso bajo carga, dejando sitio para la base de datos y el sistema. Pero ese es el máximo que cabe, no el que conviene: si la CPU o la base de datos se saturan antes, más procesos solo alargan la cola. Y dos ajustes que se olvidan:

  • request_terminate_timeout: mata una petición que tarda demasiado. Sin él, una petición atascada ocupa un proceso indefinidamente.
  • pm.max_requests: recicla cada proceso tras un número de peticiones, para que las pérdidas de memoria no se acumulen.

5. La base de datos: que cada consulta cueste poco

Lo último es lo que hacían ya con ProxySQL: cachear las consultas que se repiten, con Redis o con lo que use cada aplicación. Y, antes que nada, revisar los índices. Una consulta que tarda 50 milisegundos en vez de 2 no se nota con diez visitas, pero con quinientas por segundo es la diferencia entre aguantar y caerse.


Cloudflare no es un escudo que se pone y se olvida. Es la primera capa, la que para casi todo, pero el daño lo hace lo que deja pasar. Y frente a eso, la pregunta útil no es cuánto tráfico aguanta tu servidor, sino cuánto le cuesta cada petición. Cada capa que la para antes, o la sirve sin llegar a PHP, la abarata.

Esta web, por cierto, está hecha en PHP —lo contaba en «PHP ha muerto, y hace diez días hubo que parchear medio internet»— y corre con nginx y PHP-FPM. Son las mismas piezas que en el caso de elhacker.net, a una escala mucho más modesta.

Si tienes una web con algo de tráfico y no sabes cuántas de estas capas tiene, o si la última vez que hubo un pico la web dejó de responder y nadie supo por qué, escríbeme. Una primera revisión permite ver dónde se está gastando ese margen, y suele salir bastante más barata que pasar un ataque sin preparación.

Preguntas frecuentes

¿Por qué una web detrás de Cloudflare puede caer en un ataque DDoS?

Porque el WAF filtra la mayoría de las peticiones, pero no todas. Las que pasan llegan al servidor como visitas normales, y si cada una obliga a ejecutar PHP y consultar la base de datos, unos pocos miles por segundo bastan para ocupar todos los procesos. También cae si el servidor acepta conexiones directas y el atacante conoce su IP real.

¿Es mejor pm = static o pm = dynamic en PHP-FPM?

Static mantiene siempre arrancados todos los procesos: evita la espera de crearlos en un pico, a cambio de mantener ocupada su memoria aunque no haya tráfico. Dynamic los crea y los cierra según el tráfico. Ninguno de los dos protege de un ataque: si la base de datos es el cuello de botella, más procesos solo mandan más consultas a la vez.

¿Funciona limit_req de nginx detrás de Cloudflare?

Sí, pero solo si nginx conoce la IP real del visitante. Detrás de Cloudflare todas las peticiones llegan desde sus IP, así que hay que configurar el módulo real_ip con los rangos de Cloudflare y la cabecera CF-Connecting-IP. Si no, el límite por IP se aplica a los servidores de Cloudflare y bloquea a visitantes legítimos.

Sigue leyendo

Infraestructura

El servidor estaba vivo, pero no contestaba

Estoy fuera y uno de mis servidores decide que necesita una placa base nueva. OVHcloud la cambia, las MAC cambian con ella y me lo devuelve en Rescue Mode: discos sanos, RAID sano, Ubuntu entero y una configuración de red que espera una tarjeta que ya no existe. El arreglo cabía en una línea de YAML.

Leer más →

¿Te ha resultado útil o quieres comentar algo?

Hablemos →