Conteneurisation Rootless

Dans le cadre de mon stage de fin d’études en Master Informatique à Systerel, j’ai été amené à étudier différentes solutions de conteneurisation au fil de mes recherches et des suggestions de collègues de travail. Ce stage arrivant à son terme, une partie du fruit de ces recherches est ainsi retranscrite ici.

Contexte

Siemens réalise un système appelé CBTC (Communication Based Train Control) qui permet de surveiller voire automatiser un réseau de métro. C’est dans ce cadre que Systerel travaille avec Siemens pour réaliser des campagnes de tests des programmes CBTC afin de vérifier leur fiabilité. Plusieurs équipes réalisent ce travail, chacune avec un niveau d’abstraction avec le matériel réel du métro différente. L’équipe dans laquelle j’ai évolué est la plus abstraite. L’idée est ici de permettre de tester la fiabilité des programmes CBTC sans aucun équipement réel, en ne se basant que sur un serveur Linux quelconque. Des simulateurs logiciels fourniront au programme testé les valeurs souhaitées. Pour s’assurer de l’exécution du test souhaité sur un serveur de test Linux donné, des collections de scripts sont utilisées. La problématique rencontrée est que cette collection de scripts souffre d’un défaut d’architecture qui conduit à un problème d’allocation de ressources lorsqu’une deuxième campagne de tests est lancée en parallèle d’une première sur un même serveur de tests. Les équipes de tests ne peuvent ainsi exécuter que N campagnes de tests grâce à N serveurs de tests. Ne pouvant pas modifier cette collection de scripts, l’idée qui a été imaginée est de conteneuriser cet environnement de test pour pouvoir en exécuter autant que possible sur un même serveur sans concurrence de ressources entre eux.

Docker

De par sa popularité et sa maturité logicielle, le premier outil de conteneurisation étudié était Docker. L’arrivée de Docker en 2013 a entamé la démocratisation de l’usage des conteneurs dans la sphère personnelle comme professionnelle pour répondre à un large panel de problématiques. Sa praticité donnerait même envie de fermer les yeux sur son plus gros défaut potentiel.

En effet, peu se soucient ou s’intéressent à cette particularité, mais l’utilisation de Docker dans son installation classique donne l’équivalent des droits root aux utilisateurs capables d’utiliser la commande Docker. Il est assez instinctif de réfléchir à deux fois avant d’attribuer les droits root ou sudo à un employé, moins lorsqu’il s’agit de l’ajouter au groupe Linux “docker” en apparence anodin pour lui permettre d’exécuter un conteneur.

Le fonctionnement de Docker repose sur un daemon central nommé dockerd avec lequel interagissent un ou des clients Docker, locaux ou distants. Ces clients ne sont au final que des interfaces utilisateur qui demandent au daemon de réaliser les actions nécessaires. Pour communiquer avec ce daemon, celui-ci se connecte à une socket Unix. Par défaut, elle appartient à l’utilisateur root et les autres utilisateurs ne peuvent y accéder qu’en préfixant leur commande Docker par sudo. Dans ce cas, l’élévation de privilèges apportée est assez visible. Mais il est aussi possible de passer par un groupe qui sera nommé “docker”. Lorsque le daemon Docker démarre, il crée une socket Unix auquel les membres du groupe “docker” auront accès, ce qui est très pratique pour ne pas avoir à toucher à la commande sudo. Mais en lisant cette page de la documentation officielle de Docker, on peut retrouver cet avertissement.

Capture d’écran de la documentation de Docker
Certes notre employé a conservé son entrée sudoers et ses droits Linux initiaux, mais il a acquis le droit de communiquer avec un daemon exécuté en continu, doté de fonctionnalités extrêmement puissantes et possédé par root.

Prenons un cas de dérapage simple. Lors de l’utilisation de Docker, il est possible de demander l’exécution d’un conteneur et d’y partager des dossiers de l’hôte. L’ajout d’un simple -v /:/host à une commande exécutant un conteneur Docker permet d’obtenir un accès total à l’ensemble du système de fichiers de l’hôte à l’intérieur d’un dossier “host” placé à la racine du conteneur. Toute opération réalisée sur ces fichiers sera appliquée en tant que root par le daemon, il est donc possible d’accéder et de modifier les fichiers d’un autre employé du même serveur sans restriction.

Plus largement, un --privileged appliqué à une commande exécutant un conteneur Docker permet de lever la plupart des sécurités et isolations du conteneur avec l’hôte. Cela peut permettre, en plus de pouvoir retrouver la situation précédente, d’exécuter des commandes de son choix sur l’hôte depuis le conteneur. En étant exécutées avec les privilèges de root, elles posent ainsi une menace accrue pour la sécurité de l’hôte.

De plus, le principe même d’un daemon central doté des droits root le donnent comme cible de choix pour un attaquant. Il lui suffit de trouver une vulnérabilité due à une mauvaise configuration, un manque de mise à jour ou même une faille 0day, et celui-ci obtient essentiellement les “clés du royaume” sur l’hôte.

Docker Rootless

Il existe cependant une alternative, nommée Docker Rootless, qui comme son nom l’indique fonctionne sans avoir besoin de privilèges root. Le daemon central appartient ici à l’utilisateur courant et l’installation de l’outil se fait dans son répertoire personnel. Ainsi un conteneur ne peut avoir qu’au mieux autant de privilèges que l’utilisateur courant. Cela s’accompagne évidemment d’une réduction des fonctionnalités offertes par l’outil, mais après avoir étudié ces limitations qui sont officiellement documentées à cette page, ces limitations ne sont pas bloquantes pour la réalisation de mon stage.

Un problème est toutefois apparu lors des prototypages. La solution à réaliser au cours de mon stage nécessite de pouvoir exécuter deux conteneurs en parallèle qui écouteront chacun sur une adresse IP virtuelle dédiée de l’hôte et sur l’ensemble des ports disponibles. Mais lors de tests au cours desquels deux conteneurs avaient alloué chacun une adresse IP et un même numéro de port, le deuxième conteneur ne pouvait pas se lancer pour cause d’un problème de port déjà alloué. Ce problème ne devrait pas arriver en théorie étant donné que malgré le fait que le même numéro de port est alloué deux fois, il l’est sur deux adresses IP distinctes.

Situation souhaitée et possible avec Docker
Ce problème ne survenait pas lors de tests avec Docker en installation standard ce qui veut dire que le problème est lié à l’implémentation de Docker Rootless. En effet, pour remédier au problème dû au manque de droits root sur l’hôte, un proxy “userland” est créé dans un espace de noms Linux et fait office d’intermédiaire entre le réseau du conteneur et le réseau de l’hôte. Lorsqu’un Docker Rootless alloue le couple IP:port de l’hôte au couple IP:port du conteneur, il réalise aussi cette allocation sur une adresse IP intermédiaire du proxy userland. L’allocation d’un deuxième conteneur à une seconde adresse IP sur un même numéro de port a donc pour effet la seconde tentative d’allocation sur l’adresse IP du proxy sur un même numéro de port ce qui ne peut fonctionner.

Cette situation peut se schématiser ainsi :

Problème rencontré avec Docker Rootless

La possibilité de contourner ce problème à l’aide de règles IPtables a été envisagée afin de répliquer celles normalement créées lors d’une installation root, mais cela serait trop rigide face aux changements potentiels du projet.

Podman

Au cours d’une discussion avec un collègue de travail, celui-ci m’a parlé de Podman, l’alternative à Docker créée par Red Hat. Malgré sa récence et sa bien moindre popularité par rapport à Docker, celui-ci a des atouts très convaincants. Tout d’abord, sa conception est dite daemonless, c’est à dire que contrairement à Docker, il n’a pas besoin de daemon pour fonctionner. Ainsi le problème de sécurité qui émane d’une telle conception disparait de par l’exécution de Podman en tant que processus utilisateur. Afin de permettre aux utilisateurs de Docker de migrer aisément vers Podman, la quasi-totalité des commandes de Docker sont réutilisables telles quelles en remplaçant simplement “docker” par “podman” dans celle-ci.
Par exemple pour une commande Docker telle que :

docker run -it --rm -v /home/:/home/ --cpuset-cpus=1 -p 192.168.1.1:22:22 \
           --systemd=always --userns=keep-id hello-world

Il suffira d’exécuter :

podman run -it --rm -v /home/:/home/ --cpuset-cpus=1 -p 192.168.1.1:22:22 \
           --systemd=always --userns=keep-id hello-world

Podman propose aussi une socket imitant celle de Docker afin de pouvoir être connecté à des outils tiers conçus pour Docker de manière transparente comme Docker Compose. Podman est pleinement compatible avec les images et registres de Docker grâce à la norme OCI ce qui permet de n’avoir aucun travail de migration entre les deux outils pour les conteneurs existants. En plus de ces avantages et d’autres encore, Podman prend en charge par défaut le fonctionnement Rootless et est lui aussi open source sous la même licence que Docker.

Après quelques tests, il s’avère même que le problème précédemment rencontré avec Docker Rootless n’existe pas ici avec Podman en exécution rootless. Cette solution a ainsi été retenue pour réaliser mon stage et a été implémentée sur les serveurs de Siemens.

Commentaires