- Prima
docker run(imperativo, capisco come), poidocker-compose(dichiarativo, dico cosa) - I container-firewall (fw1, fw2) fanno forwarding con iptables espliciti: niente automatismi nascosti
docker network connecte' il momento in cui vedi il concetto "container multi-rete" con manonsenter+ip linksull'host rivelano namespace/veth/bridge che Docker crea al posto tuo- Un WAF (ModSecurity) e' un reverse proxy: cambia la topologia (due container), non solo la config
- Mai fidarsi del gateway di default per il traffico di ritorno tra zone: rotta esplicita, sempre
▶ $ history
docker network create --subnet— crea rete isolata con subnet fissadocker run --network ... --ip --cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1— container-router pronto dalla creazione, nientesysctl -wa runtimedocker network connect— attacca container a seconda rete (multi-zona)docker exec ... iptables -P FORWARD DROP— firewall L3/L4 dentro containerdocker exec ... iptables -R FORWARD 1 ...— sostituisce una regola esistente per posizioneip route add <rete> via <router>— rotta esplicita, mai il gateway di default tra zone diversensenter -t $PID -n— entra nel namespace di rete del container (anche perip route/tcpdumpse l'immagine non li ha)owasp/modsecurity-crs:nginxconBACKEND=http://...— WAF reverse proxy davanti a un backend gia' esistentedocker compose up— cristallizza l'imperativo in dichiarativo
Perche' questo lab#
Stesso scenario: Cisco → Linux namespaces → Docker.
L'approccio e' diverso dai lab precedenti. Prima si fa tutto a mano con docker run, un pezzo alla volta. Solo dopo aver capito come funziona ogni componente si cristallizza in docker-compose.yml. Il compose e' la forma dichiarativa di cio' che hai gia' capito: scriverlo diventa quasi meccanico.
Codice completo, pronto da lanciare: github.com/Barno/u-random-dev/tree/final/docker-dmz — imperativo e dichiarativo, entrambi con modalita' interattiva o diretta, entrambi con verifica finale automatica.
La regola del lab: a ogni fase c'e' un test intermedio. Non si prosegue finche' il test non passa. Se arrivano 0 pacchetti si diagnostica con tcpdump + iptables -L -v -n + nsenter, non si tira a indovinare.
La topologia#

Quattro zone, cinque container, due firewall espliciti. Stessa logica del Lab 04/05, strumenti diversi.
┌──────────┐
│ attacker │ net_ext: 10.20.0.50
└────┬─────┘
│ net_ext 10.20.0.0/24
┌────▼─────┐
│ fw1 │ ACL: ACCEPT 80/443 verso web; DROP default
│ │ net_ext: 10.20.0.2 | net_dmz: 10.21.0.2
└────┬─────┘
│ net_dmz 10.21.0.0/24
┌────▼─────┐
│ web │ listener :80 :443
│ (Sofia) │ net_dmz: 10.21.0.10 | net_mgmt: 10.22.0.10
└────┬─────┘
│ net_mgmt 10.22.0.0/24
┌────▼─────┐
│ fw2 │ ACL: ACCEPT 3306 SOLO src=web; DROP default
│ │ net_mgmt: 10.22.0.2 | net_lan: 10.23.0.2
└────┬─────┘
│ net_lan 10.23.0.0/24
┌────▼─────┐
│ db │ listener :3306
│ (Giulia) │ net_lan: 10.23.0.10
└──────────┘Modello: container-firewall esplicito#

Ho scelto di replicare ns-fw1 e ns-fw2 come container dedicati che fanno da firewall L3/L4. E' il modello che tiene tutto esplicito: routing tra zone, iptables, veth, tutto visibile e niente automatismo nascosto. E' il piu' vicino al Lab 05.
La mappatura con i lab precedenti e con il futuro cloud:
| Lab a mano (04/05) | Qui (Docker) | Domani (AWS) |
|---|---|---|
ip netns | container | istanza EC2 |
| veth pair | rete Docker + endpoint | ENI / subnet attach |
| ns-fw1/fw2 | container-firewall | security group |
| FORWARD DROP | iptables nel container-fw | SG default-deny ingress |
| bridge L2 | docker network bridge | subnet |
-s 10.10.x | -s <ip web> | "src = SG del web" |
Un docker run attacca il container a una sola rete. I container che stanno su due zone (fw1, web, fw2) richiedono un secondo passaggio esplicito: docker network connect <rete> <container>. E' li' che tocchi con mano il concetto "container multi-rete": non una riga di YAML invisibile, un comando che dai tu e di cui vedi l'effetto.
Riferimento rapido#
net_ext: 10.20.0.0/24
net_dmz: 10.21.0.0/24
net_mgmt: 10.22.0.0/24
net_lan: 10.23.0.0/24
CONTAINER
attacker net_ext: 10.20.0.50
fw1 net_ext: 10.20.0.2 net_dmz: 10.21.0.2
web net_dmz: 10.21.0.10 net_mgmt: 10.22.0.10
fw2 net_mgmt: 10.22.0.2 net_lan: 10.23.0.2
db net_lan: 10.23.0.10Schema IP#
| Zona | Subnet | Container | IP |
|---|---|---|---|
| Esterna | 10.20.0.0/24 | attacker | 10.20.0.50 |
| Esterna | 10.20.0.0/24 | fw1 (ext) | 10.20.0.2 |
| DMZ | 10.21.0.0/24 | fw1 (dmz) | 10.21.0.2 |
| DMZ | 10.21.0.0/24 | web | 10.21.0.10 |
| Mgmt | 10.22.0.0/24 | web (mgmt) | 10.22.0.10 |
| Mgmt | 10.22.0.0/24 | fw2 (mgmt) | 10.22.0.2 |
| LAN | 10.23.0.0/24 | fw2 (lan) | 10.23.0.2 |
| LAN | 10.23.0.0/24 | db | 10.23.0.10 |
Zone in Docker: le docker network#
| Cisco | Linux namespaces | Docker |
|---|---|---|
| SW-EXT + VLAN 10 | veth tra host e ns-fw1 | docker network net_ext |
| SW-DMZ + VLAN 20 | veth tra ns-fw1/sofia/fw2 | docker network net_dmz |
| SW-MGMT (nuovo) | veth tra ns-sofia e ns-fw2 | docker network net_mgmt |
| SW-LAN + VLAN 30 | veth tra ns-fw2 e ns-giulia | docker network net_lan |
FASE 0 — Reti prima dei container#
Specchio del Lab 05: prima esistono i bridge/namespace, poi ci si attacca sopra.
Prima di creare qualsiasi cosa, verifico che non ci sia gia' nulla con quel nome:
docker network ls | grep net_Vuoto, come atteso alla prima esecuzione del lab.
Attenzione, un gotcha reale: se sulla stessa macchina hai altri progetti Docker (anche fermi da mesi), ogni rete che hanno creato resta riservata finche' non la cancelli esplicitamente — spegnere i container non basta, una docker network e' un oggetto a se', indipendente dallo stato dei container che ci sono sopra. Con docker-compose up, ogni progetto si crea automaticamente una rete propria (<progetto>_default) prendendosi il prossimo blocco /16 libero nel pool interno di Docker (172.17, 172.18, 172.19...) — quindi se hai gia' avviato parecchi docker-compose diversi nel tempo, e' facile che ti ritrovi con l'intero spazio 172.x gia' occupato da progetti scollegati dal lab. Il sintomo e' Error response from daemon: invalid pool request: Pool overlaps with other one on this address space al primo docker network create. Si risolve con docker network prune (cancella solo le reti senza container attivi collegati — Docker rifiuta di cancellare quelle in uso, quindi e' sicuro da provare) o spostando il lab su un range libero.
Non e' un problema isolato del Mac: spostando il lab su un server Ubuntu reale, lo stesso identico conflitto e' ricomparso — stavolta con single-node_default (il Docker Compose di Wazuh, in produzione, non certo da cancellare) che occupava esattamente 172.20.0.0/24. Verificato prima di toccare nulla con docker network inspect single-node_default --format '{{range .Containers}}{{.Name}} {{end}}': 3 container Wazuh attivi, quindi via di mezzo diversa — cambio range invece di prune. Da qui in poi il lab usa 10.20.0.0/24-10.23.0.0/24 invece di 172.20-23.x, apposta per non litigare mai con reti Docker di altri progetti importanti (10.x e' comunque privato quanto 172.16-31.x, solo meno probabile da trovare gia' occupato).
Poi le reti vere e proprie:
docker network create --subnet 10.20.0.0/24 net_ext
docker network create --subnet 10.21.0.0/24 net_dmz
docker network create --subnet 10.22.0.0/24 net_mgmt
docker network create --subnet 10.23.0.0/24 net_lanTest 0#
docker network ls | grep net_
docker network inspect net_dmz | grep -i subnetEsito reale:
"Subnet": "10.21.0.0/24",Poi sotto il cofano:
ip link | grep br-
iptables -t nat -L -n | grep 10.2Atteso: un bridge br-xxxx per ogni rete piu' regole NAT automatiche. Questo e' cio' che Docker crea al posto del ip link add ... type bridge che facevo a mano nel Lab 04.
FASE 1 — Il DB isolato#
Giulia sta SOLO su net_lan. Nessun altro container e' su net_lan ancora: deve essere irraggiungibile da tutto.
docker run -d --name db --network net_lan --ip 10.23.0.10 alpine sleep infinity
docker exec -it db sh -c "apk add --no-cache netcat-openbsd; nc -l -p 3306"Il listener simula MySQL su 3306. Tengo aperto in un terminale dedicato.
sh -c "cmd1; cmd2" e non due comandi separatidocker exec lancia un solo programma con i suoi argomenti — docker exec db ip addr esegue ip con argomento addr, punto. Non capisce ;, &&, |: quella e' sintassi della shell, non del programma. Per eseguire piu' comandi in sequenza dentro il container con un solo exec, devi prima lanciare una shell (sh, l'unica disponibile su Alpine) e dirle di interpretare una stringa (-c "...") invece di aprirsi interattiva. Pattern riusabile ogni volta che serve incatenare comandi dentro un container.
Test 1#
docker exec db ip addr # solo net_lan, un unico IP
docker exec db ip route # gateway = il bridge di net_lan
docker exec db ls # promemoria: un container non e' una VMEsito reale:
11: eth0@if36: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
inet 10.23.0.10/24 brd 10.23.0.255 scope global eth0
default via 10.23.0.1 dev eth0
10.23.0.0/24 dev eth0 scope link src 10.23.0.10Un'unica interfaccia di rete vera, eth0, con l'IP che ho assegnato io. Le altre righe che compaiono in ip addr (tunl0, gre0, gretap0, ip_vti0, ip6_vti0, sit0, ip6tnl0, ip6gre0) non le ha create Docker: sono interfacce tunnel di default del kernel Linux, sempre presenti e disattivate (state DOWN/NOARP), le stesse su qualsiasi Linux non solo dentro un container. Non fanno parte della topologia del lab.
ls mostra un filesystem Linux minimale (bin, etc, lib, usr...) sopra il kernel dell'host: e' il promemoria concreto che un container non e' una VM con un suo kernel, e' un processo isolato che vede un filesystem e una rete tutti suoi.
Da un container su net_ext (che creo in Fase 2), db non deve rispondere: reti separate, nessun ponte ancora.
FASE 2 — L'attaccante esterno#
Un container su net_ext, da cui lanciare i test negativi.
docker run -d --name attacker --network net_ext --ip 10.20.0.50 alpine sleep infinity
docker exec attacker sh -c "apk add --no-cache netcat-openbsd tcpdump iproute2"Test 2 — il primo "deve fallire"#
docker exec attacker nc -w3 10.23.0.10 3306
# Atteso: timeout. attacker e db non condividono nessuna rete.Questo fallisce gia' ora, senza nessun firewall. Perche'? Docker isola le reti tra loro di default. Questo e' l'isolamento di rete: diverso dal filtraggio granulare che arriva con iptables nei passi successivi.
FASE 3 — fw2: il firewall davanti al DB#
Il container che replica ns-fw2. Sta su net_mgmt (lato DMZ) e net_lan (lato DB). Fa da ponte ma con FORWARD DROP + ACL: solo 3306, solo dal web.
docker run -d --name fw2 --network net_mgmt --ip 10.22.0.2 --cap-add NET_ADMIN alpine sleep infinity--cap-add NET_ADMIN e' il permesso minimo per scrivere iptables dentro il container. Non e' hardening: e' il prerequisito tecnico per fare forwarding/firewall.
Il secondo attacco di rete (fw2 su net_lan) non e' andato liscio al primo colpo:
docker network connect --ip 10.23.0.1 net_lan fw2
# Error response from daemon: Address already in usePerche': net_lan e' stata creata in Fase 0 senza --gateway esplicito, quindi Docker si e' preso automaticamente il primo IP utilizzabile (.1) come indirizzo del bridge stesso — un indirizzo virtuale che appartiene alla rete, non a un container. L'idea originale era di far prendere proprio quell'IP a fw2, cosi' db (che ha gia' una rotta di default automatica verso .1) ci sarebbe passato attraverso senza bisogno di configurazione. Non funziona: quell'indirizzo non e' mai assegnabile a un container su una rete bridge Docker, e' riservato al bridge. Trucco valido su namespace/veth manuali, non qui.
Fix: IP normale per fw2 (coerente con fw1, che usa sempre .2), e una rotta esplicita su db per il traffico di ritorno verso web:
docker network connect --ip 10.23.0.2 net_lan fw2
docker exec db ip route add 10.22.0.0/24 via 10.23.0.2
# ip: RTNETLINK answers: Operation not permittedSecondo blocco: db era stato creato in Fase 1 senza --cap-add NET_ADMIN (doveva essere solo un listener passivo). Modificare le rotte dentro un container richiede quella capability — Docker la toglie per default a ogni container. Va ricreato:
docker rm -f db
docker run -d --name db --network net_lan --ip 10.23.0.10 --cap-add NET_ADMIN alpine sleep infinity
docker exec db ip route add 10.22.0.0/24 via 10.23.0.2
docker exec -it db sh -c "apk add --no-cache netcat-openbsd; nc -l -p 3306"docker exec fw2 sh -c "apk add --no-cache iptables iproute2 tcpdump"docker exec fw2 sysctl -w net.ipv4.ip_forward=1
docker exec fw2 iptables -P FORWARD DROP
docker exec fw2 iptables -A FORWARD \
-p tcp --dport 3306 -d 10.23.0.10 -j ACCEPT
docker exec fw2 iptables -A FORWARD \
-m state --state ESTABLISHED,RELATED -j ACCEPTLa sorgente (-s 10.22.0.10) la aggiungo in Fase 4, dopo aver lanciato web con l'IP fisso.
Test 3#
docker exec fw2 iptables -L FORWARD -v -n
# Atteso: policy DROP, 2 regole presenti, contatori a 0db e' su net_lan, fw2 e' l'unico altro nodo su net_lan. Chi puo' raggiungere db ora? Solo chi passa per fw2, e fw2 droppa tutto tranne 3306. Questo e' il filtraggio granulare che la sola separazione di rete non dava.
FASE 4 — web: il ponte DMZ-LAN#
web sta su net_dmz e net_mgmt. E' l'unico autorizzato a parlare col DB via fw2.
A differenza di db/attacker/fw1/fw2 (container "vuoti" tenuti accesi con sleep infinity), qui parto direttamente con nginx vero al posto del solito placeholder alpine — nginx stesso fa da PID 1 e tiene il container vivo, non serve nessun trucco. --cap-add NET_ADMIN lo metto subito: serve tra due righe per ip route add, e ormai la lezione l'abbiamo imparata due volte (su db e sul sysctl di fw2) — non aspetto il terzo errore per scoprirlo di nuovo.
docker run -d --name web --network net_dmz --ip 10.21.0.10 --cap-add NET_ADMIN nginx:stable-alpine
docker network connect --ip 10.22.0.10 net_mgmt web
docker exec web sh -c "apk add --no-cache netcat-openbsd iproute2 tcpdump"La rotta verso la LAN, specchio del problema "il routing di Sofia non passa per FW2" del Lab 05:
docker exec web ip route add 10.23.0.0/24 via 10.22.0.2Ora completo l'ACL di fw2 col vero IP di web come sorgente:
docker exec fw2 iptables -R FORWARD 1 \
-p tcp --dport 3306 -s 10.22.0.10 -d 10.23.0.10 -j ACCEPTTest 4 — il percorso buono funziona, gli altri no#
# terminale A: listener gia' attivo dentro db (Fase 1)
# terminale B:
docker exec web sh -c "echo 'SELECT * FROM lezioni' | nc 10.23.0.10 3306"
# Atteso: il testo compare nel listener di db. Contatori fw2 salgono.
# Negativo 1: porta sbagliata — DROP
docker exec web nc -w3 10.23.0.10 22
# Negativo 2: db non apre connessioni verso web (solo ESTABLISHED di ritorno)Esito reale — nel terminale A, dentro il listener di db, e' comparso proprio il testo mandato da web:
SELECT * FROM lezioniIl testo e' arrivato al listener di db, e i contatori di fw2 confermano il percorso:
docker exec fw2 iptables -L FORWARD -v -nChain FORWARD (policy DROP 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
3 186 ACCEPT tcp -- * * 10.22.0.10 10.23.0.10 tcp dpt:3306
2 112 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED3 pacchetti sulla regola 3306 (SYN, dati, FIN/ACK), 2 sulla regola ESTABLISHED/RELATED (il traffico di ritorno) — la policy DROP resta a 0 perche' nessun pacchetto illegittimo ha nemmeno provato a passare finora. Il negativo 1 (porta 22) e' andato in timeout come atteso: fw2 droppa tutto quello che non e' 3306 da web.
Se zero pacchetti: tcpdump dentro fw2 sulle due interfacce, controlla ip route get su web, verifica --ip e le subnet.
FASE 5 — fw1: il firewall esterno#
Replica ns-fw1. Sta su net_ext e net_dmz. ACL: 80/443 verso web, ESTABLISHED ritorno.
docker run -d --name fw1 \
--network net_ext --ip 10.20.0.2 \
--cap-add NET_ADMIN \
alpine sleep infinity
docker network connect --ip 10.21.0.2 net_dmz fw1
docker exec fw1 sh -c "apk add --no-cache iptables iproute2 tcpdump"
docker exec fw1 sysctl -w net.ipv4.ip_forward=1
docker exec fw1 iptables -P FORWARD DROP
docker exec fw1 iptables -A FORWARD -p tcp --dport 80 -d 10.21.0.10 -j ACCEPT
docker exec fw1 iptables -A FORWARD -p tcp --dport 443 -d 10.21.0.10 -j ACCEPT
docker exec fw1 iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPTRotta su attacker verso la DMZ via fw1, e listener su web per il test:
docker exec attacker ip route add 10.21.0.0/24 via 10.20.0.2
docker exec -it web sh -c "nc -l -p 80"Test 5 — la catena completa#
Regola per tutto il resto del lab: ogni test ha subito dopo il comando che verifica che le regole abbiano davvero fatto qualcosa — non basta vedere il risultato atteso, serve vedere i contatori iptables muoversi. Altrimenti non sai se sta funzionando la regola giusta o se per caso il traffico passa per un altro motivo.
# Positivo: attacker → web sul 80 (richiesta HTTP vera, nginx risponde davvero)
docker exec attacker sh -c "printf 'GET / HTTP/1.0\r\n\r\n' | nc 10.21.0.10 80"Esito reale — risposta HTTP completa, non solo un eco:
HTTP/1.1 200 OK
Server: nginx/1.30.4
...
<title>Welcome to nginx!</title>
...Verifica subito i contatori di fw1 — se non salgono, il traffico sta passando per un'altra strada (o non sta passando affatto e nginx risponde per un motivo diverso):
docker exec fw1 iptables -L FORWARD -v -nEsito reale: 6 pacchetti / 338 byte sulla regola dpt:80, ma zero sulla regola ESTABLISHED,RELATED. Numeri troppo bassi per una risposta HTTP completa (l'HTML da solo pesa 896 byte) — significa che solo la richiesta (attacker → web) e' passata per fw1. La risposta (web → attacker) e' tornata indietro per un'altra strada.

Verificato con docker exec web ip route get 10.20.0.50: web raggiunge attacker via 10.21.0.1 — il gateway automatico del bridge net_dmz (lo stesso creato da Docker in Fase 0, non fw1 che e' 10.21.0.2). web non ha nessuna rotta esplicita verso net_ext, quindi il traffico di ritorno usa il default e finisce dritto sul bridge.
Prima di continuare, domanda seria: se il ritorno bypassa fw1, lo fa anche una connessione nuova, non solo una risposta? Test mirato:
# terminale A, dentro attacker:
docker exec -it attacker nc -l -p 4444
# terminale B, connessione NUOVA da web verso attacker (non una risposta):
docker exec web sh -c "echo 'canary da web' | nc -w3 10.20.0.50 4444"
# poi, prima di guardare se e' arrivato:
docker exec fw1 iptables -L FORWARD -v -nEsito reale: contatori di fw1 invariati (conferma: anche questo non passa per fw1) — ma il testo non e' arrivato nel listener di attacker. La connessione nuova e' fallita del tutto, non ha trovato nessuna scorciatoia.
Conclusione: non e' un buco di sicurezza, e' un dettaglio di come Docker gestisce piu' reti bridge sullo stesso host. Oltre alle regole che scriviamo noi dentro fw1/fw2, Docker mantiene un suo isolamento a livello di host (catene DOCKER-ISOLATION gestite dal daemon, non visibili da dentro i container) che blocca di default ogni connessione nuova tra reti bridge diverse — esattamente quello che ha fermato il canary. Il traffico di ritorno su una connessione gia' tracciata (conntrack) invece passa comunque, perche' il kernel lo riconosce come parte di un flusso gia' esistente, anche se la strada fisica di ritorno non e' la stessa dell'andata. Risultato: l'isolamento tra zone regge per il traffico che conta (connessioni nuove), il "bypass" che sembrava un problema serio era solo asimmetria del path di ritorno su una connessione gia' legittima.
# Negativo 1: porta sbagliata
docker exec attacker nc -w3 10.21.0.10 22
# Atteso: timeout. Verifica che sia la regola DROP a fermarlo:
docker exec fw1 iptables -L FORWARD -v -n
# La policy DROP (in alto) deve aver contato questo pacchetto.
# Negativo 2 (il piu' importante): attacker → db direttamente
docker exec attacker nc -w3 10.23.0.10 3306
# Atteso: timeout.Esito reale: timeout, come atteso. Ma occhio al motivo — non e' "nessuna rotta". Verificato con:
docker exec attacker ip route get 10.23.0.1010.23.0.10 via 10.20.0.1 dev eth0 src 10.20.0.50 uid 0Una rotta esiste eccome: Linux ha sempre un default (qui il gateway automatico di net_ext), quindi ip route get non dice mai "irraggiungibile" a meno che manchi anche quello. Il vero motivo del fallimento e' lo stesso meccanismo scoperto col canary poco sopra: l'isolamento automatico di Docker tra reti bridge diverse a livello di host blocca le connessioni nuove tra bridge che nessun container-router collega direttamente. net_ext e net_lan non sono mai stati messi in comunicazione ne' da fw1 ne' da fw2 — il pacchetto esce dal bridge di attacker, ma l'isolamento host lo ferma li', prima ancora di avvicinarsi a fw2. Nessun iptables da controllare qui: il blocco avviene a un livello che i nostri container-firewall non vedono ne' gestiscono.
Negativo 3 (lateral movement), concettuale: se attacker fosse dentro la DMZ invece che su net_ext (es. avesse compromesso web), potrebbe raggiungere db? No — fw2 accetta la porta 3306 solo dalla sorgente 10.22.0.10 (web), non da qualsiasi altro IP della DMZ. E' il parametro -s del Lab 05 che blocca il movimento laterale: anche un altro container piazzato nella stessa zona di web non passerebbe l'ACL di fw2.
UFW non vede Docker — la scoperta più importante di questo lab#
Spostando il lab su un server Ubuntu reale (quello dove gira il mio Wazuh in produzione), verifico anche ufw, per completezza:
sudo ufw status verboseDefault: deny (incoming), allow (outgoing), deny (routed)deny (routed) e' la policy che dovrebbe bloccare esattamente il tipo di traffico che stiamo inoltrando tra le reti Docker — eppure il Test 5 e' passato senza problemi (11 pacchetti, risposta HTTP completa). Motivo: Docker bypassa UFW di default. Il demone Docker scrive le proprie regole iptables direttamente nella catena DOCKER-USER/FORWARD, valutata prima delle regole di UFW — qualsiasi cosa Docker decida di inoltrare passa, a prescindere da cosa dice UFW. La policy "deny (routed)" e' vera sulla carta, ma semplicemente non si applica al traffico che Docker gestisce da solo.
Non e' un dettaglio da laboratorio: su un server che espone servizi reali (qui, Wazuh), fidarsi di UFW per bloccare traffico verso/da container Docker e' un errore comune e documentato. Il fix vero (fuori scope qui) richiede regole esplicite nella catena DOCKER-USER dentro /etc/ufw/after.rules, oppure disabilitare la gestione iptables di Docker ("iptables": false nel daemon) e gestire tutto a mano con UFW.
FASE 6 — Sotto il cofano: ritrovare i namespace#
Il momento clou: Docker ha creato namespace/veth/iptables al posto mio. Li ritrovo e confronto col Lab 04/05.
# Network namespace creati da Docker
sudo ls -l /var/run/docker/netns/Esito reale — 8 file, uno per ciascun container in esecuzione sull'host (i 5 del lab + i 3 di Wazuh, ognuno con il proprio netns anche se un container come fw2 ha due interfacce di rete: e' sempre un solo namespace, con piu' interfacce dentro):
-r--r--r-- 1 root root 0 Jul 28 09:44 1f374f20fb17
-r--r--r-- 1 root root 0 Jul 28 09:44 35528b252d62
... (8 totali)lsns -t net (senza sudo) ne mostra solo 3 — un limite pratico, non concettuale: senza privilegi elevati vede solo i namespace dei processi a cui l'utente corrente ha gia' visibilita' diretta in /proc, non tutti quelli bind-montati da Docker in /var/run/docker/netns/. La fonte di verita' resta la lista dei file sopra.
Entro nel namespace di fw2 e ritrovo le sue strutture dal lato host, senza passare da docker exec:
PID=$(docker inspect -f '{{.State.Pid}}' fw2)
sudo nsenter -t $PID -n ip addr
sudo nsenter -t $PID -n iptables -L FORWARD -v -nEsito reale — le due interfacce di fw2, viste dal lato host invece che da dentro il container, stesso identico risultato:
2: eth0@if15: ... inet 10.22.0.2/24 ...
3: eth1@if16: ... inet 10.23.0.2/24 ...Chain FORWARD (policy DROP 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
0 0 ACCEPT tcp -- * * 10.22.0.10 10.23.0.10 tcp dpt:3306
0 0 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHEDSull'host: bridge e veth.
ip link | grep -E 'br-|veth'
iptables -t nat -L -n | grep 10.2Esito reale — 4 bridge, uno per rete del lab, ciascuno con esattamente 2 veth (2 container per zona: attacker+fw1 su net_ext, web+fw1 su net_dmz, web+fw2 su net_mgmt, db+fw2 su net_lan) — e accanto, ben visibile, wazuh-br0 con i suoi 3 veth: due progetti completamente separati sullo stesso host, isolati l'uno dall'altro dalla stessa identica meccanica bridge+veth:
br-3308a4f8df99 (2 veth)
br-581cb20b90ca (2 veth)
br-5e21292af040 (2 veth)
br-c1a95bab7493 (2 veth)
wazuh-br0 (3 veth)MASQUERADE 10.20.0.0/24 -> 0.0.0.0/0
MASQUERADE 10.21.0.0/24 -> 0.0.0.0/0
MASQUERADE 10.22.0.0/24 -> 0.0.0.0/0
MASQUERADE 10.23.0.0/24 -> 0.0.0.0/0Una regola NAT per rete, esattamente come previsto in Fase 0 — e' quello che permette a un container di uscire verso internet senza che nessuno gliel'abbia chiesto esplicitamente.
Test 6 — il confronto#
| Docker ha fatto X | A mano (Lab 04) era Y |
|---|---|
bridge br-xxxx (uno per rete, 4 + wazuh-br0 di un altro progetto) | ip link add ... type bridge |
| 2 veth per bridge (uno per container collegato) | veth pair manuale |
iptables NAT (MASQUERADE per subnet) automatiche | non avevo NAT a mano |
| namespace per container (8 su questo host, uno anche per Wazuh) | ip netns add |
Domanda finale: cosa fa Docker che a mano dovevo fare io? E cosa fa in piu' che a mano non facevo (NAT, DNS interno)? Se sai rispondere a voce, il lab ha raggiunto il suo obiettivo.
FASE 7 — ModSecurity WAF davanti a web#

Fin qui la DMZ filtra solo L3/L4 (indirizzi e porte). fw1 lascia passare qualsiasi cosa arrivi sulla porta 80 verso web — anche una SQL injection nell'URL, perche' a quel livello e' un pacchetto TCP identico a qualsiasi altro. Serve un filtro L7 che guardi dentro la richiesta HTTP: un WAF.
Un vincolo che cambia la topologia#
owasp/modsecurity-crs:nginx non e' un web server sostitutivo di web — e' un reverse proxy: sta davanti a un backend che deve gia' esistere, configurato con la variabile BACKEND. Quindi non sostituisco web, aggiungo waf davanti:
waf(nuovo) prende10.21.0.10— la posizione chefw1gia' bersaglia, niente da cambiare nella sua ACLwebsi sposta su10.21.0.12— raggiungibile solo dawafdentronet_dmz, mai piu' direttamente dafw1
docker rm -f web
docker run -d --name web --network net_dmz --ip 10.21.0.12 --cap-add NET_ADMIN nginx:stable-alpine
docker network connect --ip 10.22.0.10 net_mgmt web
docker exec web sh -c "apk add --no-cache netcat-openbsd iproute2 tcpdump"
docker exec web ip route add 10.23.0.0/24 via 10.22.0.2
docker run -d --name waf --network net_dmz --ip 10.21.0.10 --cap-add NET_ADMIN \
-e BACKEND="http://10.21.0.12:80" \
owasp/modsecurity-crs:nginxPrimo inciampo: la porta privilegiata#
Avevo aggiunto anche -e PORT=80 per non toccare l'ACL di fw1. Il container non parte nemmeno:
ERROR: you are using PORT=80 and SSL_PORT=8443
Both nginx and httpd containers now run with an unprivileged user.
This means that we cannot bind to ports below 1024...L'immagine gira apposta con un utente non privilegiato — niente binding sotto 1024. Il consiglio nei log (-p 80:8080) e' per chi pubblica verso l'host, non il nostro caso: qui fw1 inoltra direttamente all'IP del container, non c'e' nessuna pubblicazione di porta di mezzo. Fix: lascio la porta di default (8080) e aggiorno l'ACL di fw1 di conseguenza:
docker exec fw1 iptables -R FORWARD 1 -p tcp --dport 8080 -d 10.21.0.10 -j ACCEPTSecondo inciampo: la stessa lezione del ritorno, di nuovo#
Test grezzo (nc -zv) da attacker: timeout. fw1 inoltra il SYN (confermato coi contatori), ma nessun SYN-ACK torna mai indietro. Diagnosi con tcpdump a piu' livelli (dentro fw1, dentro attacker via nsenter, sul bridge host di net_dmz, sul bridge host di net_ext): il SYN-ACK di waf esce davvero dal bridge net_dmz verso l'host, ma non arriva mai al bridge net_ext — si perde nel routing implicito tra i due bridge, la stessa scorciatoia "gratuita" via il gateway di default che per web a volte bastava.
La lezione e' quella che avremmo dovuto applicare fin da subito, per coerenza con tutto il resto del lab (dove ogni rotta di ritorno l'abbiamo sempre scritta esplicita, mai lasciata all'automatismo): dare a waf una rotta esplicita verso attacker passando per fw1, invece di fidarsi del gateway di default del bridge. L'immagine non ha iproute2 installato, quindi la aggiungo dal lato host con lo stesso trucco nsenter della Fase 6:
PID=$(docker inspect -f '{{.State.Pid}}' waf)
sudo nsenter -t $PID -n ip route add 10.20.0.0/24 via 10.21.0.2Da qui in poi funziona in modo prevedibile, non "a volte".
Test 7 — il WAF blocca davvero#
# Negativo (voluto): niente header Host, ModSecurity lo considera protocol violation
docker exec attacker sh -c "printf 'GET / HTTP/1.0\r\n\r\n' | nc -w3 10.21.0.10 8080"Esito reale: 403 Forbidden. Nel log di ModSecurity (docker logs waf):
[id "920280"] [msg "Request Missing a Host Header"]
[id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"]# Positivo: richiesta legittima, con Host
docker exec attacker sh -c "printf 'GET / HTTP/1.0\r\nHost: 10.21.0.10\r\n\r\n' | nc -w3 10.21.0.10 8080"Esito reale: 200 OK, pagina di benvenuto di nginx, proxata correttamente da waf a web. Interessante: anche qui il log segnala un match ([id "920350"] "Host header is a numeric IP address") — CRS non ama un IP nudo come Host, ma il punteggio da solo non basta a bloccare. Con un dominio vero al posto dell'IP, questo warning sparirebbe.
# Attacco: SQLi nell'URL — attenzione, dentro printf i % vanno raddoppiati (%%) o li interpreta come formato
docker exec attacker sh -c "printf 'GET /?id=1%%27%%20OR%%20%%271%%27=%%271 HTTP/1.0\r\nHost: 10.21.0.10\r\n\r\n' | nc -w3 10.21.0.10 8080"Esito reale: 403 Forbidden. Stessa SQL injection che lab06 bloccava con ModSecurity su ns-sofia (namespace Linux), qui bloccata identica da un container Docker separato che fa da reverse proxy — stesso risultato di sicurezza, architettura diversa.
Dove finiscono questi log#
Sia l'access log di nginx sia l'audit log JSON di ModSecurity (visto sopra) vanno su stdout — visibili con docker logs waf, niente file da andare a cercare dentro il container.
Il modo "giusto" per portarli in un SIEM e' il log-driver syslog nativo di Docker, senza toccare nulla dentro il container:
docker run ... --log-driver syslog --log-opt syslog-address=udp://<host>:514 owasp/modsecurity-crs:nginxSu questa macchina wazuh-manager pubblica gia' 514/udp sull'host — tecnicamente basterebbe puntare li'. Scelta deliberata: non lo collego davvero. Questo Wazuh e' in produzione, non un lab — iniettarci traffico di test vorrebbe dire sporcare dashboard e alert reali con rumore che non e' un incidente vero. Il meccanismo resta documentato per quando servira' su un progetto reale (es. Chatwoot/Hetzner), ma questo lab resta isolato.
FASE 8 — Cristallizzare in docker-compose.yml#
Solo ora, dopo aver capito ogni run. Il compose e' la forma dichiarativa di cio' che hai fatto imperativo: scriverlo e' quasi meccanico — con un'eccezione reale, scoperta scrivendolo, non prevista.
File completo: docker-dmz-compose.yml.
Traduzione#
- Ogni
docker rundiventa un servizio inservices: - Ogni
network create --subnetdiventa una voce innetworks:conipam.config docker network connectsparisce del tutto: in compose un servizio puo' stare su piu' reti fin dalla definizione (networks:con piu' voci sotto lo stesso servizio, ognuna col suoipv4_address) — niente secondo comando separato, cosa che il semplicedocker runnon permette- I comandi iptables/sysctl diventano
command:/entrypoint:+sysctls:— e siccome ora conosciamo gia' tutta la topologia, le regole difw1/fw2sono scritte corrette dal primo colpo (es.-s 10.22.0.10sufw2da subito, niente piu' "prima larga, poi ristretta" come nell'imperativo)
L'eccezione che non si poteva prevedere#
Il placeholder di questa sezione, scritto prima di provarci davvero, diceva: "la rotta esplicita di waf verso net_ext diventa un comando nell'entrypoint". Falso. Scrivendo il compose per davvero: owasp/modsecurity-crs:nginx non ha iproute2 installato, e il suo entrypoint fa gia' tutto il lavoro di attivazione CRS — sovrascriverlo con un wrapper (come fatto per web/fw1/fw2) romperebbe quel setup.
La rotta resta un'operazione host, fatta con nsenter da fuori il container, dopo l'avvio — esattamente come nell'imperativo. Un file compose descrive container, non puo' esprimere "entra nel namespace di rete dall'host e aggiungi una rotta": e' un confine reale tra cosa la dichiarazione puo' fare e cosa resta imperativo per forza.
bash docker-dmz-compose-run.sh # pulizia + up + rotta di waf + verifica end-to-end, tutto in un fileLezione: non tutto quello che fai a mano si traduce in dichiarativo senza perdite. Scoprirlo scrivendo il compose, non prevederlo in anticipo, e' la prova che avevi davvero capito l'imperativo prima di cristallizzarlo — se l'avessi solo letto, avresti scritto lo stesso placeholder sbagliato senza accorgertene.
Terzo errore, stessa causa: la rotta di db dimenticata#
Testando il compose per davvero: db sembrava non ricevere mai nulla da web, nonostante i contatori di fw2 confermassero traffico accettato. Diagnosi identica alle due volte precedenti (tcpdump, ps aux, verifica rotte) — e causa identica: nel tradurre l'imperativo in command:, la rotta esplicita che avevamo dato a db in Fase 3 (ip route add 10.22.0.0/24 via 10.23.0.2, necessaria perche' risponda a web passando per fw2 invece che per la scorciatoia inaffidabile del gateway di default) e' rimasta fuori dal file. Copiata distrattamente, non ripensata.
Tre volte lo stesso identico bug in un solo lab (web, waf, ora db) e' un pattern, non una sfortuna: ogni container che deve rispondere a chi sta in una zona diversa ha bisogno di una rotta esplicita, sempre, senza eccezioni — e va verificato esplicitamente ogni volta che si scrive o si traduce la configurazione, non assunto per somiglianza col container precedente che "funzionava".
Ping matrix#
Ogni riga: comando reale + esito. Tutti e 9 sono automatizzati in docker-dmz-run.sh/docker-dmz-compose-run.sh (sezione "Verifica finale"), cosi' restano confermati a ogni run, non solo quando li ho provati io.
La topologia completa, per avere il quadro d'insieme prima di entrare nel dettaglio dei singoli test:
flowchart LR
attacker[attacker
10.20.0.50]
fw1[fw1
10.20.0.2 / 10.21.0.2]
waf[waf
10.21.0.10]
web[web
10.21.0.12 / 10.22.0.10]
fw2[fw2
10.22.0.2 / 10.23.0.2]
db[db
10.23.0.10]
attacker -->|net_ext| fw1
fw1 -->|net_dmz, ACL 8080/443| waf
waf -->|proxy interno| web
web -->|net_mgmt, ACL 3306 solo da web| fw2
fw2 -->|net_lan| db
Ora un mermaid per test — freccia piena se deve passare, tratteggiata se deve essere bloccato.
Test 1 — attacker → fw1 (10.20.0.2)#
flowchart LR
attacker -->|ping, net_ext| fw1
docker exec attacker ping -c 2 10.20.0.2| Destinazione | fw1, stessa net_ext |
| Risultato | ✅ CONFERMATO — 2/2 pacchetti, 0% loss, nessun bridge di mezzo |
Test 2 — attacker → waf (10.21.0.10)#
flowchart LR
attacker -->|via fw1| fw1 --> waf
docker exec attacker sh -c "printf 'GET / HTTP/1.0\r\nHost: 10.21.0.10\r\n\r\n' | nc -w3 10.21.0.10 8080"| Destinazione | waf (non piu' web — dalla Fase 7 .10 e' del WAF), attraverso fw1 |
| Risultato | ✅ CONFERMATO — 200 OK, stesso test della Fase 7 (Test 2 del WAF) |
Test 3 — attacker → db (10.23.0.10), diretto#
flowchart LR
attacker -.->|BLOCCATO, salta tutta la DMZ| db
docker exec attacker nc -zv -w3 10.23.0.10 3306| Destinazione | db, bypassando tutta la DMZ |
| Risultato | ✅ CONFERMATO BLOCCATO — timeout. Non "nessuna rotta" (ip route get ne trova comunque una, il default gateway): e' l'isolamento host di Docker tra bridge scoperto in Fase 5, nessun container-router collega net_ext a net_lan |
Test 4 — web → fw1 (10.21.0.2)#
flowchart LR
web -->|ping, net_dmz| fw1
docker exec web ping -c 2 10.21.0.2| Destinazione | fw1, stessa net_dmz |
| Risultato | ✅ Atteso FUNZIONA (stessa subnet, nessun firewall di mezzo) — automatizzato negli script, verifica con un run |
Test 5 — web → fw2 (10.22.0.2)#
flowchart LR
web -->|ping, net_mgmt| fw2
docker exec web ping -c 2 10.22.0.2| Destinazione | fw2, stessa net_mgmt |
| Risultato | ✅ Atteso FUNZIONA (stessa subnet) — automatizzato negli script |
Test 6 — web → attacker (10.20.0.50), connessione nuova#
flowchart LR
web -.->|BLOCCATO, connessione nuova| attacker
docker exec web sh -c "echo 'canary da web' | nc -w3 10.20.0.50 4444"| Destinazione | attacker, connessione nuova (non una risposta) |
| Risultato | ✅ CONFERMATO FALLISCE — timeout, nessuna ACL lo permette. Stesso principio del canary in Fase 5: solo il ritorno su connessioni gia' stabilite passa, mai una connessione nuova non prevista |
Test 7 — web → db (10.23.0.10)#
flowchart LR
web -->|via fw2| fw2 --> db
docker exec web sh -c "echo 'SELECT * FROM lezioni' | nc -w3 10.23.0.10 3306"| Destinazione | db, attraverso fw2 |
| Risultato | ✅ CONFERMATO — funziona su 3306 (contatori fw2: 3 pkt accept + 2 established, testo arrivato al listener), fallisce su porta 22 |
Test 8 — db → fw2 (10.23.0.2)#
flowchart LR
db -->|ping, net_lan| fw2
docker exec db ping -c 2 10.23.0.2| Destinazione | fw2, stessa net_lan |
| Risultato | ✅ Atteso FUNZIONA (stessa subnet) — automatizzato negli script |
Test 9 — db → web (10.22.0.10)#
flowchart LR
db -.->|BLOCCATO, connessione nuova| web
docker exec db nc -zv -w3 10.22.0.10 80| Destinazione | web, connessione iniziata da db (mai previsto) |
| Risultato | ✅ CONFERMATO FALLISCE — timeout. db ha gia' una rotta verso 10.22.0.0/24 via fw2 (Fase 3), ma nessuna ACL di fw2 permette a db di iniziare una connessione verso web, ed e' comunque una connessione nuova tra zone diverse — bloccata dall'isolamento host di Fase 5. db risponde, non inizia mai traffico |
Cosa ho imparato#
Questo lab doveva insegnare Docker networking (namespace, veth, bridge sotto il cofano di docker run). L'ha fatto, ma le lezioni piu' dense sono arrivate dai problemi reali incontrati lungo la strada, non dal piano originale:
Sulle capability: ogni container che deve toccare la propria configurazione di rete (iptables, ip route add, sysctl) ha bisogno di --cap-add NET_ADMIN — Docker lo nega per default, giustamente. E net.ipv4.ip_forward in particolare va abilitato con --sysctl alla creazione del container: /proc/sys e' di sola lettura a runtime, non basta sysctl -w da dentro dopo l'avvio.
Sull'isolamento di rete: Docker isola le reti bridge tra loro per design (niente path tra net_ext e net_lan finche' nessun container-router li collega esplicitamente). Ma quell'isolamento vale solo per le connessioni nuove — il traffico di ritorno su una connessione gia' tracciata (conntrack) puo' prendere scorciatoie impreviste attraverso il gateway di default del bridge, bypassando completamente il container-firewall che pensavi fosse l'unico ponte. La lezione pratica: non fidarsi mai del gateway di default per il traffico di ritorno tra zone — scrivere sempre una rotta esplicita verso il vero router (fw1/fw2), come abbiamo fatto per web e poi di nuovo per waf. Un errore ripetuto due volte nello stesso lab e' comunque una lezione imparata, non una scusa.
Sulle scorciatoie che a volte funzionano e a volte no: la stessa identica scorciatoia (gateway di default invece di una rotta esplicita) ha funzionato per web e ha fallito silenziosamente per waf, stesso IP, stessa topologia. Non fidarsi mai di un comportamento "ha funzionato una volta" senza capire perche' — soprattutto in sicurezza, dove "ha funzionato" e "e' garantito" sono due cose diverse.
Su UFW e Docker: UFW non vede il traffico che Docker gestisce da solo — le regole DOCKER-USER/FORWARD del demone Docker vengono valutate prima e possono rendere deny (routed) di UFW puramente decorativo. Su un server che espone servizi reali, questo va sempre verificato esplicitamente, mai assunto.
Sul WAF come reverse proxy: ModSecurity-CRS non sostituisce il web server, ci sta davanti — cambia la topologia (due container invece di uno), non solo la configurazione. E le immagini hardened spesso girano senza privilegi (niente porte sotto 1024), un vincolo di sicurezza che va progettato nell'architettura di rete, non aggirato.
Sul debug: ogni volta che un test falliva senza una causa ovvia, la sequenza che ha funzionato e' sempre la stessa: tcpdump a piu' livelli (dentro il container sospetto, sul bridge host, dal lato opposto) per isolare dove si perde il pacchetto, prima di ipotizzare perche'. Tirare a indovinare (o fidarsi del primo sospetto plausibile, come l'ARP stale che si e' rivelato non essere il problema) costa piu' tempo che verificare.
Domanda finale, la stessa della Fase 6: cosa fa Docker che a mano dovevo fare io, e cosa fa in piu' che a mano non facevo — inclusi gli automatismi che possono tradirti se non li capisci? Dopo questo lab, la risposta e' molto piu' concreta di quanto lo fosse a inizio giornata.
Questo si usa davvero in produzione?#
iptables in se' non e' finto: Docker stesso lo scrive per davvero su ogni host (le catene DOCKER-USER/DOCKER-ISOLATION scoperte con UFW sono iptables vero, sempre attivo). Quello che non e' realistico e' il pattern specifico costruito qui — un container alpine configurato a mano con NET_ADMIN + regole scritte a mano per fare da router/firewall tra reti diverse. E' fragile esattamente per i motivi toccati con mano in questo lab: rotte esplicite da non dimenticare, capability facili da saltare, nessuna alta affidabilita', nessuna integrazione con orchestrazione o service discovery.
In produzione lo stesso problema (segmentazione tra zone) si risolve con strumenti che generano le regole giuste partendo da una policy dichiarativa, non scrivendole a mano:
- Kubernetes NetworkPolicy + CNI (Calico, Cilium) — scrivi la policy, il CNI genera iptables/nftables/eBPF sotto. Cilium in particolare sta sostituendo iptables con eBPF per performance e osservabilita' migliori
- Security Group / NSG cloud (AWS/Azure/GCP) — l'equivalente diretto di
fw1/fw2, gia' nella tabella "Modello: container-firewall esplicito" in cima all'articolo (ns-fw1/fw2 → container-firewall → security group) - Service mesh (Istio, Linkerd) — filtro L7 + mTLS tra servizi, piu' sofisticato del WAF cablato a mano qui
- WAF gestito (Cloudflare, AWS WAF) invece di un container ModSecurity configurato a mano come
waf
Il lab serve a capire il meccanismo sotto — namespace, capability, routing, iptables — che questi strumenti usano (o sostituiscono) senza farteli scrivere a mano.




