Command Palette

Search for a command to run...

Hectal
Lab 2.1·Containers & InfrastructureHIGH

Everything runs as root

Every ShopLite container runs as root, with a writable filesystem and the default Linux capabilities. Any code-execution bug gets root inside the container, and a much shorter path out of it.

Weakness
CWE-250 · Execution with unnecessary privileges · CIS Docker 4.1
Target
the ShopLite Dockerfile and compose service
container users and capabilitieshadolint and trivy misconfiguration scansread-only root filesystemsno-new-privileges

Run these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.

The threat

  1. 01Why root in a container matters

    Containers share the host kernel. A process running as root (UID 0) inside a container is root in the kernel's eyes, limited only by namespaces, capabilities, seccomp, and LSMs. If any of those is weakened (a privileged flag, an extra capability, a host mount, a kernel vulnerability), root inside becomes much closer to root on the node.

    Root plus a writable filesystem also lets an attacker who achieves code execution install tools, modify the application in place, and persist until the container is replaced.

What's at risk

  • Full control of the container: modify code, read every secret mounted into it, install tooling.
  • A materially larger chance of escaping to the host when combined with any other misconfiguration or kernel bug.

Detect

  1. 01Lint the Dockerfile

    hadolint checks Dockerfile best practices; DL3002 flags a final USER of root, and a missing USER means root.

    terminal
    $ docker run --rm -i hadolint/hadolint:v2.12.0 < Dockerfile
    ── output ──
    -:1 DL3007 warning: Using latest is prone to errors if the image will ever update. Pin the version explicitly
    -:9 DL3002 warning: Last USER should not be root
  2. 02Scan configuration with trivy

    trivy config checks Dockerfiles, compose files, Kubernetes manifests, Terraform, and more against a large built-in policy set.

    terminal
    $ docker run --rm -v "$PWD:/src" aquasec/trivy:0.63.0 config /src
    ── output ──
    Dockerfile (dockerfile)
    Tests: 27 (SUCCESSES: 25, FAILURES: 2)
    Failures: 2 (HIGH: 1, LOW: 1)
     
    AVD-DS-0002 (HIGH): Specify at least 1 USER command in Dockerfile with non-root user as argument
    AVD-DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile
  3. 03Check what's actually running

    terminal
    $ docker exec shoplite id
    docker inspect shoplite --format 'ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}}'
    ── output ──
    uid=0(root) gid=0(root) groups=0(root)...
    ReadonlyRootfs=false CapDrop=[] SecurityOpt=[]

Defend

  1. 01Run as an unprivileged user, from a pinned base

    Official Node images include a node user (UID 1000). Files the app only reads are owned by root and not writable by the app user.

    Vulnerable

    Dockerfilewhole filedocker
    FROM node:latest
    WORKDIR /app
    COPY . .
    RUN npm install
    CMD ["node", "server.js"]

    Hardened

    Dockerfilewhole filedocker
    FROM node:22-alpine
    WORKDIR /app
    COPY package.json package-lock.json ./
    RUN npm ci --omit=dev --ignore-scripts
    COPY server.js ./
    USER node
    HEALTHCHECK CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
    CMD ["node", "server.js"]
  2. 02Drop everything the runtime doesn't need

    A read-only root filesystem (with a small tmpfs where the app must write), ALL capabilities dropped (a web server on a port above 1024 needs none), no-new-privileges so setuid binaries can't regain privileges, and resource limits. The Kubernetes equivalent is the pod securityContext enforced by Pod Security Admission's restricted profile.

    Vulnerable

    docker-compose.ymladd to fileyaml
      shoplite:
        build: ./shoplite
        ports: ["8080:8080"]

    Hardened

    docker-compose.ymladd to fileyaml
      shoplite:
        build: ./shoplite
        ports: ["8080:8080"]
        user: "1000:1000"
        read_only: true
        tmpfs: ["/tmp"]
        cap_drop: [ALL]
        security_opt: ["no-new-privileges:true"]
        mem_limit: 512m
        cpus: "1.0"
        pids_limit: 200

Verify

  1. 01Same checks, clean results

    terminal
    $ docker compose up -d --build shoplite && docker exec shoplite id && docker exec shoplite sh -c 'touch /app/x' ; curl -s localhost:8080/healthz
    ── output ──
    uid=1000(node) gid=1000(node) groups=1000(node)
    touch: /app/x: Read-only file system
    {"status":"ok"}

The concepts

Least privilege for processes

Linux splits root's power into ~40 CAPABILITIES (bind low ports, change file ownership, load kernel modules, and so on). Docker grants a default subset; most applications need none of them. Dropping ALL and adding back only what's proven necessary, running as non-root, and making the filesystem read-only means that a successful exploit gives the attacker as little as possible, which is the same idea as least-privilege IAM.

Pod Security Standards

Kubernetes' built-in Pod Security Admission enforces three profiles per namespace: privileged, baseline (blocks known privilege escalations), and restricted (non-root, no privilege escalation, dropped capabilities, seccomp RuntimeDefault). Label production namespaces pod-security.kubernetes.io/enforce: restricted.

Your turn

01

Write the Kubernetes securityContext equivalent of the hardened compose service.

Interview questions

01

How do you harden a container at runtime?

0/4 · 0%