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
no-new-privilegesRun these techniques only against the lab you own. Using them on systems you don't have permission to test is illegal.
The threat
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
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 root02Scan configuration with trivy
trivy configchecks 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 argumentAVD-DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile03Check what's actually running
terminal$ docker exec shoplite iddocker 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
01Run as an unprivileged user, from a pinned base
Official Node images include a
nodeuser (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"]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-privilegesso setuid binaries can't regain privileges, and resource limits. The Kubernetes equivalent is the podsecurityContextenforced by Pod Security Admission'srestrictedprofile.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
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
Write the Kubernetes securityContext equivalent of the hardened compose service.
Interview questions
How do you harden a container at runtime?