# Montando un WAF en 10 minutos: atacando una app antes y después de Aegis

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](https://github.com/divinelabio/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:

![Arquitectura del laboratorio: la misma app, dos caminos — acceso directo vs. detrás del WAF](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/f681a676-f188-4774-a154-315dc9cbab5f.png)

Aegis es un **reverse proxy + WAF** que trae el [OWASP Core Rule Set](https://owasp.org/www-project-modsecurity-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 |

![Home de DemoShop con los cuatro formularios vulnerables](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/2e5797c3-ac72-41f3-975f-fce74eae0fe6.png align="center")

* * *

## Paso 1 — Levantar el laboratorio

Todo el stack vive en un `docker-compose.yml`. Un solo comando:

```bash
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.

![docker compose ps: los cuatro contenedores del lab, bases healthy](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/d9f0ee56-100c-4dd5-a33a-dae91b6da3c5.png align="center")

* * *

## 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:5000`
    
*   **WAF:** 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:

![Managed Rulesets: los grupos del OWASP CRS activos (Attack Protection, Protocol Enforcement, etc.)](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/ba2a8514-2add-4a4f-a5d2-97fbeb506dc2.png align="center")

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

![Consola de Aegis: la ruta demoshop apuntando al origin, Enabled](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/0b717b8f-4232-417d-987e-5d23cbd45d46.png align="center")

* * *

## 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".

```bash
python attack.py
```

### SQL Injection — bypass de login

El clásico. La app arma la query concatenando lo que le mandás a ciegas:

```sql
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:

```sql
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):**

```bash
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:

![Bypass con ' OR '1'='1'--: Login OK listando a admin, alice y bob](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/87ebdc44-48cf-4325-b486-73eceac2e6da.png align="center")

**Opción 2 — entrar directo como admin, sin password:**

```bash
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:

![Bypass con admin'--: Login OK entrando directo como admin](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/40aaa1b6-898f-4c3f-9876-1d28646e70fc.png align="center")

**Opción 3 — el truco en los dos campos:**

```bash
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:

![Bypass con ' OR '1'='1 en ambos campos: Login OK listando todos los usuarios](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/71c72822-14c2-432b-9a87-768b7392e98e.png align="center")

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

```bash
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`:

![El buscador devolviendo admin — $S3cr3t-Admin-Pass! gracias al UNION](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/6b6420f3-49bf-4d9d-8ebc-975722000e84.png align="center")

### XSS reflejado

```bash
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:

![HTML inyectado renderizado en /greet: "PWNED — XSS reflejado"](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/2497a568-9edb-4090-b6ae-84d4f5e65007.png align="center")

En un escenario real esto es robo de sesión, keylogging, defacement.

### Path Traversal

```bash
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:

![El /etc/passwd del contenedor servido por la app como text/plain](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/31d939ed-5e0d-44bf-ab65-db0c99c32cc3.png align="center")

**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.

```bash
python attack.py --md resultados.md
```

La salida de la corrida real:

![attack.py: 4/4 explotan sin WAF, 4/4 bloqueados con Aegis, trafico legitimo pasa](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/6b072f9c-c6c2-4f64-aed1-7a79797bacd6.png align="center")

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):

![Panel Traffic Events de Aegis con el trafico del laboratorio en tiempo real](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/455d4892-4ea8-424c-ab1a-88a74353d2bb.png align="center")

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**:

![Threat Logs: forensic stream con los ataques bloqueados, 100% block rate](https://cdn.hashnode.com/uploads/gql/65c385adb3ed131c0c948bd1/56424ca9-cc83-4a68-a6b4-402eb8d9663e.png align="center")

* * *

## 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](https://github.com/safernandez666/waf-poc): `docker compose up -d` y en diez minutos tenés el mismo laboratorio corriendo. El WAF que usamos es [Aegis](https://github.com/divinelabio/aegis): open source, escrito en Go, con el OWASP Core Rule Set integrado.
