DevSecOps Guides

DevSecOps Guides

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.

Reza's avatar
Reza
Mar 20, 2026
∙ Paid

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

  1. Dockerfile Without USER Directive

  2. Using :latest or Untagged Base Images

  3. Secrets Baked into Image Layers

  4. Bloated Images with Unnecessary Packages

  5. No Vulnerability Scanning in Pipeline

  6. Build Cache Leaking Sensitive Data

  7. Dockerfile Without HEALTHCHECK

  8. Using ADD Instead of COPY

  9. Unnecessary EXPOSE and Port Binding

Image Trust and Supply Chain

  1. Missing Image Signatures (Cosign)

  2. No SBOM Attached to Images

  3. Container Registry Public Access

  4. Mutable Image Tags in Registry

  5. ImagePullPolicy Misconfiguration

  6. Missing Registry Authentication

  7. ECR Missing Scanning and Lifecycle

  8. Missing Build Provenance (SLSA)

  9. No Dockerfile Linting in CI

Image Hardening and Analysis

  1. Multi-Stage Build Secret Leakage

  2. Base Image Typosquatting

  3. Image Analysis with Docker Scout

  4. Deep Image Scanning with Grype and Trivy

  5. Hardening with Distroless Images

  6. Building from Scratch

  7. Missing Read-Only Root Filesystem

  8. Missing no-new-privileges Flag

  9. Image Size and Attack Surface

Admission Control and Pipelines

  1. Kyverno Image Admission Policies

  2. OPA Gatekeeper Image Constraints

  3. Harbor Registry Security Configuration

  4. BuildKit Security Features

  5. Container Image Diff and Forensics

  6. OCI Artifacts and Referrers API

  7. Image Vulnerability Management at Scale

  8. 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:

  • groupadd and useradd create a dedicated user with a specific UID/GID

  • --shell /bin/false prevents interactive login

  • COPY --chown=appuser:appgroup sets file ownership at copy time (no extra RUN chown layer)

  • USER appuser switches 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 :latest for simplicity

  • Developers 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.


Lab 03 — Secrets Baked into Image Layers

User's avatar

Continue reading this post for free, courtesy of Reza.

Or purchase a paid subscription.
© 2026 Reza · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture