guest@6admin.io:~$

Durcir Docker

La phrase qui devrait gouverner chaque décision Docker est celle-ci: un container partage le kernel de l’hôte, donc une évasion de container est une évasion kernel. Un container n’est pas une petite VM, c’est un processus enveloppé de namespaces et de cgroups avec un filtre seccomp devant ses syscalls et tout ce qui est dessous est le même kernel que fait tourner l’hôte. Durcir Docker consiste donc surtout à ne pas remettre à un container les quelques choses qui lui permettent de contourner les namespaces et de parler directement à ce kernel partagé, et, en dessous, à ne pas faire tourner tout l’appareil en root pour commencer.

Les options qui remettent l’hôte

Une courte liste d’options d’exécution éteint l’isolation et chacune apparaît dans de vrais déploiements avec l’excuse “c’était la seule façon de le faire marcher”:

docker run --privileged ...                                   # désactive presque toute l'isolation
docker run --cap-add=SYS_ADMIN ...                            # mount, etc: primitive d'évasion
docker run -v /var/run/docker.sock:/var/run/docker.sock ...   # root sur l'hôte, blanchi
docker run -v /:/host ...                                     # le système de fichiers hôte, en écriture
docker run --pid=host ...                                     # chaque processus hôte, où s'injecter

--privileged est la brute: à l’intérieur, monter le disque hôte et y chroot est une évasion de deux lignes, parce que le container n’a jamais vraiment été séparé des périphériques de l’hôte. La socket Docker est la subtile et celle qu’on monte sans réfléchir, parce qu’un processus qui peut parler au démon peut lui demander de démarrer un nouveau container privilégié qui bind-monte /, c’est-à-dire root sur l’hôte par un appel d’API. J’ai déjà écrit que monter cette socket en lecture seule ne change rien et cela mérite répétition: le :ro vous empêche d’écrire le fichier socket, pas le démon de faire tout ce que vous lui demandez. Un bind mount de / est la même évasion sans l’indirection. Ce ne sont pas des erreurs de configuration à régler; c’est l’isolation qu’on éteint et le durcissement consiste simplement à ne pas le faire.

La démonstration classique qu’il y a bien le kernel en dessous, pas un bac à sable, est l’évasion cgroup v1 par release_agent: un container privilégié écrit un chemin hôte dans release_agent et ce programme tourne sur l’hôte quand le cgroup se vide. Elle est contournée par des correctifs et demande le privilège, mais c’est la preuve la plus nette que “privilégié” veut dire “sur l’hôte”.

Lancer chaque container comme s’il allait être percé

Le défaut défensif est l’inverse de ce qui précède: tout retirer, ne rendre que ce que la charge prouve nécessaire et rendre le container incapable de gagner du privilège même si son processus est pris.

docker run \
  --user 1000:1000 \
  --cap-drop ALL --cap-add CHOWN \
  --security-opt no-new-privileges \
  --read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --pids-limit 200 --memory 512m --cpus 0.5 \
  myimage:tag

no-new-privileges est le héros discret: il empêche un binaire setuid dans le container d’élever jamais le privilège, ce qui neutralise une large classe d’escalades internes. cap-drop ALL puis cap-add les deux ou trois que vous utilisez vraiment est la discipline de capabilities qui compte, parce que l’ensemble par défaut est bien plus large qu’aucun service n’en a besoin et qu’un SYS_ADMIN caché dans une concession large est une évasion qui attend d’être trouvée. La racine en lecture seule avec un petit tmpfs noexec fait qu’un processus compromis ne peut pas écrire une charge là où il pourra ensuite l’exécuter. Rien de tout cela ne coûte à l’application, parce qu’une image bien bâtie n’a pas besoin d’écrire dans ses propres binaires ni de détenir des capabilities qu’elle n’appelle jamais.

L’image porte la moitié de la posture. Une base minimale, un USER non-root, aucun outil de build laissé dans la couche finale et COPY --chown pour que l’app ne tourne pas en root:

FROM alpine:3.20
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app
WORKDIR /app
COPY --chown=app:app app/ /app/
USER app
EXPOSE 8080
CMD ["/app/run"]

Le rootless est la correction structurelle

Tout ce qui précède contraint un container; le Docker rootless change ce que vaut une évasion réussie. Avec le démon tournant sous un utilisateur non privilégié et un user namespace qui mappe le root du container vers un UID hôte ordinaire, une évasion atterrit sous cet utilisateur non privilégié, pas sous le root de l’hôte. Podman atteint le même point sans démon du tout, ce pourquoi je le fais tourner pour tout ce qui est neuf. Le coût est réel mais petit: les ports sous 1024 réclament un helper, certains pilotes de stockage réclament fuse-overlayfs et --privileged ne peut vraiment pas tout faire ce qu’il fait en rootful, ce qui est surtout une fonctionnalité. Les prérequis sont les user namespaces activés et les plages subuid/subgid allouées:

echo "$USER:100000:65536" | sudo tee -a /etc/subuid /etc/subgid
curl -fsSL https://get.docker.com/rootless | sh

Une évasion de container qui donne un utilisateur non privilégié plutôt que root est la différence entre un incident et une catastrophe et c’est le changement au plus fort levier de cette page.

Images : scanner et vérifier ce qu’on fait tourner

Un runtime durci qui fait tourner une image piégée reste piégé, donc la chaîne d’approvisionnement de l’image fait partie du sujet. Scannez avant de déployer et faites échouer le build sur les hautes corrigeables et vérifiez la provenance plutôt que de faire confiance à un tag:

trivy image --severity HIGH,CRITICAL --exit-code 1 myimage:tag
cosign verify --key cosign.pub myregistry/myimage:tag

Signer avec cosign et vérifier à l’admission est la voie moderne et il vaut la peine de rappeler pourquoi: un tag est un pointeur mutable et lui faire confiance, c’est faire confiance à quiconque peut le déplacer. Épinglez par digest les bases que vous ne construisez pas et signez celles que vous construisez.

Une surveillance qui vaut la peine

La détection au runtime a ici un outil vraiment bon, Falco, qui observe les syscalls et se déclenche sur ce qui compte, un shell lancé dans un container, le montage d’un chemin hôte sensible, une écriture sous /proc ou /sys:

- rule: Privileged container started
  condition: container and proc.name=docker and proc.cmdline contains "--privileged"
  output: "container privilégié (cmd=%proc.cmdline image=%container.image.repository)"
  priority: CRITICAL

Ce que je ne construirais pas, c’est ce que ces guides incluent toujours: une boucle shell qui greppe le log Docker et fait un geoiplookup des connexions pour signaler les “pays à risque”. C’est du bruit, ça profile par nationalité plutôt que par comportement et ça n’arrête rien. docker-bench-security pour une base de configuration et Falco pour les signaux au runtime sont les vrais outils; le reste est du théâtre.

Où atterrir

Jamais --privileged, jamais la socket Docker dans un container, jamais un bind mount de /, parce que ce sont l’isolation éteinte, pas réglée. Lancez chaque container avec cap-drop ALL, no-new-privileges, un utilisateur non-root, la racine en lecture seule et des limites de ressources, depuis une image minimale qui tourne déjà en non-root. Passez au rootless ou à Podman pour qu’une évasion coûte à l’attaquant un UID non privilégié plutôt que l’hôte. Scannez et signez les images et vérifiez-les à l’admission. Le tout repose sur un fait qui mérite une dernière répétition: il y a un seul kernel, le container est dessus et tout ceci consiste à empêcher le container de s’en apercevoir.

[edit: 2026] L’implémentation Ansible de tout ceci: les rôles docker et podman .