+39 3662317539/+34 620899163
info@simonecosci.com

Il socket Docker montato in un container: perché equivale a root sull’host

Sviluppo Siti Web a Tenerife

Il socket Docker montato in un container: perché equivale a root sull’host

Traefik, Portainer e parecchi strumenti di monitoraggio chiedono la stessa riga nel docker-compose.yml:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

Sembra un dettaglio di configurazione. In pratica è la riga che consegna a quel container le chiavi del server. Vale la pena sapere perché, e cosa mettere al suo posto.

Cosa c’è dietro quel file

Il daemon Docker, dice la documentazione sulla sicurezza di Docker, “requires root privileges unless you opt-in to Rootless mode”: gira come root, a meno di non aver scelto la modalità rootless. Il comando docker che usi dal terminale non fa niente da solo: parla con il daemon tramite una API REST, e quella API passa da un socket Unix, /var/run/docker.sock.

Chi può scrivere su quel socket può fare tutto quello che fa il comando docker. Lo dice il riferimento di docker run: montando il socket “you give the container the full access to create and manipulate the host’s Docker daemon”.

E creare container, su un daemon che gira come root, vuol dire arrivare all’host. La stessa pagina sulla sicurezza lo spiega con un esempio: si può avviare un container in cui la directory /host è la / del server, e a quel punto “the container can alter your host filesystem without any restriction”. Per lo stesso motivo la guida post-installazione avverte che il gruppo docker “grants root-level privileges to the user”, e la pagina sulla sicurezza ne trae la regola: “only trusted users should be allowed to control your Docker daemon”.

Anche la documentazione di Traefik lo scrive senza giri di parole: “If Traefik is attacked, then the attacker might get access to the underlying host.”

Quindi la domanda giusta non è “mi fido di Traefik?”. È: se domani in quel container c’è una vulnerabilità, chi la sfrutta resta nel container o arriva al server? Con il socket montato, arriva al server.

Il :ro non basta

La correzione che si vede più spesso è aggiungere :ro:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro

Non protegge niente. Il :ro rende di sola lettura il mount, cioè impedisce di modificare il file. Ma parlare con il daemon non vuol dire modificare il file: vuol dire connettersi al socket e mandare richieste. E il kernel Linux, quando un processo si connette a un socket Unix, controlla il permesso di scrittura sul socket (path_permission(&path, MAY_WRITE) in net/unix/af_unix.c) ma non se il mount è in sola lettura. Il controllo che restituisce “file system di sola lettura”, in fs/namei.c, vale solo per file regolari, directory e link simbolici. I socket non sono tra questi.

Risultato: con o senza :ro, il container può chiedere al daemon di creare un altro container, con i privilegi che vuole.

Come capire se ce l’hai

Sul server, per ogni container (anche fermo) che ha qualcosa montato in /var/run/docker.sock:

docker ps -a --filter volume=/var/run/docker.sock --format "table {{.ID}}\t{{.Mounts}}"

Il filtro volume, dalla documentazione di docker ps, “shows only containers that mount a specific volume or have a volume mounted in a specific path”: guarda il percorso dentro il container. Se qualcuno ha montato il socket altrove, ad esempio in /tmp/docker.sock, non compare. Per questo conviene cercare anche nei file Compose:

grep -rn "docker.sock\|use_api_socket" /percorso/dei/progetti

use_api_socket è l’attributo di Compose che fa la stessa cosa in forma più breve. Il riferimento dei servizi dice che con questo attributo il container “is able to interact with the underlying container engine”, e in più “your credentials are mounted inside the container”: anche le credenziali del registry.

Già che ci sei, controlla chi è nel gruppo docker con getent group docker. Ogni nome che compare lì ha, di fatto, accesso root.

Le alternative

1. Chiediti se serve davvero

Molti strumenti montano il socket solo per leggere: Traefik guarda le label dei container per sapere dove instradare il traffico. Non ha bisogno di crearne. Il problema è che il socket non distingue: o dai tutto, o niente. Le soluzioni che seguono servono a dare solo la parte che serve.

2. Un socket proxy che filtra le richieste

È la prima mitigazione che suggerisce la documentazione di Traefik: “Authorize and filter requests to restrict possible actions”, indicando il progetto Docker Socket Proxy di Tecnativa. È un HAProxy che sta davanti al socket e “blocks access to the Docker socket API according to the environment variables you set”. Ogni sezione dell’API si abilita con 1 e si nega con 0. Di default sono aperte solo EVENTS, PING e VERSION; POST è chiuso, e senza POST passano solo le richieste GET e HEAD, cioè solo lettura.

services:
  dockerproxy:
    image: tecnativa/docker-socket-proxy
    privileged: true
    environment:
      CONTAINERS: 1   # lettura della lista dei container
      POST: 0         # nessuna scrittura
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    networks:
      - socket

  traefik:
    image: traefik   # la versione che usi
    command:
      - --providers.docker.endpoint=tcp://dockerproxy:2375
    networks:
      - socket
      - web

networks:
  socket:
    internal: true
  web:

Il socket lo monta ancora qualcuno, ed è il proxy: il README dice che richiede --privileged. Il guadagno è che ora lo tiene un componente piccolo che fa una cosa sola, non un’applicazione esposta su internet. Due regole dal README: il proxy non ha TLS, e la porta 2375 non va mai pubblicata (“Never expose this container’s port to a public network”). Nell’esempio non c’è nessun ports: e la rete socket è internal: true, che secondo il riferimento delle reti “lets you create an externally isolated network”.

3. Docker in modalità rootless

La modalità rootless “lets you run the Docker daemon and containers as a non-root user”. Il socket diventa quello dell’utente (l’esempio della documentazione, per l’utente con UID 1000, è unix:///run/user/1000/docker.sock), e chi lo controlla ottiene i privilegi di quell’utente, non quelli di root. Non rende innocuo il socket, ma abbassa molto il tetto del danno. Ha i suoi limiti pratici, che conviene provare prima di migrare un server in produzione.

4. Per l’accesso remoto, SSH e non TCP in chiaro

Se il motivo per cui qualcuno tocca il socket è amministrare il server da fuori, la documentazione Docker è netta: “Exposing the daemon API over HTTP without TLS is not permitted”. La strada più semplice è DOCKER_HOST=ssh://utente@server, che riusa la chiave SSH che hai già.

Cosa evitare

  • Il :ro come misura di sicurezza. Cambia il mount, non quello che il container può chiedere al daemon.
  • Aggiungere utenti al gruppo docker “per non scrivere sudo“. È dare root senza chiamarlo così.
  • Pubblicare la porta del socket proxy, anche “solo per un test”.
  • use_api_socket: true copiato da un esempio senza sapere cosa fa: è il socket montato, più le credenziali.

Se hai un server con qualche container in produzione e non sai quali di questi hanno in mano il socket, è un controllo da mezz’ora che vale la pena fare. Se vuoi farlo insieme, scrivici.