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.

0/0 checks

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?

2. Rootless Docker: UID Remapping

Docker runs containers as root by default. Rootless mode remaps UIDs via user namespaces.

The Docker daemon runs as 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.
The daemon, and everything it runs, executes as an unprivileged host user. User namespaces remap container UIDs onto a range of unprivileged host UIDs, so container 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?

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
1. Socket mounted. A container starts with -v /var/run/docker.sock:/var/run/docker.sock — usually done so the container can "check build status" or orchestrate sibling containers.
2. Full API access, no isolation. Anything inside that container can now talk to the host's Docker daemon directly, exactly as if it were running docker commands on the host itself.
3. Spawn a privileged container. The attacker asks the daemon to run a new container with --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."
4. chroot into the host filesystem. chroot /host sh makes the new container's shell treat the mounted host root as its own root filesystem.
5. Root shell on the host. Because the Docker daemon itself runs as root, every step above ran as root — the attacker now has an interactive root shell on the underlying host, not just the original container.

Never mount the socket in untrusted containers. If CI/CD needs it, use:

Docker-in-Docker (dind) with a sidecar. Run a full nested Docker daemon in its own sidecar container instead of sharing the host's socket. The build container talks only to its own, disposable daemon — a breakout there compromises the sidecar, not the host.
Kaniko or Buildah (daemonless builds). Build OCI images in userspace, inside the CI container itself, with no Docker daemon involved at all — no socket to mount, so this entire class of escape doesn't exist.
Socket proxies like 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?

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:

1. Scan the filesystem pre-build. 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.
2. Build the image. Normal docker build / buildx build step, unchanged by scanning.
3. Scan the built image. 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.
4. Compare against the severity gate. --exit-code 1 --severity CRITICAL,HIGH makes Trivy exit non-zero the moment it finds anything at or above that threshold.
5. CI passes or blocks. A non-zero exit code fails the CI step and blocks the merge/deploy — MEDIUM and LOW findings get tracked, but don't stop the pipeline.

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:

1. Push the image. Developer builds and pushes ghcr.io/myorg/myapp:v1.0.0 as normal — nothing signature-related has happened yet.
2. cosign sign (keyless). 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.
3. Signature logged in Rekor. The signature and certificate are recorded in Sigstore's Rekor transparency log — a public, append-only record that a signature for this exact image digest was made, by this identity, at this time.
4. Consumer runs cosign verify. The puller specifies the identity it expects — --certificate-identity-regexp and --certificate-oidc-issuer — and cosign checks the stored certificate against those constraints, not against a static public key.
5. Match → pull allowed; mismatch → rejected. Anyone can sign an image under their own OIDC identity — the security comes entirely from the verifier insisting on a specific identity, not just "some valid signature exists."

Keyless cosign signing skips managing a private key. What actually stops an attacker from just signing a malicious image themselves?

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 root
  • NET_ADMIN — reconfigure host networking
  • SYS_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?

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?

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:

All containers still share the same default bridge, but 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.
Containers on a named network (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.
The container shares the host's network stack directly — no isolation at all. Every port the container binds is bound on the host, and a compromised container can sniff every packet the host sees. Avoid in production.
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?