Skip to main content
RunBook Academy

Docker & ContainersXXXV Β· Production HardeningHardening

Auditing the running fleet β€” container controls and their proof

Advanced⏱ ~28 min

What you'll learn

  • Read the effective capability set of a running container from the kernel
  • Distinguish a requested control from an enforced one
  • Produce a repeatable pass/fail audit across every container on a host

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

The daemon lesson checked settings that apply to the whole host. This one checks the settings that apply to one container at a time, which is where hardening actually drifts: the daemon is configured once and rarely changed, while containers are created continuously by CI, Compose, and people in a hurry.

The distinction that carries the lesson is between a control that was requested and one that is enforced. docker inspect reports the request. /proc/<pid>/status inside the container reports what the kernel did with it, and the two disagree more often than you would like.

The eight per-container controls

ControlRequested viaRead the requestRead the enforcement
Not privileged--privileged absent.HostConfig.PrivilegedCapEff in /proc/1/status
Capabilities dropped--cap-drop=ALL.HostConfig.CapDropCapBnd in /proc/1/status
Non-root user--user or image USER.Config.Userid inside the container
Read-only root--read-only.HostConfig.ReadonlyRootfsa write attempt
No privilege escalation--security-opt.HostConfig.SecurityOptNoNewPrivs in /proc/1/status
MAC profile applieddaemon default.AppArmorProfile/proc/1/attr/current
Host namespaces not sharedflags absent.HostConfig.PidMode, IpcMode, NetworkMode/proc/1/ns/* compared to host
Process count bounded--pids-limit.HostConfig.PidsLimitpids.max in the cgroup

Capabilities: what the container really holds

Read-only / Safeeffective and bounding capability sets
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
grep -E 'CapEff|CapBnd|NoNewPrivs' "/proc/$PID/status"
CapBnd:	00000000a80425fb
CapEff:	0000000000000000
NoNewPrivs:	1
Read-only / Safedecode the hex
CID=api
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
BND=$(awk '/^CapBnd/ {print $2}' "/proc/$PID/status")
capsh --decode="$BND"
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

0xa80425fb is Docker’s default set: fourteen capabilities granted to every container that does not say otherwise. Learn to recognise that constant on sight β€” seeing it in an audit means nobody dropped anything.

Two of the fourteen deserve naming:

  • cap_dac_override ignores file permission checks entirely. A process holding it reads any file in the container regardless of mode, which turns a path-traversal bug into a full filesystem read.
  • cap_setuid and cap_setgid let a process become any user in the container, which is how β€œwe run as non-root” gets undone from inside.

A hardened container shows something much shorter:

0x0000000000000400=cap_net_bind_service

Or, for a container running as a non-root user with everything dropped, CapEff: 0000000000000000 β€” the capture at the top of this section, which is what good looks like.

User: the request and the reality

Read-only / Safeconfigured user
docker ps -q | while read -r c; do
  printf '%-28s user=%s\n' \
    "$(docker inspect --format '{{.Name}}' "$c")" \
    "$(docker inspect --format '{{.Config.User}}' "$c")"
done
/api                         user=10001
/web                         user=10001
/legacy-batch                user=

Illustrative output

An empty value is not a pass and not a fail β€” it means β€œlook at the image”. Resolve it:

Read-only / Safewhat the image defaults to
IMG=$(docker inspect --format '{{.Config.Image}}' legacy-batch)
docker image inspect "$IMG" --format 'image USER={{.Config.User}}'
image USER=

Illustrative output

Both empty means the container is running as root. The kernel’s answer is the definitive one:

Read-only / Safethe effective UID
CID=legacy-batch
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
awk '/^Uid:/ {print "real uid:", $2, " effective uid:", $3}' "/proc/$PID/status"
real uid: 0  effective uid: 0

Illustrative output

Reading it from /proc rather than with docker exec ... id matters for two reasons: it works on distroless images that have no shell, and it cannot be affected by anything inside the container.

Namespace sharing

Read-only / Safehost namespace escape hatches
docker ps -q | while read -r c; do
  docker inspect --format \
    '{{.Name}} net={{.HostConfig.NetworkMode}} pid={{.HostConfig.PidMode}} ipc={{.HostConfig.IpcMode}} uts={{.HostConfig.UTSMode}}' "$c"
done
/api net=app-net pid= ipc=private uts=
/monitoring-agent net=host pid=host ipc=private uts=host

Illustrative output

An empty pid is the default (private). pid=host means the container sees and can signal every process on the machine, including dockerd and sshd. net=host removes network isolation and publishes every listening port straight onto the host.

These are sometimes correct β€” a node exporter genuinely needs pid=host β€” but each one is a documented exception, not a default. The audit’s job is to make sure the list of exceptions is a list somebody approved.

The audit script

Everything above, as one pass over the host:

#!/usr/bin/env bash
# docker-container-audit.sh - per-container hardening evidence.
# Read-only. Exits non-zero if any container fails a control.
set -uo pipefail

DEFAULT_CAPS=00000000a80425fb
fail=0

for c in $(docker ps -q); do
  name=$(docker inspect --format '{{.Name}}' "$c" | tr -d /)
  pid=$(docker inspect --format '{{.State.Pid}}' "$c")
  priv=$(docker inspect --format '{{.HostConfig.Privileged}}' "$c")
  user=$(docker inspect --format '{{.Config.User}}' "$c")
  ro=$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$c")
  aa=$(docker inspect --format '{{.AppArmorProfile}}' "$c")
  bnd=$(awk '/^CapBnd/ {print $2}' "/proc/$pid/status")
  nnp=$(awk '/^NoNewPrivs/ {print $2}' "/proc/$pid/status")
  uid=$(awk '/^Uid:/ {print $2}' "/proc/$pid/status")

  problems=""
  [ "$priv" = "true" ] && problems="$problems privileged"
  [ "$bnd" = "$DEFAULT_CAPS" ] && problems="$problems default-caps"
  [ "$uid" = "0" ] && problems="$problems runs-as-root"
  [ "$nnp" != "1" ] && problems="$problems no-new-privs-off"
  [ "$ro" != "true" ] && problems="$problems writable-rootfs"
  [ -z "$aa" ] && problems="$problems no-apparmor"

  if [ -n "$problems" ]; then
    printf 'FAIL %-24s%s\n' "$name" "$problems"
    fail=1
  else
    printf 'PASS %-24suid=%s caps=%s\n' "$name" "$uid" "$bnd"
  fi
done

exit "$fail"

Sample output from a host mid-remediation:

PASS api                     uid=10001 caps=0000000000000400
PASS web                     uid=10001 caps=0000000000000400
FAIL legacy-batch            default-caps runs-as-root writable-rootfs
FAIL monitoring-agent        default-caps runs-as-root

Two findings, both real, both with a name attached. That output is the artefact β€” paste it into the change ticket, and paste the clean run next to it when the work is done.

Sanity check

Knowledge check Β· 4 questions

  1. Q1. A container shows `CapBnd: 00000000a80425fb`. What have you learned?

  2. Q2. Why read the container user from `/proc/<pid>/status` rather than with `docker exec ... id`?

  3. Q3. Which findings should an audit treat as failures even when other controls pass? Select all that apply.

  4. Q4. A container started with `--cap-drop=ALL` but running as root is adequately hardened against filesystem access inside the container.

Passing score: 75%. Answers are checked in this browser.