Montando un WAF en 10 minutos: atacando una app antes y después de Aegis
SQLi, XSS y path traversal contra la misma app: 4/4 explotan sin WAF, 4/4 bloqueados con Aegis — con evidencia y repo para probarlo.

I have a bachelor's degree in Technology from the University of Palermo, a Master in Information Security from the University of Murcia and different certifications such as CISSP | CISM | CDPSE | CCSK | CSX | MCSA | SMAC™️ | DSOE | DEPC | CSFPC | CSFPC | 5x AWS Certified.
He is currently CISO at Klar, a Mexican Fintech. He was fortunate to be awarded as CISO of the Year in Argentina in 2021 and was among the Top 100 CISO's in the World in 2022.
A lover of new technologies, he has developed a career in DevSecOps and Cloud Security at Eko Party, the largest security conference in Latin America.
Todos sabemos en teoría qué hace un Web Application Firewall: se pone delante de tu aplicación y filtra el tráfico malicioso antes de que llegue al backend. Pero "en teoría" no convence a nadie. En este post lo vamos a ver: levantamos una app deliberadamente vulnerable, la atacamos con SQL injection, XSS y path traversal, y después pusimos Aegis —un WAF open source escrito en Go— delante de ella y repetimos exactamente los mismos ataques.
Spoiler: el antes y el después se explican solos.
⚠️ Disclaimer. Todo esto corre en un laboratorio local, contra una app de juguete que escribí para este post. Atacar sistemas que no son tuyos es ilegal. Esto es material educativo de defensa.
Qué vamos a montar
La arquitectura de la POC es simple. La misma app vulnerable, expuesta por dos caminos:

Aegis es un reverse proxy + WAF que trae el OWASP Core Rule Set con anomaly scoring. Es decir, no bloquea por una sola coincidencia tonta: va sumando un puntaje de amenaza según las reglas que matchean y recién bloquea al pasar un umbral. Eso reduce los falsos positivos sobre tráfico legítimo.
La app vulnerable la escribí en Python (Flask), con cuatro agujeros a propósito:
| Endpoint | Vulnerabilidad |
|---|---|
/login |
SQL Injection (bypass de autenticación) |
/search |
SQL Injection (UNION, exfiltración de datos) |
/greet |
XSS reflejado |
/download |
Path Traversal |

Paso 1 — Levantar el laboratorio
Todo el stack vive en un docker-compose.yml. Un solo comando:
docker compose up -d
Esto levanta cuatro contenedores: la app vulnerable, Aegis, y las dos dependencias que este build de Aegis necesita (PostgreSQL para configuración, ClickHouse para analytics en tiempo real).
Esperá ~20 segundos a que las bases pasen el healthcheck y Aegis termine de arrancar.

Paso 2 — Configurar la ruta en Aegis
Aegis todavía no sabe a quién tiene que proteger. Hay que decírselo — desde su consola web (http://localhost:8081, usuario admin, contraseña admin) o por su API, que es como lo hace este lab para que sea reproducible. Creá una ruta nueva:
Hostname / path:
/(todo el tráfico)Upstream:
http://demoshop:5000WAF: activado, modo bloqueo (no solo detección)
A partir de acá, lo que entra por :8080 pasa por el filtro antes de tocar la app. El modo bloqueo no es un toggle de la ruta: viene del config.yaml (sections.waf_core.mode: blocking), y lo que la consola muestra activo es el OWASP CRS con sus grupos de reglas:

La ruta creada en el paso anterior, vista desde Network → Routing:

Paso 3 — El "ANTES": atacar sin protección
Escribí un script en Python que lanza la misma batería de ataques contra ambos destinos y arma la comparación. Primero miremos solo la columna "sin WAF".
python attack.py
SQL Injection — bypass de login
El clásico. La app arma la query concatenando lo que le mandás a ciegas:
SELECT id, username, role FROM users
WHERE username = '{username}' AND password = '{password}'
Acá hay un detalle que a muchos les explota en la cara la primera vez: en SQL el AND se evalúa antes que el OR. Si ponés solo ' OR '1'='1 en el usuario y cualquier cosa en la password, la query queda:
WHERE username = '' OR ('1'='1' AND password = 'loquesea')
El '1'='1' solo "salva" la condición de la password — y como loquesea no es la password de nadie, no matchea nada y te come un "Credenciales inválidas". Estas son las tres formas que sí funcionan (probadas una por una contra :5000):
Opción 1 — comentar el resto de la query (la más limpia):
curl -s --get "http://localhost:5000/login" \
--data-urlencode "username=' OR '1'='1'--" \
--data-urlencode "password=loquesea"
El -- convierte todo lo que sigue en un comentario, así que la password deja de chequearse. La query efectiva es WHERE username = '' OR '1'='1', siempre verdadera. Entramos sin credenciales y la app nos lista todos los usuarios con su rol:

Opción 2 — entrar directo como admin, sin password:
curl -s --get "http://localhost:5000/login" \
--data-urlencode "username=admin'--" \
--data-urlencode "password=loquesea"
Cierra el string en admin y comenta el chequeo de password: la query efectiva es WHERE username = 'admin'. Más discreto que la opción 1, porque devuelve una sola fila en vez de todas:

Opción 3 — el truco en los dos campos:
curl -s --get "http://localhost:5000/login" \
--data-urlencode "username=' OR '1'='1" \
--data-urlencode "password=' OR '1'='1"
Al repetir el payload en la password, el OR final salva la query aunque falle el del medio. Es la versión que aguanta forms donde no sabés si el -- va a sobrevivir al viaje:

En las tres capturas fijate en el pie: la app misma te muestra la query completa que ejecutó. Eso es oro para el post, porque deja en evidencia que el problema no es el payload, es la concatenación.
SQL Injection — exfiltración con UNION
curl -s --get "http://localhost:5000/search" \
--data-urlencode "q=x' UNION SELECT username, password FROM users-- -"
La app nos devuelve, mezclado con los productos, el usuario y la contraseña del admin. Datos que jamás deberían salir de la tabla users:

XSS reflejado
curl -s --get "http://localhost:5000/greet" \
--data-urlencode "name=<script>alert('xss')</script>"
La app refleja el parámetro sin escaparlo, así que el navegador ejecuta el script. Lo comprobado con un navegador real (Playwright/Chromium): al entrar a /greet con ese payload, el navegador dispara el alert('xss'). Y con un payload visible en lugar del script, se ve claramente lo que una víctima vería — HTML arbitrario inyectado en la página:

En un escenario real esto es robo de sesión, keylogging, defacement.
Path Traversal
curl -s --get "http://localhost:5000/download" \
--data-urlencode "file=../../../../etc/passwd"
La app concatena el nombre del archivo sin sanitizar, así que nos salimos de su carpeta y leemos un archivo del sistema:

Cuatro de cuatro. La app está completamente a merced del atacante.
Paso 4 — El "DESPUÉS": los mismos ataques, ahora con Aegis
Ahora apuntamos el script al puerto del WAF (:8080) y repetimos exactamente lo mismo. No cambiamos ni una coma de los payloads.
python attack.py --md resultados.md
La salida de la corrida real:

El resultado de la tabla comparativa (generada por el propio script):
| Ataque | Categoría | Sin WAF | Con WAF (Aegis) |
|---|---|---|---|
| SQLi — bypass de login | SQL Injection | Explota ❌ (HTTP 200) | Bloquea ✅ (HTTP 403) |
| SQLi — UNION en búsqueda | SQL Injection | Explota ❌ (HTTP 200) | Bloquea ✅ (HTTP 403) |
| XSS reflejado | Cross-Site Scripting | Explota ❌ (HTTP 200) | Bloquea ✅ (HTTP 403) |
| Path Traversal — /etc/passwd | Path Traversal | Explota ❌ (HTTP 200) | Bloquea ✅ (HTTP 403) |
| Tráfico legítimo (control) | Benigno | Pasa ✅ | Pasa ✅ |
La última fila es la clave y la que suele faltar en estas demos: el tráfico legítimo sigue pasando. Un WAF que bloquea todo no sirve; la gracia es cortar el ataque sin romper al usuario real. Buscá un producto normal y la respuesta llega perfecta a través del WAF.
Y lo mejor: el WAF no solo corta — ve todo. En la consola de Aegis, Traffic Events muestra el tráfico agregado en tiempo real (rutas, paths, distribución 2xx/4xx):

Y Threat Logs es la vista forense: cada request bloqueada con su IP, path, status y veredicto. Ojo al número de arriba: 11 eventos, 11 bloqueados, 100% de tasa de bloqueo:

Qué está pasando por debajo
Cuando mandás ' UNION SELECT ..., el OWASP CRS lo reconoce como un patrón de inyección SQL y le suma puntos al "anomaly score" de esa request. Al cruzar el umbral configurado, Aegis responde 403 Forbidden y la request nunca llega a tu app en Python. Tu código vulnerable sigue estando vulnerable — pero el atacante nunca lo alcanza.
Esa es la idea central del WAF como capa de defensa: no arregla tu código, pero te compra tiempo mientras lo arreglás, y te da visibilidad de quién te está atacando y cómo.
Importante: un WAF es defensa en profundidad, no un reemplazo de escribir código seguro. La solución real al SQLi es parametrizar las queries; al XSS, escapar la salida; al traversal, validar la ruta. El WAF es el cinturón de seguridad, no la excusa para manejar mal.
Cerrando
En diez minutos pasamos de una app indefensa a una app detrás de un WAF que frena los ataques más comunes del OWASP Top 10, sin tocar una línea del código vulnerable. El antes y el después hablan por sí solos.
El código de toda la POC (app vulnerable, compose y script de ataque) está en GitHub: docker compose up -d y en diez minutos tenés el mismo laboratorio corriendo. El WAF que usamos es Aegis: open source, escrito en Go, con el OWASP Core Rule Set integrado.



