Skip to main content

In this article

Conteneurs Docker privilégiés : en avez-vous vraiment besoin ?

Écrit par

5 novembre 2020

0 minutes de lecture

Cette semaine, en testant Podman, je me suis retrouvé plongé dans une enquête sur les raisons pour lesquelles l’exécution d’un certain conteneur en mode rootless nécessitait l’option --privileged. À juste titre, mon collègue Eric Smalling m’a demandé pourquoi cette option était nécessaire.

En définitive, --privileged signifie qu’on accorde tous les privilèges. Et même si vous pensez que cela n’a pas beaucoup d’importance en mode rootless, cela remet tout de même en cause les principes du moindre privilège et du zéro trust. Même en mode rootless, il est toujours recommandé de bien comprendre les besoins exacts de votre conteneur et de ne lui accorder que les permissions minimales.

Même si cette option n’accorde pas réellement plus de privilèges aux processus exécutés en mode rootless que ceux dont dispose l’utilisateur, je me suis demandé quelles capacités ce processus nécessitait réellement pour qu’on recommande cette configuration. Dans un bel esprit d’exploration, j’ai enfilé mon équipement de spéléologie et me suis aventuré au cœur des ténèbres.

Allons-y

Commençons par voir ce que signifie exécuter des conteneurs en mode rootless. Tout repose sur la magie des espaces de noms utilisateur du noyau Linux, qui permettent aux utilisateurs non privilégiés de créer de nouveaux espaces de noms utilisateur. Lorsqu’un utilisateur crée un nouvel espace de noms utilisateur et y entre, il devient root dans le contexte de cet espace de noms et obtient la plupart des privilèges nécessaires au lancement d’un conteneur fonctionnel. Dans les espaces de noms utilisateur, root ne dispose évidemment pas des mêmes privilèges que le root du système. Cela permet toutefois d’exécuter des conteneurs sans privilèges élevés et de réduire considérablement la surface d’attaque potentielle liée aux vulnérabilités.

J’utilise une machine virtuelle dotée d’une installation minimale de CentOS 8, exécutée dans VirtualBox sur mon MacBook. J’ai d’abord installé podman avec dnf :

dnf install podman

J’ai ensuite créé un répertoire dans le répertoire personnel de mon utilisateur et y ai généré un certificat auto-signé :

mkdir certs
openssl req -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key -x509 -days 365 -out certs/domain.crt

Je veux maintenant utiliser ce certificat pour exécuter un registre Docker local à des fins de test. C’est là que notre aventure commence vraiment. Toute la documentation que j’avais trouvée à ce sujet recommandait d’utiliser l’option --privileged :

[matt@localhost ~]$ podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key --privileged registry:2

Comme on peut le voir dans la commande ci-dessus, nous exécutons l’image de registre étiquetée 2, créons un montage de volume qui lie le répertoire certs du répertoire courant à /certs dans le conteneur, transmettons des variables d’environnement pour configurer le registre et ajoutons allègrement l’option --privileged, qui demande à podman d’exécuter ce conteneur en mode privilégié.

Pour utiliser le registre avec podman, nous devons ensuite ajouter une entrée dans /etc/containers/registries.conf :

[registries.insecure]
registries = ['localhost:5000']

L’exécution en mode privilégié fonctionne parfaitement. J’ai donc d’abord voulu voir ce qui se passerait sans privilèges. C’était assez simple : le conteneur démarre, puis plante et redémarre, et ses journaux indiquent qu’il ne peut pas accéder au certificat.

[matt@localhost log]$ podman logs bd323f90c60b

time="2020-10-20T18:24:27.806128235Z" level=fatal msg="open /certs/domain.crt: permission denied"

À ce stade, j’ai supposé que le problème était lié aux capacités Linux, car l’une des principales fonctions de l’option --privileged est de permettre au conteneur d’accéder à toutes les capacités fournies par le noyau. Voici ce que nous montre podman lorsqu’on exécute ce conteneur en mode privilégié :

[matt@localhost ~]$ podman top -l capeff
EFFECTIVE CAPS
full

Je me suis alors tourné vers les pages de manuel Linux pour examiner en détail les actions que chaque capacité permet aux processus d’effectuer. Nous pouvons de nouveau utiliser podman pour vérifier les capacités de notre conteneur non privilégié en cours d’exécution :

[matt@localhost ~]$ podman top b7cea04eb70e
USER   PID   PPID   %CPU    ELAPSED        TTY   TIME   COMMAND
root   1     0      0.000   1.400417458s   ?     0s     registry serve /etc/docker/registry/config.yml

[matt@localhost ~]$ podman top -l capeff
EFFECTIVE CAPS
AUDIT_WRITE,CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,MKNOD,NET_BIND_SERVICE,NET_RAW,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT

En comparant cette liste aux pages de manuel Linux, on constate que les capacités effectives en mode non privilégié devraient suffire à permettre au conteneur de lire des fichiers. En fait, cette liste lui accorde probablement plus de capacités que nécessaire, mais nous y reviendrons plus tard.

L’étape suivante consiste à consulter le fichier audit.log sur l’hôte. Et, sans surprise :

type=AVC msg=audit(1603218166.554:4674): avc:  denied  { read } for  pid=223435 comm="registry" name="domain.crt" dev="dm-0" ino=6582402 scontext=system_u:system_r:container_t:s0:c127,c779 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

Cette entrée nous indique que SELinux a bloqué l’opération de lecture du fichier domain.crt. Sur les systèmes CentOS et RHEL, SELinux est configuré par défaut en mode Enforcing. Nous pouvons le vérifier avec l’outil sestatus :

[matt@localhost ~]$ sudo sestatus
[sudo] password for matt: 
SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing
Mode from config file:          enforcing
Policy MLS status:              enabled
Policy deny_unknown status:     allowed
Memory protection checking:     actual (secure)
Max kernel policy version:      31

En mode Enforcing, SELinux étiquette les fichiers et les répertoires pour gérer leur accès. Ces étiquettes influent également sur le comportement des moteurs de conteneurs. Ceux-ci lancent les processus avec l’étiquette container_t et attribuent au conteneur lui-même l’étiquette container_file_t. SELinux veille à ce que les processus n’interagissent qu’avec les fichiers portant ces étiquettes et refuse par défaut l’accès aux fichiers qui se trouvent hors du conteneur. Avec l’option --privileged, les étiquettes sont désactivées et le conteneur s’exécute avec l’étiquette utilisée au démarrage du moteur de conteneurs. Nous pouvons le vérifier en examinant nos conteneurs avec podman. Voici un conteneur privilégié :

[matt@localhost ~]$ podman top -l label
LABEL
unconfined_u:system_r:container_runtime_t:s0

Et voici un conteneur exécuté sans privilèges :

[matt@localhost ~]$ podman top -l label
LABEL
System_u:system_r:container_t:s0:c23,c603

En examinant le répertoire certs dans le système de fichiers de l’hôte, on constate qu’il porte l’étiquette user_home_t :

[matt@localhost ~]$ ls -lZ certs
total 8
-rw-rw-r--. 1 matt matt unconfined_u:object_r:user_home_t:s0 1944 Oct 20 17:54 domain.crt
-rw-------. 1 matt matt unconfined_u:object_r:user_home_t:s0 3272 Oct 20 17:53 domain.key

En pratique, cela signifie qu’en mode non privilégié, ces fichiers ne sont pas accessibles au conteneur, même s’ils sont montés dans l’image.

Au lieu d’exécuter notre conteneur avec --privileged, nous avons plusieurs solutions. Nous pouvons d’abord désactiver complètement les étiquettes en ajoutant --security-opts label=disable à la ligne de commande podman. Ce n’est évidemment pas idéal du point de vue de la sécurité. podman et Docker disposent donc tous deux d’un mécanisme permettant de réétiqueter les montages : utilisez l’option Z pour un montage privé ou l’option z si le montage est partagé.

Pour corriger notre conteneur, il suffit donc de l’exécuter ainsi :

podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs:Z  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2

Enfin, pour revenir aux capacités, il est toujours préférable d’exécuter les conteneurs avec le moins de capacités possible. Dans ma configuration de test, il est même possible d’exécuter ce conteneur de registre en supprimant toutes les capacités.

podman run -d --name registry -p 5000:5000 -v "$(pwd)"/certs:/certs:Z  --restart=always -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key --cap-drop=all registry:2

Il existe différentes façons de déterminer les capacités nécessaires à votre conteneur. Vous pouvez, par exemple, consulter dans le journal d’audit les capacités que SELinux bloque pendant l’exécution, puis ajouter celles qui sont nécessaires à l’aide de l’argument --cap-add. Le principe du moindre privilège reste toujours la meilleure option ! La morale de cette histoire : ne jetez pas le bébé avec l’eau du bain.

En résumé

Déclarer des conteneurs --privileged, même dans des espaces de noms utilisateur, n’est pas une bonne pratique et va à l’encontre des principes du moindre privilège et du zéro trust. Avant de lancer votre conteneur, déterminez ses besoins réels en vous appuyant sur des outils comme SELinux pour auditer les capacités et permissions demandées par votre image, puis accordez-lui uniquement celles-ci lors de son exécution en production. Vous constaterez souvent que ses besoins sont en réalité très limités.

La sécurité des conteneurs, pensée pour les développeurs

Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.