Container Internals and Runtime Engineering

Capabilities and the Root Illusion

A container runs as root. Is that dangerous? The honest answer is 'it depends entirely on capabilities' and most engineers can't explain why.

We have covered what a container can see (namespaces) and what it can use (cgroups). This lesson is about what it can do: privilege. The reason "runs as root" is not a yes-or-no security question is that on modern Linux, root is not one thing. The kernel long ago split the historic all-powerful UID 0 into around forty discrete capabilities, each a specific privilege that can be granted or removed independently. What a process can do is decided by which capabilities it holds, not by its UID.

KEY CONCEPT

"Root" is a set of capabilities, not a magic UID. UID 0 with an empty capability set can do almost nothing privileged; a non-zero UID that holds the right capability can perform that privileged operation. When you ask "is this container dangerous because it runs as root?" the real question is always "which capabilities does it hold?"

Root, decomposed

Each capability names one slice of former root power. A handful of the security-relevant ones:

CapabilityGrants the power to
CAP_SYS_ADMINMount filesystems, manipulate namespaces, and a huge grab-bag of admin operations
CAP_NET_ADMINReconfigure interfaces, routes, and firewall rules
CAP_NET_RAWCraft raw packets (ping, but also spoofing and sniffing)
CAP_SYS_PTRACETrace and inspect other processes' memory
CAP_SYS_MODULELoad and unload kernel modules (game over if held)
CAP_DAC_OVERRIDEBypass file read/write/execute permission checks
CAP_SETUID / CAP_SETGIDChange process UID/GID arbitrarily

A container runtime does not give a container all of them. Docker and containerd start containers with a default bounding set of roughly 14 capabilities and drop the rest. So the default container root already lacks CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, and most of the dangerous ones. That default is why running as root in a normal container is far less alarming than running as root on a host.

Inspecting and dropping capabilities

You can see the exact set a container holds and tighten it:

# The default set a container gets
docker run --rm alpine sh -c 'apk add -q libcap; capsh --print' | grep Current
# Current: cap_chown,cap_dac_override,cap_fowner,...,cap_net_bind_service,...

# Best practice: drop everything, add back only what the app needs
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
# nginx can bind port 80 as non-root, and can do nothing else privileged

In Kubernetes the same thing is securityContext.capabilities.drop: ["ALL"] with an explicit add list. The discipline is identical: start from nothing, grant the minimum.

root vs privileged: not the same thing

These two are constantly confused, and the difference is the whole lesson.

Default root container vs a privileged container

Runs as root (default container)

UID 0 inside, but heavily bounded

CapabilitiesAbout 14 by default; the dangerous ones are dropped
DevicesNo access to host devices
Seccomp / LSMDefault seccomp profile and AppArmor/SELinux applied
Danger levelLimited: cannot mount, load modules, or reach the host directly
--privileged

Effectively host root

CapabilitiesAll capabilities granted, including CAP_SYS_ADMIN
DevicesFull access to all host devices under /dev
Seccomp / LSMSeccomp disabled and confinement relaxed
Danger levelSevere: trivially escapes to the host; treat as host root

"Runs as root" means UID 0 with the default, trimmed capability set. --privileged means every capability, all host devices, and no seccomp confinement. A privileged container is not "a bit more powerful," it is effectively root on the host and one of the most common real-world escape paths (Module 4). The word "privileged" is doing enormous work; never grant it casually.

CAP_SYS_ADMIN: the dangerous one

If a single capability deserves a warning, it is CAP_SYS_ADMIN. It is so overloaded that it has been called "the new root": it grants mounting, namespace manipulation, and dozens of unrelated admin operations. Many container escapes require exactly this one capability, because with it a process can mount filesystems or manipulate namespaces in ways that reach the host. If a workload asks for CAP_SYS_ADMIN, treat that as a request for host-level trust and push back hard.

no-new-privileges

One more control ties it together. The no-new-privileges flag (the PR_SET_NO_NEW_PRIVS prctl) prevents a process and its children from gaining privileges later, for example through a setuid binary or a file with file capabilities. Even if an attacker drops a setuid-root helper into the container, it cannot use it to escalate. In Kubernetes this is securityContext.allowPrivilegeEscalation: false. It is cheap and should be the default for every workload.

docker run --rm --security-opt no-new-privileges alpine \
  sh -c 'id; echo "cannot escalate via setuid binaries"'

Common mistakes

  • Equating UID 0 with danger. Root with an empty capability set is nearly harmless; a non-root UID with a powerful capability is not. Judge by capabilities, not UID.
  • Reaching for --privileged to fix a permission error. It almost always grants far more than the one missing capability. Find the specific capability (or device, or mount) and grant only that.
  • Leaving the default capability set in place for sensitive workloads. The default ~14 is convenient, not minimal. Drop ALL and add back what the app provably needs.
  • Granting CAP_SYS_ADMIN to make something work. It is the widest capability there is and a frequent escape enabler. If an app needs it, question the design.
  • Forgetting no-new-privileges. Without it, a setuid binary in the image can undo your careful capability dropping.

The interview answer

The scenario's exact question: root with all capabilities dropped, versus an unprivileged UID holding CAP_SYS_ADMIN. The second is far more dangerous. Capabilities, not the UID, decide what a process can do, and the kernel checks the capability regardless of whether the UID is 0. Root-with-no-capabilities can open its own files and little else privileged; it cannot mount, load modules, or manipulate namespaces. The unprivileged-UID-with-CAP_SYS_ADMIN process can perform the mount and namespace operations that lead to container escapes, precisely because it holds the capability that authorizes them. The lesson: stop reading the UID and start reading the capability set.

Summary

Root is a bundle of around forty capabilities, and the kernel authorizes privileged actions by capability, not by UID. Container runtimes grant a trimmed default set (about 14) and drop the rest, which is why default container-root is far weaker than host-root. --privileged restores everything and is effectively host root; CAP_SYS_ADMIN is the single most dangerous capability and a common escape enabler; no-new-privileges stops later escalation. Best practice is drop ALL, add the minimum, never go privileged, and set no-new-privileges. Next: seccomp and the LSMs that constrain a process even further, at the syscall level.

KNOWLEDGE CHECK

Container A runs as root (UID 0) with all capabilities dropped and no-new-privileges set. Container B runs as an unprivileged user (UID 1000) but holds CAP_SYS_ADMIN. Which is more dangerous, and what principle explains it?

INTERVIEW QUESTION

A container runs as root but with all capabilities dropped. Another runs as an unprivileged user but with CAP_SYS_ADMIN. Which is more dangerous and why?