Dans cet article, nous allons essayer de voir les différentes bonnes pratiques de sécurité à adopter lorsqu’on travaille avec des conteneurs.
Vous trouverez à la fin de cet article toutes les différentes sources que je vous recommande et que j’ai utilisées.
Cet article fait suite à ma conférence sur la plateforme de challenge Root-Me que vous pouvez retrouver ici : voir la conférence
Je vous propose aussi le powerpoint de la présentation si vous souhaitez quelque chose de plus visuel.
Si vous avez d’autres bonnes pratiques ou bien d’autres idées qui pourraient enrichir l’article, n’hésitez pas à m’envoyer un petit MP sur Twitter. Le but étant de faire une introduction à la sécurité avec Docker la plus complète possible.
Architecture de Docker
Il est important de comprendre le fonctionnement de l’architecture Docker avant de s’aventurer dans le sujet de la sécurité. C’est pourquoi je vous en propose un bref aperçu.
Docker utilise une architecture simple de type client-serveur.
On peut distinguer trois modules :
- Docker CLI : On l’utilise, nous, en tant qu’utilisateur pour exécuter nos commandes telles que pull, run, stop, start…
- Docker API : Il s’agit ici de l’interface qui va se placer entre Docker CLI et le démon Docker. Il utilisera soit le Socker Unix soit le Socket TCP.
- Daemon Docker : C’est lui qui gère tout. Nos conteneurs, nos images, nos réseaux, etc.
Sécuriser son hôte
Lorsqu’on parle de sécurité avec Docker, on va également parler de l’hôte sur lequel est installé Docker.
- La première chose est de toujours garder son hôte à jour, qu’il s’agisse de l’OS ou même des packages qui sont dessus.
- Je vous conseil également de ne garder que l’essentiel sur ce dernier, donc éviter d’exposer des ports ou installer des applications qui n’auraient rien à faire sur le serveur et qui ne sont pas en lien avec Docker.
- Il s’agit ici d’une bonne pratique, la création d’une partition spécifique pour Docker afin d’éviter d’aller jusqu’à saturer /var.
- On peut utiliser AppArmor / SELinux pour sécuriser l’accès qu’à Docker sur l’hôte en mettant en place un système de whitelist/blacklist sur les fichiers, les chemins, etc.
- Pour terminer, il est possible d’utiliser Seccomp qui agira sur les appels systèmes et descendra donc bas au niveau des couches, au niveau du kernel. Il s’agira ici de donner ou de restreindre les droits des conteneurs. Une gestion plus fine que les capabilities (qu’on verra un peu plus loin).
La sécurité de l’image
docker run --rm -tid --name monConteneur alpine:latest
Cette commande, en apparence anodine, présente en réalité trois aspects importants de la sécurité de nos images.
Le premier étant la version de l’image, deux choix s’offrent à nous :
- Utiliser le tag latest et être toujours à la dernière version lorsqu’on pull
- Utiliser une version “en dur” mais s’assurer d’être sur une release stable
Les deux peuvent s’entendre et seront à utiliser suivant l’application qui tournera dans votre conteneur.
Le deuxième point concerne la registry depuis laquelle vous allez pull votre image. Assurez-vous d’utiliser une registry sécurisée et vérifiée. Vous pouvez même regarder qui est le dernier auteur du push. Le but étant de contrôler l’image de base qu’on va utiliser pour nos conteneurs.
Pour terminer, vous constaterez que j’ai utilisé une alpine, et ce n’est pas un choix anodin. Je vous recommande de toujours utiliser des images légères en taille, mais aussi en application, de telle sorte à pouvoir contrôler au maximum ce qu’il y a dedans.
Partez soit de rien et construisez l’image vous-même, ou bien partez d’une alpine qui reste une image très légère. En résumé, évitez les images déjà toutes faîtes.
docker build . --pull --no-cache
Docker nous offre un système de caching afin de reconstruire nos images beaucoup plus rapidement.
Cependant, il se peut que dans certains cas, on souhaite désactiver ce système car il va sauter les étapes qu’il a déjà éxécutées… Comme par exemple notre commande pour update !
Je vous recommande trois petites choses :
- Reconstruisez souvent vos images,
- Utiliser l’argument –no-cache, bien que ce soit un peu plus lent mais plus secure,
- Utilisez l’argument –pull pour aller chercher la dernière version de l’image.
RUN git pull https://bochi:MonPassWordSecure123@RepodeBochi.fr/monAppSecure
Ne stockez jamais de mot de passe ou de choses sensibles dans vos dockerfile ! On peut utiliser des outils comme Dive pour les inspecter et les voir en clair.
Vous retrouverez d’ailleurs un article ICI de personnes qui vont à la chasse aux secrets dans les images sur les repository public.
Article sur le minage de crypto avec des images malicieuses
Pour conclure sur les images, je vous recommande d’essayer Hadolint (gratuit & open source), qui vous surlignera et mettra en évidence vos mauvaises pratiques dans vos Dockerfile, une bonne manière de les optimiser et de les rendre plus secure.
Les relations entre les conteneurs
Dans cette partie, nous allons aborder des concepts de base avec Docker mais qui sont pourtant essentiel et au coeur de la sécurité de nos conteneurs.
L’utilisation d’un builder
Lorsqu’on va souhaiter créer une image qui a plusieurs étapes, on pourra passer par un “builder”.
Nous parlions dans le chapitre précédent de la légèreté des images dans un but d’optimisation, mais aussi de sécurité, ici c’est pareil, on va pouvoir effectuer des actions dans un conteneur dédié à ceci (en général ce sera de la compilation) et on pourra transférer le rendu de cette compilation dans un conteneur propre.
Prenons l’exemple donné par la documentation de Docker pour expliquer ça plus en détail.
On prend une image golang et on build à l’intérieur.
Notez bien le AS builder juste après le FROM GOLANG
FROM golang:1.16 AS builder WORKDIR /go/src/github.com/alexellis/href-counter RUN go get -d -v golang.org/x/net/html COPY app.go ./ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
On pourra ensuite appeler notre builder dans un autre conteneur et transférer notre artefact dans un conteneur propre.
FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./ CMD ["./app"]
Outre la légèreté de l’image, l’intérêt est de ne laisser aucun outil qui n’est nécessaire dans un conteneur destiné à la production.
Les réseaux avec Docker
Bien souvent, le réseau on premise dans une entreprise est configuré de telle manière à faire du routage, définir les accès internet, l’isolation… Mais, souvent, on ne prendra pas le temps de mettre ces sécurités et bonnes pratiques en place pour nos conteneurs.
docker network ls #OUTPUT NETWORK ID NAME DRIVER SCOPE ee0dff615ad0 bridge bridge local 3d67270f7eac host host local 2819366b76a9 none null local
Pour commencer, découvrons les trois types de réseaux que Docker nous offre :
- De base, nous utilisons le bridge Docker0 qui permet à tous nos conteneurs à l’intérieur de se toucher entre eux et de sortir sur Internet,
- Le réseau Host permet de partager les interfaces de notre hôte sur nos conteneurs (donc on retrouvera toutes les interfaces de notre hôte à l’intérieur du conteneur), en plus de prendre le hostname de l’host,
- Le réseau None isole notre conteneur d’un point de vue réseau, il ne sera pas touchable par les autres conteneurs et n’aura pas d’accès à internet.
Docker nous offre la possibilité de créer nos propres réseaux, pour cela on utilise la commande suivante :
docker network create Monreseau
On pourra de cette manière segmenter notre réseau et définir par exemple un réseau pour les conteneurs exposés en front-end et les autres en back-end.
C’est justement parce que le backend n’a parfois pas vocation à aller sur Internet qu’on va vouloir l’isoler du WAN.
Pour cela on, utilise la commande :
docker network create --internal MonReseauBackEnd
On peut aussi avoir besoin de connecter plusieurs réseaux à un conteneur.
Dans ce cas, il ne faut pas que le conteneur soit lancé, on pourra lui ajouter plusieurs réseaux et ensuite le run
docker network connect MonReseauBackEnd MonConteneur
Pour terminer avec les réseaux Docker, on a aussi la possibilité, que de base, toutes les communications entre les conteneurs soient bloquées.
Pour cela, on peut éditer notre démon docker en ajoutant :
{ "icc" : false }
A ce moment-là, nos conteneurs ne pourront plus communiquer entre eux.
Si on souhaite tout de même que certains d’entre eux communiquent, on peut utiliser l’argument – -link en indiquant le nom d’un conteneur.
docker run -tid --name monConteneur --link Conteneur2 alpine:latest
La gestion des ressources
Il est important d’imposer une limitation à la consommation de ressources à l’intérieur des conteneurs.
Je vous recommande de ne pas utiliser l’argument suivant :
--cgroup-parent
Cet argument partage les ressources avec l’hôte, ce qui est une très mauvaise chose car en cas d’attaque DOS, votre hôte sera impacté.
On cherche toujours à isoler nos conteneurs de notre hôte.
A la place, on va pouvoir définir une limitation des ressources avec la commande suivante :
Docker run -tid --cpu=1 --memory=256m alpine:latest
On peut ajouter à cela un nombre maximum de restart avec l’argument – – restart ou bien définir des ulimit qui permettrons par exemple de limiter le nombre d’ouvertures d’un fichier ou bien le nombre de process pour un utilisateur spécifique.
La gestion des volumes
Lorsqu’on monte un volume dans notre conteneur, il est important de définir par avance son utilité pour savoir quel type de volume choisir et quels droits lui accorder (RO/RW).
On peut monter un volume en ReadOnly (RO)
-v /tmp/tmp:RO
On peut utiliser tmpfs pour créer un volume qui sera stocké dans la mémoire de l’hôte puis monter dans le conteneur, ce qui impliquera que lorsqu’on arrête notre conteneur, le volume sera supprimé.
TMPFS sera utile pour stocker des fichiers temporaires ou bien stocker des fichiers de configuration sensibles, par exemple.
A cela peuvent s’ajouter des arguments pour contrôler ses droits, en voici quelques exemples ci-dessous :
--tmpfs /perso:rw:noexec,nosuid
Docker possède aussi l’option read only que je trouve très utile.
Elle permet de mettre tout le root file system (/) de l’intérieur du conteneur en read-only, ce qui est vraiment pratique !
On pourra tout de même mettre des fichiers en écritures si on monte par exemple un volume en RW à l’intérieur.
--read-only
Les capabilities
Présentation des capabilities
Les capabilities (ou capacités en français) sont un sujet majeur quand on parle de la sécurité avec Docker, c’est pour cela qu’il faut s’assurer d’en comprendre, même simplement, le fonctionnement.
Lors de la sécurité de l’Hôte, je vous ai évoqué le kernel et les différents appels systèmes en parlant de Seccomp.
Pour rentrer un peu plus dans le détail, le kernel est formé d’une multitude de privilèges qui représentent des droits spécifiques pour des actions spécifiques.
Afin d’éviter de donner trop de droits d’un coup, les capabilities vont nous permettre de prendre un petit groupe de droits spécifiques, comme par exemple la capacité Network qui nous permettra d’avoir des autorisations pour envoyer et recevoir des paquets d’un destinataire.
Docker, par défaut, garde 14 capabilities qu’il applique aux conteneurs :
Nom de la capacité | Utilité |
---|---|
CHOWN | Make arbitrary changes to file UIDs and GIDs |
DAC_OVERRIDE | Discretionary access control (DAC) – Bypass file read, write, and execute permission checks |
FSETID | Don’t clear set-user-ID and set-group-ID mode bits when a file is modified; set the set-group-ID bit for a file whose GID does not match the file system or any of the supplementary GIDs of the calling process. |
FOWNER | Bypass permission checks on operations that normally require the file system UID of the process to match the UID of the file, excluding those operations covered by CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH. |
MKNOD | Create special files using mknod(2) |
NET_RAW | Use RAW and PACKET sockets; bind to any address for transparent proxying. |
SETGID | Make arbitrary manipulations of process GIDs and supplementary GID list; forge GID when passing socket credentials via UNIX domain sockets; write a group ID mapping in a user namespace. |
SETUID | Make arbitrary manipulations of process UIDs; forge UID when passing socket credentials via UNIX domain sockets; write a user ID mapping in a user namespace. |
SETFCAP | Set file capabilities. |
SETPCAP | If file capabilities are not supported: grant or remove any capability in the caller’s permitted capability set to or from any other process. |
NET_BIND_SERVICE | Bind a socket to Internet domain privileged ports (port numbers less than 1024). |
SYS_CHROOT | Use chroot(2) to change to a different root directory. |
KILL | Bypass permission checks for sending signals. This includes use of the ioctl(2) KDSIGACCEPT operation. |
AUDIT_WRITE | Write records to kernel auditing log. |
Vous pouvez d’ailleurs le vérifier en lançant un conteneur avec la commande suivante :
docker run --rm --tid alpine:latest apk add -U libcap; capsh --print
Pour voir toutes les capabilities, vous pouvez aller regarder du côté du MAN
Comment les utiliser avec Docker
Docker nous offre la possibilité d’ajouter ou de supprimer des capabilities dans un conteneur de manières très simple !
Pour ajouter :
--cap-add=DAC_OVERRIDE
Pour supprimer :
--cap-drop=CHOWN
Pour tout supprimer
--cap-drop=ALL
⚠️ Je vous recommande de ne pas utiliser la capacité SYS_ADMIN qui en réalité en contient plusieurs autres (de capacités). Il vaut mieux à ce moment-là ajouter uniquement celles dont on aura besoin.
A la place, je vous recommande de faire un cap-drop=ALL puis d’ajouter manuellement les capacités nécessaires.
Les privilèges
Élévation de privilèges
Ahhh, le sujet épineux des privilèges sur Docker.
Avant toute chose, il faut savoir que Docker possède par défault un système d’élévation de privilège à l’intérieur des conteneurs.
Si un process a besoin de privilège pour fonctionner, il peut alors faire une demande d’élévation de privilège à Docker.
Je vous propose une démonstration que j’ai trouvée sur le blog de raesene
Pour notre démonstration, on créer un Dockerfile
FROM Ubuntu:latest #On copie le bash de notre hôte sur le conteneur RUN cp /bin/bash /bin/newbash && chmod 4755 /bin/newbash #On ajoute un utilisateur RUN useradd -ms /bin/bash bochi #On défini l'utilisateur par défaut comme étant bochi USER bochi CMD ["/bin/bash"]
Une première chose, c’est l’instruction user qui est une bonne pratique et que je vous recommande de mettre dans tous vos Dockerfile pour éviter que l’utilisateur par défaut soit Root.
Si on lance un conteneur avec cette image et qu’on lance /bin/newbash -p (le -p pour privileged qui va nous permettre de faire une demande d’élévation de privilège avec le bash) on voit qu’on passe en root.
On peut désactiver l’élévation de privilège lorsqu’on lance un conteneur en indiquant :
--security-opt=no-new-privileges:true
Ou bien par défaut dans le daemon docker :
{ "no-new-privileges": true }
On peut refaire le test avec cette option de sécurité activée :
On reste bien avec notre shell utilisateur.
L’argument privileged
Il serait impensable de parler de privilèges sans parler de l’argument –privileged
On a vu les capabilities dans le chapitre précédent, l’argument privileged englobe toutes les capabilities.(oui oui toutes)
l’effet de bord le plus connu et le plus simple à exploiter lorsqu’on a un conteneur qui se présente à nous avec privileged, c’est qu’on peut monter des devices à l’intérieur.
Un challenge existe sur root-me d’ailleurs que je vous recommande de le faire si vous souhaitez en savoir plus sur l’exploitation :).
Pour la petite demo, un conteneur sans le flag privileged :
Un conteneur avec le flag privileged :
Pour en terminer, on voit très souvent que les personnes qui l’utilisent le font pour rapidement monter des devices dans leur conteneur.
A la place, on peut utiliser – – device suivi du device qu’on souhaite monter.
On aura exactement le même resultat mais avec uniquement le device qu’on souhaite, donc plus secure !
Les namespaces
Les namespaces représentent également un gros sujet concernant la sécurité avec Docker.
Théorie simple des namespaces
L’utilité des namespaces est d’isoler les conteneurs qui seront lancés.
On va mettre nos conteneurs dans un environnement dans lequel les process de nos conteneurs ne pourront interagir avec les autres.
Si on doit retenir une seule chose des namespaces : c’est une solution qui va permettre de remap les PID (Process ID) à l’intérieur de nos conteneurs de telle sorte à ce que ce ne soit pas les mêmes PID que ceux qui sont à l’extérieur du conteneur. Simple ? 🙂
Explications simples et au top des namespaces sur Wikipedia
Les namespaces pour les conteneurs
Démonstration
Plutôt que de la théorie pompeuse sur les namespaces, on peut se faire une petite démonstration pour en saisir l’utilité.
On voit ici que vais créer un fichier dans /etc sur mon hôte et mettre du texte dedans.
je lance un conteneur, je monte mon /etc à l’intérieur du conteneur et on peut voir que j’ai le droit de le supprimer…
Pour remédier à ça, il faut éditer le démon Docker.
On créer un utilisateur sur notre hôte et on rajoute ceci à notre fichier de configuration :
{ "userns" : "Monutilisateur" }
A savoir : si à la place d’un nom d’utilisateur je mets default, alors docker créera un utilisateur par défaut qui se nommera dockremap
Une fois cette manipulation faîte, on relance Docker et ses services…
On va récupérer l’ID de notre utilisateur afin de vérifier que Docker et ses process en seront bien des enfants.
Pour cela :
#On récupère l'UID cat /etc/subuid #On récupère le GID cat /etc/subgid
Dans mon cas on voit que l’UID est 231072
On peut vérifier que Docker tourne bien dans un namespace avec la commande suivante :
#Vous remplacerez les 231072 par vos ID si vous avez choisis un utilisateur spécifique plutôt que Dockrmap sudo ls -l /var/lib/docker/231072.231072/
On peut afficher nos namespaces avec la commande lsns, à bien lancer en sudo.
Maintenant qu’on a activé nos namespaces, on refait le test :
Permission denied !
Remarquez comme on ne voit plus le user ni le groupe quand on fait un ls du fichier.
En résumé, pour mettre en place nos namespaces : on ajoute juste la ligne dans le démon Docker, on peut laisser default pour qu’il crée automatiquement l’utilisateur et on redémarre Docker et ses services, simple !
D’autres pistes intéréssantes pour compléter cette introduction
On pourrait améliorer cet article en rajoutant pas mal de choses concernant la sécurité des conteneurs avec Docker… Ce sera peut-être le sujet de futurs articles sur le blog
Je vous conseille de regarder également la sécurité des registry (mettre de l’authentification & TLS au minimum…), de même pour le démon Docker ;).
Il y a aussi beaucoup d’outils OpenSource tels que trivy pour scanner les vulnérabilités dans vos images Docker, ça peut être intéressant de regarder de ce côté et pourquoi pas l’intégrer à vos pipelines CI/CD ?
Un autre scanner pratique, c’est GGSHIELD qui vérifie si vous n’avez pas laissé de secrets dans vos images.
Enfin, je vous recommande de monitorer un minimum vos conteneurs, vous pouvez jeter un coup d’oeil à la stack ELK qui se prête bien à l’exercice et reste l’une des plus populaires bien qu’il existe d’autres alternatives.
Je vous mets ici une série de sites intéressants si vous souhaitez creuser le sujet ou en savoir plus :
Red hat Container Security Guide
SYSDIG : Top 20 Dockerfile best practices
La super cheat sheet de GitGuardian
Où s’entrainer à la sécurité avec Docker ?
Vous souhaitez vous entraîner sur la sécurité avec Docker, mais vous ne savez pas par où commencer ?! Ca tombe bien je vais essayer de vous donner quelques pistes ! 🙂
Je vous conseille de commencer par les labs de TryHackMe afin d’apprendre les bases et d’avoir une bonne compréhension de la sécurité de base des conteneurs.
Vous pouvez ensuite passer faire un tour sur root-me dans les challenges App-Script vous trouverez des chall Docker fait par Nishacid ou bien encore dans réaliste avec SSHOCKER de Laluka.
En parlant de réaliste, TryHackMe possède aussi des box avec Docker, vous pouvez chercher Docker ICI et trouver votre bonheur !
Conclusion
Cet article reste une introduction et il y a beaucoup d’autres choses qu’on peut retrouver sur Docker en termes de sécurité ou bien d’autres choses qui tourneront autours.
J’espère qu’il vous aura plu autant que j’ai pris du plaisir à le construire ainsi qu’à faire cette conférence.
Faites-moi savoir si d’autres sujets pourraient vous plaire à l’avenir ! 🙂