Skip to main content
All articles

Linux capabilities: root privileges without the SUID bit, and the container escape they enable

Capabilities split root into 40+ pieces, and about a dozen of them are still root. Which ones matter, how CVE-2022-0492 turned a cgroup detail into a container escape, and how to audit a host and an image.

7 min read0 views

Traditional Unix has two privilege levels: root and not-root. Capabilities were introduced to break that binary apart, so a program that needs to bind port 80 gets the ability to bind low ports rather than the ability to do everything.

That is a genuine improvement, and it produced a new problem: there are now over forty distinct capabilities, several of which are functionally equivalent to full root, and the split makes them look modest. A process with CAP_DAC_READ_SEARCH is not "partly privileged". It can read every file on the system, including /etc/shadow and every private key.

This matters most in containers, where capabilities are the primary isolation mechanism and where "just add --cap-add to make it work" is a common answer to a broken build.

The ones that are root

Ranked by how directly they get you there.

CAP_SYS_ADMIN. The catch-all. Mount filesystems, use setns() to enter other namespaces, and reach a long list of administrative operations. Kernel developers have described it as "the new root", and it is required by so many unrelated operations that it ends up granted casually. If a container has it, treat the container as having the host.

CAP_SYS_PTRACE, attach to any process and read or write its memory. Inject code into a root-owned process and you are that process. In a container sharing the host PID namespace, this reaches host processes.

CAP_SYS_MODULE, load kernel modules. Straight to ring 0; nothing else is required.

CAP_DAC_READ_SEARCH, bypass all file read permission checks. Read /etc/shadow, SSH host and user keys, cloud credential files, kubeconfigs. It also enables open_by_handle_at(), which is the basis of the "shocker" container escape: with a handle to an inode on the host filesystem, you can read files outside the container's mount namespace.

CAP_DAC_OVERRIDE, bypass all file read and write checks. Write /etc/passwd, /etc/sudoers, a systemd unit, a cron entry.

CAP_SETUID / CAP_SETGID, become any user, including root.

CAP_CHOWN / CAP_FOWNER, change ownership or bypass ownership checks; the file-write path again by a different route.

CAP_SYS_CHROOT, chroot, which combined with other primitives assists escapes.

CAP_NET_ADMIN, reconfigure networking: interfaces, firewall rules, routing. Redirect traffic, disable egress filtering, intercept.

CAP_NET_RAW, raw sockets: ARP spoofing and traffic interception on the container network. This one is in Docker's default set, which is worth knowing.

CAP_SYS_BOOT, CAP_SYS_RAWIO, CAP_MKNOD, CAP_AUDIT_WRITE. Each with its own route, CAP_MKNOD notably allowing creation of a device node for the host's disk.

The general rule: any capability that grants file write, memory access, module loading, or namespace manipulation is root with extra steps.

CVE-2022-0492: a missing check, a container escape

This is the case that shows why capabilities deserve attention rather than a checkbox.

cgroups v1 has a release_agent file: a path to a program the kernel executes as fully-privileged root on the host when the last process in a cgroup exits. It is a legitimate mechanism, and it has always required CAP_SYS_ADMIN to set.

Except the kernel did not actually verify that the process writing release_agent had CAP_SYS_ADMIN. That omission is CVE-2022-0492, patched in kernel 5.17-rc3 and backported widely, and it is in CISA's Known Exploited Vulnerabilities catalog.

The escape chain is worth following because it is a good lesson in how a "small" bug composes:

  1. Inside the container, cgroup mounts are read-only, so the existing release_agent cannot be written.
  2. But an unprivileged process can call unshare() to create a new user namespace, in which it holds a full capability set, including CAP_SYS_ADMIN, relative to that namespace.
  3. In a new cgroup namespace it can then mount a fresh, writable cgroupfs.
  4. Write release_agent, pointing at a script on a filesystem the host can see.
  5. Trigger it, and the kernel runs that script as real root on the host, outside every namespace.

Two things generalise:

  • User namespaces let an unprivileged user hold capabilities, scoped to that namespace. Any kernel code path that checks "has CAP_SYS_ADMIN" without checking in which namespace is a potential escape. This class recurs.
  • Hardening that was not aimed at this bug stopped it anyway. Containers running under SELinux, AppArmor or an appropriate seccomp profile were protected, because those controls block the mount operation regardless of the capability check. Defence in depth is not a slogan here. It is the difference between an escape and a failed syscall.

Auditing a host

# Files with capabilities set -- the SUID equivalent
getcap -r / 2>/dev/null

# Your own process
capsh --print
grep Cap /proc/self/status          # then: capsh --decode=<hex>

Anything with cap_setuid, cap_dac_read_search, cap_dac_override, cap_sys_admin, cap_sys_ptrace or cap_sys_module in getcap output is a local privilege escalation route waiting for an argument. The classic real-world find is a Python or Perl binary that someone gave cap_setuid+ep so a script could drop privileges:

/usr/bin/python3.11 = cap_setuid+ep

That is root for anyone who can run Python. It is the capability-era equivalent of a SUID interpreter, and it is common enough to be worth checking first on every host.

Do not stop at getcap. Also check +ei, inheritable and effective, and check ambient capabilities on running processes, which do not appear in a filesystem scan at all.

Auditing a container

# What the container has
capsh --print
grep CapEff /proc/1/status

# What the image was configured with, from outside
docker inspect <container> --format '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}} {{.HostConfig.Privileged}}'

Docker's default set already includes CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_SETGID, CAP_SETUID, CAP_NET_RAW, CAP_MKNOD and others, enough that "default" is not the same as "minimal". Kubernetes containers inherit a similar set unless a securityContext says otherwise.

Red flags, in order of severity: Privileged: true (all capabilities, all devices. The container is the host); any CapAdd containing SYS_ADMIN, SYS_PTRACE or SYS_MODULE; hostPID, hostNetwork or hostIPC set to true; and /var/run/docker.sock mounted into the container, which is root on the host regardless of any capability configuration.

Fixing it

Drop everything, add back nothing if you can:

# Kubernetes
securityContext:
  runAsNonRoot: true
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault
# Docker
docker run --cap-drop=ALL --security-opt=no-new-privileges ...

Most application containers need no capabilities at all. A web server that binds a port above 1024 as a non-root user needs nothing. If it needs port 80, map the port on the host rather than granting CAP_NET_BIND_SERVICE inside.

allowPrivilegeEscalation: false sets no_new_privs, which is independently valuable: it prevents a process from gaining privileges through SUID binaries or file capabilities, which breaks a large fraction of standard privilege-escalation paths regardless of what else is misconfigured.

Enforce it, do not merely recommend it. Pod Security Admission at restricted, or an admission controller policy, so a manifest with privileged: true is rejected rather than deployed and reported later.

Keep seccomp and AppArmor/SELinux on. As CVE-2022-0492 demonstrated, they stop escapes whose specific bug you have never heard of.

Patch kernels. Container escapes are kernel bugs, and containers on a shared host share that kernel. A container-level control does not compensate for an unpatched host.

On hosts, prefer no file capabilities to careful ones. If a service needs a privileged operation, give it a systemd unit with AmbientCapabilities= and NoNewPrivileges=yes, rather than setting a capability on a binary that any user can run.

Detection

  • getcap, capsh, unshare or nsenter executed inside a container. These are enumeration and escape primitives, essentially never part of normal application behaviour, and they make an excellent high-signal rule.
  • mount syscalls from a container, particularly cgroupfs.
  • Writes to release_agent, /sys/fs/cgroup/**/release_agent, or notify_on_release.
  • Container creation with Privileged: true or added SYS_ADMIN, alert at admission time, not in a quarterly report.
  • Changes to file capabilities on hosts (setcap execution, or file-integrity monitoring on binaries).
  • unshare with CLONE_NEWUSER from unexpected processes.

Take this away

Capabilities were meant to reduce the blast radius of root. They do, but only if the set is actually minimal, and the honest default is that the useful ones are root.

The right question for any container or service is not "which capabilities does this need?" It is "can this run with none?", because the answer is usually yes, and every capability you add back is a route someone will eventually find.


Further reading

Was this useful?

Share

Tags

  • linux privilege escalation
  • container security
  • cve analysis
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Internal & External Penetration Testing

Find the paths into your network, and the paths across it once someone is in.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.