Container Image Security Labs 2026
We wrote 35 hands-on labs covering every security problem we find in container images -- from Dockerfile misconfigurations through registry hardening to full supply chain verification.
Container Image Security Labs in 2026
Before we start, shoutout to a platform we built for YOU!
💎 Your next level in cybersecurity isn’t a dream, it’s a proactive roadmap.
HADESS AI Career Coach turns ambition into expertise:
→ 390+ clear career blueprints from entry-level to leadership
→ 490+ in-demand skill modules + practical labs
→ Intelligent AI(Not AI buzz, applied AI, promise!) tools + real-world expert coaches and scenarios
Master the skills that matter. Land the roles that pay. Build the future you want.
🔥 Start engineering your career → https://career.hadess.io
Each lab walks through a real vulnerability, shows you what an attacker does with it, and gives you the fix with verification steps.
The labs span five areas: Dockerfile and build security (root users, secrets in layers, bloated images, caching leaks), image scanning and analysis (Trivy, Grype, Docker Scout, dual-scanner approaches), image trust and supply chain (Cosign signing, SBOM generation, SLSA provenance, typosquatting), registry and runtime security (ECR, Harbor, pull policies, immutable tags, authentication), and admission control and pipelines (Kyverno, OPA Gatekeeper, complete GitHub Actions workflows).
We use real tools throughout -- hadolint, dockle, trivy, grype, cosign, syft, crane, dive, docker scout, conftest, kyverno, oras -- with actual command output and real check IDs. Every exploitation section shows what an attacker does in practice, not theory.
Table of Contents
Dockerfile and Build Security
Dockerfile Without USER Directive
Using :latest or Untagged Base Images
Secrets Baked into Image Layers
Bloated Images with Unnecessary Packages
No Vulnerability Scanning in Pipeline
Build Cache Leaking Sensitive Data
Dockerfile Without HEALTHCHECK
Using ADD Instead of COPY
Unnecessary EXPOSE and Port Binding
Image Trust and Supply Chain
Missing Image Signatures (Cosign)
No SBOM Attached to Images
Container Registry Public Access
Mutable Image Tags in Registry
ImagePullPolicy Misconfiguration
Missing Registry Authentication
ECR Missing Scanning and Lifecycle
Missing Build Provenance (SLSA)
No Dockerfile Linting in CI
Image Hardening and Analysis
Multi-Stage Build Secret Leakage
Base Image Typosquatting
Image Analysis with Docker Scout
Deep Image Scanning with Grype and Trivy
Hardening with Distroless Images
Building from Scratch
Missing Read-Only Root Filesystem
Missing no-new-privileges Flag
Image Size and Attack Surface
Admission Control and Pipelines
Kyverno Image Admission Policies
OPA Gatekeeper Image Constraints
Harbor Registry Security Configuration
BuildKit Security Features
Container Image Diff and Forensics
OCI Artifacts and Referrers API
Image Vulnerability Management at Scale
Complete Secure Image Pipeline (GitHub Actions)
Lab 01 — Dockerfile Without USER Directive
Field Value Lab Title Dockerfile Without USER Directive Risk Rating High MITRE ATT&CK T1611 — Escape to Host CIS Docker Benchmark 4.1 — Ensure a user for the container has been created Tools hadolint, dockle, docker inspect CVE Reference CVE-2019-5736 (runc container escape, requires root in container) Time to Complete 20 minutes
Writeup
When a Dockerfile has no USER directive, the container process runs as UID 0 — root. This is the Docker default, and it is a terrible default. Root inside the container can read shadow, install packages via the package manager, modify binaries on mounted volumes, and — in specific kernel/runtime configurations — escape to the host entirely.
CVE-2019-5736 demonstrated this concretely: a malicious container process running as root could overwrite the host runc binary through /proc/self/exe, gaining code execution on the host. The precondition was root inside the container. A non-root container user would have stopped the exploit cold.
We still see this pattern in production. Teams copy a Dockerfile from a tutorial, it works, nobody questions the privilege level. The container runs as root for months until an audit flags it.
Root Cause Analysis
The root cause is the absence of a USER directive in the Dockerfile. Docker defaults to UID 0 when no user is specified. Many base images (python, node, golang) ship without creating a non-root user, so unless the Dockerfile author explicitly creates one, everything runs as root.
Contributing factors:
Base images default to root
Local development “just works” with root — no permission errors
No CI gate checking for USER directive
Misconception that container isolation makes root safe
Vulnerable Configuration
# Dockerfile — no USER directive
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "app.py"]
Build and run it:
docker build -t vuln-root-app .
docker run -d --name root-demo vuln-root-app
Exploitation
Step 1 — Confirm we are root
docker exec root-demo whoami
Expected output:
root
docker exec root-demo id
Expected output:
uid=0(root) gid=0(root) groups=0(root)
Step 2 — Read sensitive files
docker exec root-demo cat shadow
Expected output (truncated):
root:*:19750:0:99999:7:::
daemon:*:19750:0:99999:7:::
bin:*:19750:0:99999:7:::
A non-root user would get Permission denied.
Step 3 — Install arbitrary tools
docker exec root-demo apt-get update -qq && docker exec root-demo apt-get install -y -qq nmap netcat-openbsd
Root can install reconnaissance and lateral movement tools at runtime. This turns the container into an attack platform.
Step 4 — Modify application binaries
docker exec root-demo cp /bin/bash /app/app.py
Root can overwrite the application with anything. No file ownership protects against UID 0.
Step 5 — Inspect with docker inspect
docker inspect --format '{{.Config.User}}' root-demo
Expected output:
Empty string — no user configured.
Detection
hadolint catches the missing USER directive:
hadolint Dockerfile
Expected output:
Dockerfile:1 DL3002 warning: Last USER should not be root
Note: hadolint flags DL3002 when the last USER is root or when no USER exists at all.
dockle checks CIS benchmarks against built images:
dockle vuln-root-app
Expected output:
WARN - CIS-DI-0001: Create a user for the container
* Last user should not be root
docker inspect for runtime verification:
docker inspect --format '{{.Config.User}}' vuln-root-app
Returns empty string if no user is set.
Trivy misconfiguration scan on the Dockerfile:
trivy config --severity HIGH,CRITICAL Dockerfile
Flags the missing USER directive as a misconfiguration.
Solution
# Dockerfile — fixed with non-root user
FROM python:3.12-slim
# Create a non-root user and group
RUN groupadd --gid 1001 appgroup && \
useradd --uid 1001 --gid appgroup --shell /bin/false --create-home appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files owned by the non-root user
COPY --chown=appuser:appgroup . .
# Drop to non-root before CMD
USER appuser
EXPOSE 8080
CMD ["python", "app.py"]
Key changes:
groupaddanduseraddcreate a dedicated user with a specific UID/GID--shell /bin/falseprevents interactive loginCOPY --chown=appuser:appgroupsets file ownership at copy time (no extraRUN chownlayer)USER appuserswitches to non-root for all subsequent instructions and the runtime CMD
For Kubernetes, enforce this at the pod level too:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
allowPrivilegeEscalation: false
Verification
docker build -t fixed-root-app .
docker run -d --name fixed-demo fixed-root-app
docker exec fixed-demo whoami
Expected output:
appuser
docker exec fixed-demo cat shadow
Expected output:
cat: shadow: Permission denied
docker inspect --format '{{.Config.User}}' fixed-root-app
Expected output:
appuser
hadolint Dockerfile
No DL3002 warning.
dockle fixed-root-app
CIS-DI-0001 no longer flagged.
Takeaway: Always set a non-root USER in your Dockerfile. It costs three lines and blocks an entire class of container escape attacks. Pair it with runAsNonRoot: true in Kubernetes to enforce the policy at orchestration level.
Lab 02 — Using :latest or Untagged Base Images
Field Value Lab Title Using :latest or Untagged Base Images Risk Rating Medium CIS Docker Benchmark 4.7 — Ensure update instructions are not used alone in the Dockerfile Tools hadolint, dockle, crane, docker inspect Related SLSA Supply Chain Levels, Sigstore cosign Time to Complete 20 minutes
Writeup
FROM python:latest and FROM node (no tag at all) are the same thing — they both resolve to the :latest tag, which is mutable. Today it points to Python 3.12.x. Next week it might point to 3.13.x. You have no control over what ends up in your image, and you cannot reproduce a previous build.
This is a supply chain problem. A mutable tag means every docker build can pull a different base image. If the upstream maintainer pushes a compromised or broken image to :latest, your next build silently inherits it. You won’t know until something breaks in production — or worse, until you find out during an incident.
Tags like 3.12 or 3.12-slim are better but still mutable. The maintainer can push a new 3.12-slim image with updated system packages at any time. The only immutable reference is a digest pin: FROM python:3.12-slim@sha256:abcdef....
Root Cause Analysis
The root cause is tag mutability in container registries. OCI registries allow tags to be overwritten — a tag is just a pointer to a manifest digest, and that pointer can change. Using a mutable tag in FROM means the build input is non-deterministic.
Contributing factors:
Tutorials and quickstarts use
:latestfor simplicityDevelopers copy FROM lines without thinking about pinning
No CI check for tag mutability
Perceived inconvenience of updating digest pins (solved by Renovate/Dependabot)
Vulnerable Configuration
# Dockerfile — mutable base image tags
FROM python:latest
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Or even worse — no tag at all:
FROM python
WORKDIR /app
COPY . .
CMD ["python", "app.py"]
Exploitation
Step 1 — Show that :latest resolves differently over time
crane digest python:latest
Expected output (example — this changes):
sha256:a1b2c3d4e5f6...
Run it again a week later and the digest will differ if the maintainer pushed an update.
Step 2 — Build the same Dockerfile twice, get different images
docker build --no-cache -t myapp:build1 .
# Wait for upstream to update :latest, or simulate by editing the FROM
docker build --no-cache -t myapp:build2 .
docker inspect --format '{{.RootFS.Layers}}' myapp:build1
docker inspect --format '{{.RootFS.Layers}}' myapp:build2
Different layer digests — different base images pulled silently.
Step 3 — Show the hidden CVE difference
trivy image python:latest --severity CRITICAL -q
Expected output (varies by date):
Total: 14 (CRITICAL: 14)
┌──────────────────┬────────────────┬──────────┬───────────────────┐
│ Library │ Vulnerability │ Severity │ Installed Version │
├──────────────────┼────────────────┼──────────┼───────────────────┤
│ libexpat │ CVE-2024-XXXXX │ CRITICAL │ 2.5.0-1 │
│ openssl │ CVE-2024-XXXXX │ CRITICAL │ 3.0.13-1 │
└──────────────────┴────────────────┴──────────┴───────────────────┘
The :latest full image carries hundreds of packages and dozens of CVEs. You inherit them all without knowing which version you got.
Step 4 — Compare image sizes (attack surface indicator)
crane manifest python:latest | jq '.config.size'
crane manifest python:3.12-slim | jq '.config.size'
The full :latest image is often 3-5x larger than slim variants. More packages means more attack surface.
Detection
hadolint flags :latest and missing tags:
hadolint Dockerfile
Expected output:
Dockerfile:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag
dockle on the built image:
dockle myapp:build1
Expected output:
WARN - DKL-DI-0006: Avoid latest tag
crane to retrieve the current digest of a tag:
crane digest python:3.12-slim
sha256:f5a1c8b9e7d2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0...
Use this digest in your Dockerfile pin.
Solution
# Dockerfile — pinned by digest
FROM python:3.12-slim@sha256:f5a1c8b9e7d2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER nobody
CMD ["python", "app.py"]
Get the real digest:
crane digest python:3.12-slim
# Use the output in your FROM line
Automate digest updates with Renovate:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"docker": {
"pinDigests": true
},
"packageRules": [
{
"matchDatasources": ["docker"],
"matchUpdateTypes": ["digest"],
"automerge": true,
"schedule": ["before 6am on Monday"]
}
]
}
Renovate detects new digests for pinned images and opens a PR automatically. You review the diff, merge, and your pin stays current.
Dependabot alternative (.github/dependabot.yml):
version: 2
updates:
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
Verification
hadolint Dockerfile
No DL3007 warning.
# Build twice — identical images
docker build --no-cache -t myapp:v1 .
docker build --no-cache -t myapp:v2 .
docker inspect --format '{{index .RepoDigests 0}}' myapp:v1
docker inspect --format '{{index .RepoDigests 0}}' myapp:v2
Both builds produce identical layer stacks because the base image is pinned to an immutable digest.
# Verify the digest matches what we pinned
crane digest python:3.12-slim@sha256:f5a1c8b9e7d2...
Returns the same digest — immutable.
dockle myapp:v1
No DKL-DI-0006 warning.
Takeaway: Pin base images by digest, not tag. Use Renovate or Dependabot to keep the pins current. This gives you reproducible builds and a clear audit trail of exactly which base image each build used.



