L49 · Container security
Module 7: Cloud, Containers, and Kubernetes · ~20 min · Lesson 49 of 71
🎯 What you'll learn
- How image layers work, and why a file you deleted in a Dockerfile is still in the image
- Why containers should run as a non-root user, and what minimal and distroless images remove
- What image scanners (Trivy, Grype, ECR scanning) find, and what they can't
- How pinning by digest and signing an image prove which bytes run and who built them
🔥 Why this matters
A developer COPYs a settings.xml holding a private Maven repository token into the
statements-svc image and rms it two lines later. The image goes to ECR. Anyone who can pull it
(a vendor scanner, an attacker with a leaked read-only role) can recover that token in a minute.
This isn't rare: a 2023 RWTH Aachen study of about 345,000 images, mostly from Docker Hub, found
secrets (largely private keys and API keys) in about 8.5% of them.
1. An image is a stack of layers, and nothing is ever deleted
A container image is a set of read-only layers plus a small config file. Each Dockerfile
instruction that changes files (RUN, COPY, ADD) adds a layer: a tar file of what changed.
At start, the runtime stacks them into one view, with a thin writable layer on top.
Dockerfile line layer contents
FROM maven:3.9-eclipse-temurin-21 ───▶ L1-L6 OS, JDK, Maven
COPY settings.xml /root/.m2/ ───▶ L7 settings.xml (TOKEN)
RUN mvn package ───▶ L8 target/*.jar
RUN rm /root/.m2/settings.xml ───▶ L9 "whiteout" marker
what `ls` shows in the container: settings.xml is gone
what `docker save` / a registry
pull gives anyone: L7 still holds the token
A deletion is just a whiteout: a marker in a later layer that hides the file from the merged
view. The bytes stay in the earlier layer, which ships with the image. The same goes for build
arguments: Docker's docs warn that ARG values used in a RUN are visible in docker history
and in provenance attestations, so ARG NEXUS_TOKEN leaks too.
Keep secrets out of layers with BuildKit secret mounts (RUN --mount=type=secret,...
exposes a secret to one command without writing it to a layer), multi-stage builds (only the
final stage ships), and a .dockerignore (COPY . . otherwise copies .git/ and .env). A
secret that reached a pushed image is leaked: rotate it (L19).
2. Root in the container is root on the kernel
A container isn't a small VM. It's an ordinary Linux process that the kernel fences in with namespaces (what it can see) and cgroups (how much it can use). Every container on a node shares that node's kernel (first deep dive).
Unless the Dockerfile says otherwise, the process runs as root (UID 0): without user namespaces, the same UID 0 the host kernel knows. The runtime removes some of root's powers (capabilities), but if a kernel or runtime bug lets the process out, it lands on the node as root.
container as root (default) container as UID 10001
┌────────────────────────────┐ ┌────────────────────────────┐
│ RCE in statements-svc │ │ RCE in statements-svc │
│ uid=0: rewrite the app, │ │ uid=10001: can't modify │
│ install tools (if a pkg │ │ app files or install │
│ manager exists) │ │ packages │
└─────────────┬──────────────┘ └─────────────┬──────────────┘
│ runtime bug (e.g. CVE-2019-5736) │
▼ ▼
escape lands as host root escape lands as an
on the EKS node: every pod's unprivileged user
secrets, the node's IAM role (still bad, much less)
CVE-2019-5736 did exactly this: root inside a container could overwrite the host's runc
binary and gain host root. The fix is one line, USER 10001. Use a numeric UID: Kubernetes'
runAsNonRoot: true (L51) can only verify a number, and refuses an image whose user is a name like
appuser. Listen on a port above 1024, since low ports need extra privileges.
3. Ship less: minimal and distroless base images
Everything in the image is something to patch and something an attacker can use. A full OS image
brings a shell, curl, a package manager and hundreds of packages your service never calls.
- Slim images (
eclipse-temurin:21-jre,python:3.12-slim) drop build tools and docs. - Alpine is tiny but uses musl instead of glibc, which occasionally breaks native libraries.
- Distroless images (
gcr.io/distroless/*) hold only the runtime and its libraries: no shell, no package manager. Their:nonroottags run as UID 65532. Chainguard offers similar images.
Be precise about what this buys you. "No shell" doesn't stop exploitation: Log4Shell (L36) ran
attacker code inside the JVM. It makes the next steps harder (no sh, no curl), and it cuts
the scanner's CVE list sharply. The cost is debugging: use kubectl debug instead of shelling in.
4. Scan the image, and keep scanning it
An image scanner lists the packages in every layer (OS packages and language packages such as jars and wheels) and matches them against CVE databases.
| Tool | Where it runs | Notes |
|---|---|---|
| Trivy (Aqua, open source) | Laptop, CI | OS and language packages, also IaC and secrets |
| Grype (Anchore, open source) | Laptop, CI | Pairs with Syft, which makes the SBOM |
| ECR basic scanning | On push or on demand | OS packages |
| ECR enhanced scanning | Continuous | Uses Amazon Inspector; OS and language packages, rescans as new CVEs appear |
Scan in CI (fail on fixable CRITICAL/HIGH), on push, and continuously: an image clean on Monday gains CVEs on Tuesday without changing a byte. Scan what's running, not just the repo (L36's deep dive).
Know the limits. A scanner only finds known CVEs in packages it can identify. It doesn't find bugs in your code, a root user, or (unless secret scanning is on) a leaked token.
5. Prove which bytes run, and who built them
A tag like statements-svc:1.4.2 is a movable label. Pinning by digest (@sha256:...)
names the exact bytes; L14's deep dive covers why and how. But a digest only proves sameness.
If an attacker pushes their own image, its digest is perfectly valid too.
Image signing answers "who built this?". The build pipeline signs the image digest with a private key; the cluster verifies the signature with the public key before running it (L17).
CI pipeline registry (ECR) cluster
┌──────────────┐ 1. push ┌──────────────┐ ┌──────────────┐
│ build image │ ─────────▶ │ image │ 3.pull │ admission │
│ get digest │ │ sha256:9f74 │ ─────▶ │ check (L51): │
│ sign digest │ 2. push │ signature │ │ signed by │
│ (KMS key or │ ─────────▶ │ for 9f74 │ │ OUR CI? │
│ keyless) │ └──────────────┘ │ yes: run │
└──────────────┘ │ no: reject │
└──────────────┘
Two common toolchains: cosign (from Sigstore) signs with a key, which can live in AWS KMS, or keyless with the CI job's OIDC identity (second deep dive). Notation (the CNCF Notary Project) signs with X.509 certificates, and AWS Signer has a Notation plugin for ECR.
A signature nobody checks protects nothing. Verify it at admission (L51), and name the expected signer, not just "any valid signature".
🧪 Worked example: hardening the statements-svc Dockerfile
Before:
FROM maven:3.9-eclipse-temurin-21 # (4) moving tag
WORKDIR /app
COPY . . # (3) .git, .env too
COPY settings.xml /root/.m2/settings.xml # (1) token in a layer
RUN mvn -B package -DskipTests
RUN rm /root/.m2/settings.xml # (1) whiteout only
EXPOSE 8080
CMD ["java", "-jar", "target/statements-svc.jar"]
# (2) runs as root, with
# JDK, Maven, shell, apt
After:
# syntax=docker/dockerfile:1
FROM maven:3.9.9-eclipse-temurin-21@sha256:<digest> AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=secret,id=mvn,target=/root/.m2/settings.xml \
--mount=type=cache,target=/root/.m2/repository \
mvn -B package -DskipTests
FROM gcr.io/distroless/java21-debian12:nonroot@sha256:<digest>
WORKDIR /app
COPY --from=build /src/target/statements-svc.jar app.jar
USER 65532:65532
EXPOSE 8080
CMD ["app.jar"]
Built with docker build --secret id=mvn,src=$HOME/.m2/settings.xml .. What changed:
- The token is a secret mount: present while
mvnruns, in no layer or history. - The runtime stage is distroless; only
pom.xml,src/and the jar are copied. - Both bases are pinned by digest (placeholder shown); Renovate keeps pins fresh.
USERis numeric (the:nonroottag sets it too; explicit is better).CMD ["app.jar"]: the distroless Java image's entrypoint is alreadyjava -jar.
CI then scans the image and signs its digest before anything deploys it.
🛠 Try it (optional, ~5 min)
Prove to yourself that rm doesn't remove anything. Local Docker (or Podman) only, fake data.
mkdir -p ~/tmp/l49 && cd ~/tmp/l49
cat > Dockerfile <<'EOF'
FROM alpine:3.22
RUN echo "token=FAKE-not-a-real-secret" > /tmp/creds.txt
RUN rm /tmp/creds.txt
EOF
docker build -q -t layer-demo .
docker run --rm layer-demo cat /tmp/creds.txt # No such file
docker save layer-demo -o demo.tar
mkdir -p x && tar -xf demo.tar -C x
find x -type f | while read -r f; do
tar -tf "$f" 2>/dev/null | grep -q 'creds.txt' && echo "$f" \
&& tar -tf "$f" | grep creds
done
You should see two layers: one with tmp/creds.txt, and a later one with tmp/.wh.creds.txt,
the whiteout. Print the "deleted" file with tar -xOf <first-layer-path> tmp/creds.txt. (Newer
Docker saves layers under blobs/sha256/, older under <id>/layer.tar; the loop finds either.)
Clean up with rm -rf ~/tmp/l49.
🧠 Key takeaways
- Layers are append-only: a file deleted in a later layer still ships in the earlier one.
- Use BuildKit secret mounts and multi-stage builds; never
COPYorARGa secret. - Run as a numeric non-root
USER; root in a container is UID 0 on a shared kernel. - Minimal or distroless images cut both the attacker's toolkit and the CVE noise.
- Scan in CI, on push, and continuously; pin by digest; sign the digest and verify at deploy.
📖 Words to know
| Term | Meaning |
|---|---|
| Image layer | A tar file of filesystem changes made by one Dockerfile instruction |
| Whiteout | A marker in a later layer that hides a file without removing its bytes |
| Multi-stage build | A Dockerfile with several FROM stages, where only the last one ships |
| Secret mount | A BuildKit feature that exposes a secret to one RUN without storing it in a layer |
| Distroless image | A base image with only the runtime and its libraries: no shell, no package manager |
| Digest | The SHA-256 hash that identifies an image's exact content |
| cosign / Notation | Tools that sign image digests and verify those signatures |
⏭ Next time
L50 opens up the cluster itself: who gets through the Kubernetes API server's front door, and how a pod proves who it is to Kubernetes and to AWS.
What a container really is: namespaces, cgroups and one shared kernel
The main lesson said a container is "just a process". This is what that means, and why it decides how much a container escape matters.
Three kernel features doing all the work
- Namespaces give a process its own view of something global: process IDs (
pid), network interfaces (net), mounted filesystems (mnt), hostname (uts), shared memory (ipc), and user IDs (user). In its ownpidnamespace, your Java process sees itself as PID 1. - cgroups limit and account for resources: CPU, memory, number of processes. Kubernetes
resources.limitsbecome cgroup settings, and the OOM killer enforces the memory one. - Capabilities split root's power into about 40 pieces (
CAP_NET_ADMIN,CAP_SYS_ADMIN,CAP_NET_RAW...). Container runtimes start root with a reduced default set. A seccomp profile can also block dangerous system calls.
virtual machines containers
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ app │ │ app │ │ app │ │ app │
│ libs │ │ libs │ │ libs │ │ libs │
│ KERNEL │ │ KERNEL │ └───┬────┘ └───┬────┘
└───┬────┘ └───┬────┘ namespaces, │ │
┌───┴──────────┴────┐ cgroups, ▼ ▼
│ hypervisor │ caps ┌───────────────────┐
└───────────────────┘ ───────▶│ ONE shared kernel │
boundary: hardware └───────────────────┘
virtualization boundary: kernel rules
A VM escape needs a hypervisor bug. A container escape needs a bug in the one kernel, or the
runtime (runc, containerd), that every pod on the node shares, or a misconfiguration that
hands the container host access (privileged mode, a mounted host path, the Docker socket).
Escapes that made the news
| CVE | Component | What happened |
|---|---|---|
| CVE-2019-5736 | runc | A root process in a container could overwrite the host's runc binary and get host root |
| CVE-2022-0185 | Linux kernel | A heap overflow in filesystem code; exploitable from a container where unprivileged user namespaces were allowed |
| CVE-2024-21626 ("Leaky Vessels") | runc before 1.1.12 | A leaked file descriptor let a malicious image or WORKDIR reach the host filesystem |
The pattern: the escape gives you whatever the container process was. As root without user
namespaces, that's host root. That's why USER, dropped capabilities and seccomp (L51 shows the
pod settings) matter even though they don't stop the first exploit. And it's why nodes need
patching (L45: on managed node groups that's your job).
Two Kubernetes facts worth knowing, both version-dependent: seccomp is unconfined by default
unless the pod asks for RuntimeDefault (or the kubelet's --seccomp-default is on), and
user namespaces for pods (hostUsers: false), which map container root to an unprivileged
host UID, are newer and need recent Kubernetes, kernel and runtime versions. Check your cluster
before relying on either.
🛠 Try it: look at your own containers (~3 min, local only)
docker run --rm alpine:3.22 sh -c 'id; grep CapEff /proc/self/status'
docker run --rm --user 10001 alpine:3.22 \
sh -c 'id; grep CapEff /proc/self/status'
docker run --rm alpine:3.22 ps # PID 1 is ps itself
docker run --rm alpine:3.22 uname -r # the host's kernel version
Typical output with Docker's defaults:
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),...
CapEff: 00000000a80425fb
uid=10001 gid=0(root) groups=0(root)
CapEff: 0000000000000000
a80425fb is a bitmask of Docker's default capabilities for root; the non-root process has none.
On macOS, uname -r shows the kernel of Docker Desktop's Linux VM: your containers share that
kernel, the same way pods share an EKS node's.
🧠 Extra takeaways
- Namespaces control what a container sees, cgroups what it uses, capabilities and seccomp what it may do.
- Every container on a node shares one kernel; that's the real isolation boundary.
- An escape inherits the container's user, so non-root turns a disaster into an incident.
- Kubernetes doesn't apply seccomp by default; L51 shows how to turn it on.
Keyless signing with Sigstore: proving which pipeline built an image
Signing with a long-lived key brings back L19's problem: the key must be stored, rotated and protected, and if it leaks, anyone can sign "as CI". Keyless signing replaces the stored key with the identity the CI job already has.
The three parts of Sigstore
- Fulcio, a certificate authority. It accepts an OIDC ID token (L24), such as the one GitHub Actions gives a job, and issues a certificate valid for about 10 minutes that binds a fresh, throwaway key pair to that identity.
- Rekor, a public, append-only transparency log. Every signing event is recorded there, with a timestamp, so a signature can be checked long after the short-lived certificate expires.
- cosign, the client that drives both and stores the signature next to the image in the registry.
GitHub Actions job (release.yml on main)
│ 1. request OIDC token: iss=token.actions.githubusercontent.com
▼
Fulcio ── 2. verify token, issue 10-min cert for an ephemeral
│ key, identity = repo + workflow + ref
▼
cosign ── 3. sign image DIGEST with ephemeral key; key discarded
│
├── 4. signature + cert ──▶ registry (next to the image)
└── 5. log entry ─────────▶ Rekor (public, timestamped)
verifier: cert chains to Fulcio? identity = expected workflow?
logged in Rekor while the cert was valid? ──▶ trust
Verifying: name the signer, exactly
Verification only means something if it pins who signed. For a made-up repository:
cosign verify \
--certificate-oidc-issuer \
https://token.actions.githubusercontent.com \
--certificate-identity \
"https://github.com/example-org/statements-svc/\
.github/workflows/release.yml@refs/heads/main" \
registry.example.com/statements-svc@sha256:<digest>
The identity is the workflow file and branch that ran. The classic mistake is a loose check, such
as --certificate-identity-regexp '.*' with only the GitHub issuer. Then any GitHub workflow,
in any public repo, can produce an image that passes. It's L24's IRSA lesson again: checking the
issuer without the subject trusts everyone that issuer serves.
Pin the branch too. A workflow on a feature branch, or one triggered from a fork's pull request, shouldn't be able to produce a "release" signature.
Choosing an option in a regulated company
| Option | Key to protect | Things to weigh |
|---|---|---|
| cosign + AWS KMS key | KMS key (never leaves KMS) | Simple, auditable in CloudTrail; IAM decides who can sign |
| cosign keyless, public Sigstore | None | Rekor is public: repo names and workflow paths become visible |
| Notation + AWS Signer | Managed by Signer | Native to ECR; X.509-based; verify with Ratify or Kyverno |
| GitHub artifact attestations | None | Uses Sigstore; GitHub documents a private instance for private repos (check current docs) |
The public-log point matters in regulated environments. Many teams sign with a KMS-held key, AWS Signer, or a private Sigstore deployment instead of the public instance.
What a signature still doesn't tell you
A valid signature says "our release pipeline produced this digest". It doesn't say the code was reviewed, the dependencies were safe, or the CI runner wasn't compromised (L56's SolarWinds case is exactly that). Signed attestations (an SBOM, build provenance under SLSA) carry more facts about how the image was built; L55 picks those up.
🧠 Extra takeaways
- Keyless signing swaps a stored signing key for CI's short-lived OIDC identity plus a transparency log.
- Always verify the exact identity (repo, workflow, branch) and issuer, never just "signed".
- The public Rekor log reveals signer identities; KMS keys, AWS Signer or private Sigstore avoid that.
- Signatures prove origin, not safety; pair them with scanning, review and provenance.
The quiz for this lesson and the Tests section need JavaScript.