Docker Security
Attack surface, image supply-chain integrity, and runtime isolation — the parts of running Docker that hand an attacker full host root on the first mistake if they're skipped. Each major section below ends with a quick check; track your progress as you go.
1. Attack Surface
graph LR
A[Image Supply Chain] --> B[Registry]
B --> C[Runtime]
C --> D[Network]
D --> E[Host Escape]
A -- "malicious base image" --> A1[Compromised layers]
B -- "no content trust" --> B1[Tampered image pull]
C -- "privileged container" --> C1[Host kernel access]
D -- "ICC enabled" --> D1[Container pivoting]
E -- "docker.sock mount" --> E1[Full host root]
In the attack surface chain above, which single link turns a compromise into full host root — not just container-level access?
docker.sock mount (Host Escape → Full host root). Full Docker API access from inside a container lets an attacker ask the daemon — which runs as root — to start a brand-new, privileged container with the host filesystem mounted in. Every other link in the chain degrades security; this one hands over the whole host.
2. Rootless Docker: UID Remapping
Docker runs containers as root by default. Rootless mode remaps UIDs via user namespaces.
root on the host, and by default a container's uid 0 is the host's real uid 0. A container breakout — a kernel bug, a bad mount, an over-granted capability — lands the attacker as actual root on the host, with no extra step required.
uid 0 is just another unprivileged account from the host's point of view — see the mapping below. A breakout still lands somewhere, but "somewhere" is a non-root host account, not root.
Container UID 0 → Host UID 100000
Container UID 1 → Host UID 100001
Container UID 999 → Host UID 100999
Formula: host_uid = subordinate_uid_start + container_uid
Default subordinate range in /etc/subuid:
dockremap:100000:65536
So container uid 0 = host uid 100000 — never actual root on host.
graph LR
subgraph Container Namespace
C0[uid 0 root]
C1[uid 1 daemon]
C999[uid 999 app]
end
subgraph Host Namespace
H0[uid 100000 unprivileged]
H1[uid 100001 unprivileged]
H999[uid 100999 unprivileged]
end
C0 --> H0
C1 --> H1
C999 --> H999
Enable rootless:
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
With the default subuid range dockremap:100000:65536, a process inside a rootless container runs as uid 0. What uid does it actually run as on the host?
uid 100000 — an ordinary unprivileged account, never the host's real root. host_uid = subordinate_uid_start + container_uid, so container uid 0 maps to 100000 + 0.
3. Docker Socket Danger
/var/run/docker.sock grants full Docker API access = root on host.
The escape:
# Attacker inside a container with socket mounted:
docker -H unix:///var/run/docker.sock run -it \
--rm --privileged \
-v /:/host \
alpine chroot /host sh
# Result: root shell on the host
-v /var/run/docker.sock:/var/run/docker.sock — usually done so the container can "check build status" or orchestrate sibling containers.
docker commands on the host itself.
--privileged and the host's / bind-mounted in — a request the daemon has no reason to refuse, since it can't distinguish "a legitimate build script" from "an attacker."
chroot /host sh makes the new container's shell treat the mounted host root as its own root filesystem.
Never mount the socket in untrusted containers. If CI/CD needs it, use:
docker-socket-proxy with read-only ACLs. Put a proxy in front of the real socket that exposes only a whitelisted, read-only subset of the Docker API — a compromised build container can check container status but can't ask for a new --privileged container with a host mount.
A container has the Docker socket mounted, but nobody passed it --privileged. Is it still a full host-root risk?
--privileged and a host mount." The attacker doesn't need to already be privileged — they just ask the daemon, which is root, to start something that is.
4. Image Scanning with Trivy
# Scan an image
trivy image nginx:latest
# Fail CI on CRITICAL or HIGH
trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:latest
# Scan filesystem (in CI before build)
trivy fs --severity CRITICAL,HIGH .
CVE severity levels:
| Level | CVSS Score | Action |
|---|---|---|
| CRITICAL | 9.0–10.0 | Block immediately |
| HIGH | 7.0–8.9 | Block in CI |
| MEDIUM | 4.0–6.9 | Track / schedule fix |
| LOW | 0.1–3.9 | Informational |
The commands above are really one pipeline, run in order:
trivy fs --severity CRITICAL,HIGH . checks dependencies and source before an image even gets built — catches a vulnerable library before you spend time building on top of it.
docker build / buildx build step, unchanged by scanning.
trivy image myapp:latest inspects every layer of the finished image, not just what you wrote — this is where a vulnerable base image or a transitively pulled-in package shows up.
--exit-code 1 --severity CRITICAL,HIGH makes Trivy exit non-zero the moment it finds anything at or above that threshold.
CI pipeline block:
# GitHub Actions
- name: Scan image
run: trivy image --exit-code 1 --severity CRITICAL,HIGH $IMAGE
5. Content Trust: cosign + Sigstore
Keyless signing with Sigstore (no long-lived keys, uses OIDC identity):
# Sign after push (keyless via Sigstore Fulcio CA)
cosign sign --yes ghcr.io/myorg/myapp:v1.0.0
# Verify on pull
cosign verify \
--certificate-identity-regexp="https://github.com/myorg/myapp" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:v1.0.0
graph TD
A[Developer pushes image] --> B[cosign sign]
B --> C["Fulcio CA issues cert<br/>via OIDC token"]
C --> D["Signature stored<br/>in Rekor transparency log"]
D --> E[Consumer: cosign verify]
E --> F{"Cert matches<br/>expected identity?"}
F -- yes --> G[Pull allowed]
F -- no --> H[Pull rejected]
Step through the same flow one stage at a time:
ghcr.io/myorg/myapp:v1.0.0 as normal — nothing signature-related has happened yet.
cosign sign --yes triggers Sigstore's Fulcio CA to issue a short-lived certificate bound to the signer's OIDC identity (e.g. the exact GitHub Actions workflow that ran) — no long-lived private key ever touches disk.
--certificate-identity-regexp and --certificate-oidc-issuer — and cosign checks the stored certificate against those constraints, not against a static public key.
Keyless cosign signing skips managing a private key. What actually stops an attacker from just signing a malicious image themselves?
cosign verify checking the certificate's identity against an expected one (--certificate-identity-regexp / --certificate-oidc-issuer). A signature from the wrong identity is rejected even though it's cryptographically valid.
6. Runtime Hardening
docker run \
--cap-drop ALL \ # drop all Linux capabilities
--cap-add NET_BIND_SERVICE \ # add back only what's needed
--security-opt no-new-privileges \ # prevent privilege escalation via setuid
--security-opt seccomp=seccomp.json \ # restrict syscalls
--read-only \ # immutable filesystem
--tmpfs /tmp \ # writable scratch space
--user 1000:1000 \ # non-root user
myapp:latest
Key capabilities to never grant:
SYS_ADMIN— nearly equals rootNET_ADMIN— reconfigure host networkingSYS_PTRACE— inspect/modify other processes
Default seccomp profile blocks ~44 syscalls including ptrace, mount, kexec_load.
A container is run with --read-only but no --tmpfs. What's the most likely visible symptom?
/tmp, a cache directory, a lock file — because the entire filesystem is immutable. --tmpfs /tmp carves out a writable, in-memory scratch space so the app still has somewhere to write without weakening the read-only guarantee on the rest of the image.
7. BuildKit Secrets (Never in Layer)
# WRONG — secret baked into image layer
RUN curl -H "Authorization: Bearer $TOKEN" https://api.example.com
# CORRECT — secret mounted at build time, not in layer
RUN --mount=type=secret,id=mysecret \
TOKEN=$(cat /run/secrets/mysecret) && \
curl -H "Authorization: Bearer $TOKEN" https://api.example.com
# Build with secret
docker buildx build \
--secret id=mysecret,src=.env \
-t myapp:latest .
Secret is never in:
- Image layers
docker history- The build cache
Using RUN --mount=type=secret,id=mysecret, does the secret ever show up in docker history or the final image layers?
RUN instruction and is never written into a layer, docker history, or the build cache — unlike baking it in via a plain curl command with the token inline, which persists it in the image forever.
8. Network Hardening
Disable ICC (inter-container communication):
// /etc/docker/daemon.json
{
"icc": false,
"iptables": true
}
With --icc=false, containers on the default bridge cannot talk to each other unless explicitly linked.
Use custom networks:
# Only containers on the same named network can communicate
docker network create --driver bridge app-net
docker run --network app-net myapp
docker run --network app-net mydb
Never --network host in production:
- Container shares host network stack
- Bypasses all network isolation
- A compromised container can sniff all host traffic
Three mutually exclusive ways containers end up isolated (or not) from each other, side by side:
icc=false blocks container-to-container traffic on it unless explicitly linked. Cheap and daemon-wide, but coarse — it's an all-or-nothing switch for the whole default bridge, not per pair of containers.
docker network create --driver bridge app-net) can reach each other; containers on a different network, or none, can't reach them at all. This is the finer-grained tool — group only the containers that actually need to talk, per application, instead of one global on/off switch.
graph TD
subgraph Safe: Custom Network
A1[app container] -- allowed --> B1[db container]
A1 -- blocked by icc=false --> C1[other container]
end
subgraph Dangerous: host network
A2[container] -- direct access --> B2[host eth0]
B2 --> C2[sniff all traffic]
end
Two containers sit on the same named custom network (app-net). Does setting icc=false in daemon.json block their traffic too?
icc only governs the default bridge network. Containers you explicitly put on the same custom network can always reach each other — that's the whole point of creating it — regardless of the icc setting.