Container escape e sicurezza Docker: la vulnerabilità che molti team ignorano
C'è un assunto pericoloso, diffuso in molti team che usano Docker, che si può riassumere in una frase: "tanto gira in un container, è isolato". L'idea implicita è che il container sia una sandbox di sicurezza, un muro che separa ciò che ci gira dentro dal sistema che lo ospita, così che anche se l'applicazione viene compromessa il danno resta confinato. È un assunto comodo, ed è sbagliato. Un container, per impostazione predefinita, è un meccanismo di isolamento dei processi, non un confine di sicurezza progettato per resistere a un attaccante determinato. La differenza non è accademica: è esattamente lo spazio in cui vive la classe di attacchi chiamata container escape, l'uscita dal container verso l'host. In un'attività di penetration testing, una volta ottenuto l'accesso a un container mal configurato, arrivare all'host che lo ospita è spesso più facile di quanto i team immaginino, e da lì si controlla tutto. In questo articolo mostro, dalla prospettiva di chi attacca, come si esce da un container verso l'host, quali misconfigurazioni lo rendono possibile, e l'hardening che chiude quei vettori prima che diventino una breccia.
Il container è una sandbox di sicurezza?
No, e capire perché è il punto di partenza per non costruire la propria sicurezza su una falsa premessa. Un container condivide il kernel con il sistema host: a differenza di una macchina virtuale, che ha un proprio kernel isolato dall'hypervisor, il container è un insieme di processi dell'host a cui il kernel applica delle restrizioni, i namespace per separare la vista delle risorse e i cgroup per limitarne l'uso. Questo isolamento è efficace per separare i processi in condizioni normali, ma non è una barriera blindata: se quelle restrizioni sono configurate male, o se una vulnerabilità del kernel le aggira, la separazione cade e il processo del container si ritrova a operare sull'host.
La distinzione che conta è quella tra isolamento e confine di sicurezza. L'isolamento serve a far convivere ordinatamente più carichi sulla stessa macchina; il confine di sicurezza serve a resistere a qualcuno che attivamente prova a sfondarlo. Docker, di default, fornisce il primo, non garantisce il secondo, e i progettisti di Docker stessi sono chiari su questo nella loro documentazione: la sicurezza di un container dipende dalla sua configurazione, e una configurazione lasca rende l'isolamento facilmente aggirabile. La documentazione ufficiale sulla sicurezza di Docker codifica proprio i controlli che trasformano l'isolamento di default in qualcosa di più vicino a un confine difeso.
Un container isola i processi, non li blinda contro un attaccante. Trattarlo come una sandbox di sicurezza per default è come considerare blindata una porta solo perché è chiusa: dipende interamente da com'è fatta la serratura, e quella, in Docker, la configuri tu.
Quali sono i vettori di escape più comuni?
Sono quasi sempre misconfigurazioni che indeboliscono o annullano l'isolamento, e quattro tornano con impressionante regolarità. Il primo è il privileged mode: avviare un container con il flag che gli concede privilegi estesi disattiva gran parte delle protezioni del kernel e gli dà accesso ai dispositivi dell'host. Un container privileged compromesso è, di fatto, root sull'host: non c'è quasi nulla da aggirare, perché la barriera è già stata rimossa volontariamente. Eppure lo si trova ovunque, attivato per comodità perché "così funziona senza dover capire quali permessi servono".
Il secondo vettore, ancora più insidioso, è il socket Docker montato dentro il container: per permettere a un'applicazione di gestire altri container, a volte si monta /var/run/docker.sock nel container. Ma quel socket è l'interfaccia di controllo del demone Docker, che gira come root sull'host. Chi ha accesso a quel socket può chiedere a Docker di creare un nuovo container privilegiato che monta l'intero filesystem dell'host, e da lì fare qualunque cosa. Montare il socket Docker in un container equivale a dargli le chiavi dell'host. Il terzo sono le capability eccessive: il kernel Linux suddivide i privilegi di root in capability granulari, e Docker ne concede un sottoinsieme di default, ma alcune configurazioni le aggiungono tutte o aggiungono quelle pericolose, che permettono operazioni con cui si può manipolare il kernel o accedere a risorse dell'host. Il quarto sono i namespace condivisi con l'host: avviare un container che condivide il namespace di rete, dei processi o, peggio, il filesystem dell'host annulla l'isolamento su quella dimensione, e spesso è la porta diretta verso l'esterno.
Cosa rende il socket Docker montato così pericoloso?
Lo rende pericoloso il fatto che dà a chi è dentro il container il controllo totale del demone che gira come root fuori, ed è il vettore che vedo sottovalutato più spesso. Quando un'applicazione ha bisogno di orchestrare container, la scorciatoia comune è montarle il socket Docker, così può parlare con il demone. Il problema è che il demone Docker non distingue le richieste "buone" da quelle "cattive": esegue qualunque comando valido riceva, con i suoi privilegi di root sull'host. Un attaccante che compromette quell'applicazione non deve trovare alcuna vulnerabilità del kernel: gli basta usare il socket per chiedere a Docker di avviare un container che monta la radice del filesystem dell'host e che gira privilegiato, e in due comandi ha accesso completo alla macchina.
È un esempio perfetto del divario tra isolamento e sicurezza: l'applicazione era "isolata" nel suo container, ma una sola configurazione, il socket montato, ha trasformato quell'isolamento in un'illusione. La lezione operativa è che certi comode scorciatoie architetturali sono in realtà rimozioni del confine di sicurezza, e vanno trattate con la stessa cautela con cui si maneggerebbe l'accesso root diretto, perché è esattamente ciò che concedono. Se hai bisogno che un container orchestri altri container, esistono pattern molto più sicuri del montaggio diretto del socket, e vanno progettati con cura. Su come affronto la sicurezza dei container in un contesto applicativo reale ho scritto nella guida all'hardening dei container Docker per Laravel e Symfony, e se vuoi una valutazione offensiva della tua infrastruttura containerizzata, nel mio profilo professionale trovi l'esperienza concreta su container security e penetration testing per le PMI.
Come si esce dal container: la prospettiva dell'attaccante
Si esce sfruttando una delle misconfigurazioni come punto di appoggio, una volta ottenuto un primo accesso dentro il container. È importante capire la catena, perché difendere bene significa capire l'attacco. Il punto di partenza dell'attaccante è quasi sempre un'applicazione vulnerabile che gira nel container: una falla che permette di eseguire comandi, una libreria compromessa, una injection. Da sola, dentro un container ben configurato, quella compromissione resterebbe confinata, ed è esattamente lo scopo dell'isolamento, contenere il danno.
Ma se il container è mal configurato, quel primo accesso diventa un trampolino. L'attaccante, una volta dentro, esplora l'ambiente per capire quali barriere sono state indebolite: controlla se è in privileged mode, se il socket Docker è raggiungibile, quali capability ha, quali parti dell'host sono montate o condivise. Trovata una di queste debolezze, la usa per uscire: dal socket crea un container che gli dà l'host, dalle capability eccessive manipola il kernel, dal filesystem condiviso scrive dove non dovrebbe. Il container escape, in pratica, è raramente lo sfruttamento di una vulnerabilità esotica del kernel: è quasi sempre l'uso di una configurazione lasca che qualcuno ha lasciato aperta per comodità. Questo è incoraggiante, perché significa che la difesa è alla portata di chiunque: non serve essere esperti di kernel per chiudere questi vettori, serve configurare i container con disciplina.
Quando il container non basta e serve un isolamento più forte?
Serve quando esegui codice davvero non fidato, perché lì anche un container ben configurato condivide pur sempre il kernel con l'host, e una vulnerabilità del kernel basta a saltarlo. È un confine onesto che vale la pena conoscere. L'hardening che ho descritto chiude le misconfigurazioni, cioè il novanta per cento dei casi reali di escape, che nascono da configurazioni lasche, non da exploit del kernel. Ma resta una verità architetturale: container e host condividono lo stesso kernel, e se il tuo scenario è eseguire codice arbitrario e ostile, per esempio una piattaforma che fa girare codice caricato da utenti sconosciuti, allora la superficie del kernel condiviso diventa un rischio che la sola configurazione non elimina.
In quei casi lo strumento giusto è un isolamento più forte. Esistono runtime che aggiungono un livello di separazione tra container e kernel dell'host, come quelli che interpongono un kernel utente o una micro-macchina-virtuale tra il carico e il sistema, dando una barriera molto più vicina a quella di una VM pur mantenendo la leggerezza operativa dei container. E in cima alla scala c'è la macchina virtuale vera e propria, con il suo kernel separato, che resta lo standard quando l'isolamento deve resistere a un attaccante che controlla completamente il codice in esecuzione. La regola di progettazione è proporzionare l'isolamento all'ostilità del carico: per i tuoi servizi, scritti da te e fidati, il container hardenizzato è adeguato; per codice di terzi non fidato, serve qualcosa di più. Confondere i due scenari, e trattare codice ostile con il solo isolamento di default di un container, è uno degli errori architetturali più pericolosi.
Come ti accorgi di un tentativo di evasione?
Te ne accorgi se monitori i comportamenti anomali a livello di host e di runtime, perché un'evasione lascia tracce che un sistema attento può cogliere. Un processo che dentro un container tenta improvvisamente operazioni che non dovrebbe, una chiamata di sistema bloccata dal profilo seccomp, un accesso al socket Docker da un container che non dovrebbe averlo, un tentativo di montare il filesystem dell'host: sono tutti segnali che, se osservati, rivelano un attacco in corso. Gli strumenti di sicurezza per i container monitorano proprio questi comportamenti a runtime, confrontando ciò che i container fanno con ciò che dovrebbero fare, e allertando sulle anomalie.
La ragione per cui la rilevazione conta è la stessa di sempre: la prevenzione chiude i vettori noti, ma la capacità di accorgersi di un attacco è ciò che limita il danno quando qualcosa sfugge alla prevenzione. Un'evasione scoperta nel momento in cui avviene è un incidente contenibile; una scoperta mesi dopo è una compromissione profonda dell'intera infrastruttura, perché chi è arrivato all'host da lì ha potuto muoversi ovunque. Monitorare i container in produzione con la stessa serietà con cui si monitora qualunque sistema critico, e non darli per sicuri solo perché "sono container", è la seconda linea che completa l'hardening.
L'hardening: come si chiude la porta?
Si chiude restituendo al container il minimo dei privilegi e attivando le protezioni che Docker offre ma non impone, e sono poche misure che insieme cambiano radicalmente la postura. La prima e più importante: non eseguire come root e usare il remapping degli utenti. Un processo che gira come root dentro il container, in molte configurazioni, è root anche rispetto a certe risorse dell'host; far girare l'applicazione come utente non privilegiato e abilitare il user namespace remapping, che mappa l'utente del container su un utente non privilegiato dell'host, riduce drasticamente cosa un'evasione può fare.
La seconda: rimuovere tutte le capability e aggiungere solo quelle necessarie. Invece di accettare il set di default o, peggio, concederle tutte, la pratica corretta è partire da zero capability e aggiungere esplicitamente solo quelle che l'applicazione richiede davvero, che per la maggior parte dei carichi sono pochissime o nessuna.
docker run \
--user 1000:1000 \
--cap-drop ALL \
--security-opt no-new-privileges \
--read-only \
--security-opt seccomp=default.json \
miaapp:produzioneLa terza: un profilo seccomp, che filtra le chiamate di sistema che il container può fare al kernel, riducendo la superficie con cui un'evasione potrebbe manipolarlo. Docker ne applica uno di default ragionevole, ma verificare che sia attivo e non disabilitato è essenziale. La quarta: il filesystem in sola lettura, che impedisce a un attaccante di scrivere nel container, montando come scrivibili solo le poche directory che lo richiedono. E le regole d'oro che fanno da cornice: mai privileged mode se non in casi eccezionali e compresi a fondo, mai montare il socket Docker se non con architetture pensate per quel rischio, e mai condividere i namespace dell'host senza una ragione precisa.
Il filo che lega tutto è un cambio di mentalità: smettere di vedere il container come una scatola sicura per definizione e iniziare a vederlo per quello che è, un isolamento di default che diventa un confine di sicurezza solo se lo configuri perché lo sia. Nessuna delle misure di hardening è complicata o costosa, e insieme trasformano un container da trampolino verso l'host a barriera reale. La ragione per cui i vettori di escape restano aperti così spesso non è la difficoltà tecnica di chiuderli, è la falsa sicurezza dell'assunto "tanto è isolato", che porta i team a non guardare mai la propria configurazione con occhi avversari. Portare quella prospettiva offensiva sulla propria infrastruttura containerizzata, chiedersi "se compromettessi questo container, riuscirei a uscirne?", è esattamente ciò che distingue chi ha chiuso questi buchi da chi non sa di averli lasciati aperti. Se vuoi sapere se i tuoi container sono davvero un confine o solo un'illusione di isolamento, contattami per una consulenza diretta: proviamo a uscirne dalla parte di chi attaccherebbe, e li trasformiamo in barriere vere prima che lo faccia qualcun altro.