When "Not Root" Still Means Root: Linux Capabilities and Least Privilege
Linux capabilities were meant to replace setuid root. In practice, many grants are just root with a different label and are much harder to find.
There’s a deployment pattern I’ve seen so many times it has the texture of folklore. Somewhere in a hardening review, somebody flags a daemon running as root. The fix lands a sprint later: the setuid bit comes off, a non-root service account appears, and a single setcap line shows up in the postinstall script. The audit checklist gets ticked. Everybody moves on.
Six months later a pentester walks in, runs one command, and root-equivalent access falls out.
The command is getcap -r / 2>/dev/null. The misconfiguration is almost always the same: somebody granted cap_dac_override+ep (or cap_sys_admin+ep, or worse) to a binary that has a code execution bug, an interpreter, or a parser. The binary is not running as root. The user invoking it is not root. The audit tools that scan for SUID don’t see it. And yet, from an attacker’s perspective, the security boundary is identical to the one that existed before the “fix.”
This post is about how that happens, why it keeps happening, and what to actually do about it. The thesis is uncomfortable but clean: Linux capabilities are sometimes a real security improvement and sometimes a sleight of hand that hides root behind a less-obvious permission bit. Telling the two apart requires understanding which capabilities are genuinely narrow and which are root by another name.
A two-paragraph refresher
Linux capabilities arrived in kernel 2.2 (1999), drafted from the withdrawn POSIX.1e standard. The model splits the omnipotent UID 0 into about 41 discrete privilege bits: CAP_NET_BIND_SERVICE to bind ports below 1024, CAP_SYS_TIME to set the clock, CAP_SYS_MODULE to load kernel modules, and so on. Each thread carries five capability sets (Permitted, Effective, Inheritable, Bounding, and, since 4.3, Ambient), and files can carry capabilities in the security.capability xattr. The intent was elegant: instead of “either you’re root or you’re not,” you get “you have exactly the privilege you need and nothing more.”
The reality is messier. Several capabilities are individually equivalent to root for any attacker who can execute code in the granted process. Several are so broad they’ve become the kernel’s “use this if you don’t know what else fits” default. Michael Kerrisk found that CAPSYS_ADMIN alone accounts for over 45% of all capability checks in modern kernels. And the security tooling most operators rely on (find / -perm -4000, audit scripts, file-integrity monitors) wasn’t designed to flag capability grants the way it flags setuid binaries. The result is a mechanism that _looks like least privilege and audits like least privilege, but in misuse provides none of the protection.
The capabilities that are actually root
If you remember nothing else, remember this: granting any of the following capabilities to a non-root binary is functionally indistinguishable from leaving the setuid bit on, except harder to find.
CAP_DAC_OVERRIDE does exactly what it says: bypasses file read, write, and execute permission checks. The entire DAC system, off, for that process. A binary with this capability can read /etc/shadow, write /etc/sudoers, or drop a key into /root/.ssh/authorized_keys. The man page describes it in five words. The brevity is the warning.
CAP_SYS_ADMIN is what Kerrisk called “the new root” in a 2012 LWN piece that still reads as definitive. It grants mount and unmount, namespace operations, pivot_root, swap control, arbitrary trusted.* and security.* xattrs, raw I/O ports, BPF, perf, sethostname, and dozens of other operations. The capabilities(7) man page itself contains an unusual editorial note: “Don’t choose CAP_SYS_ADMIN if you can possibly avoid it!” When the kernel documentation begs you not to use a feature, that’s a signal.
CAP_SYS_MODULE is ring-0 code execution by another name. Load a kernel module, and the kernel (and every in-kernel security boundary that relies on it, including every LSM and every namespace) is whatever you say it is.
CAP_SYS_PTRACE lets a process attach to and read or write the memory of any other process. gdb -p <root_pid>, dump memory, done. Any privileged daemon that doesn’t drop privileges before going interactive (ssh-agent, gnome-keyring, anything holding decrypted secrets) is open. This isn’t a theoretical concern; it’s the foundation of half the post-exploitation tooling on Linux.
CAP_SETFCAP is the meta-capability: the privilege to grant other privileges. A process with CAP_SETFCAP can write cap_setuid+ep onto a copy of bash and stash it in /var/tmp. This is now a persistent backdoor that doesn’t appear in any standard SUID audit and survives reboots.
CAP_SETUID is the obvious one: setuid(0); execve("/bin/sh", …) is a two-line escalation.
CAP_CHOWN lets you change file ownership: chown /etc/shadow to yourself, edit, chown back.
CAP_FOWNER bypasses UID-match checks for chmod and ACL operations.
CAP_LINUX_IMMUTABLE clears the immutable bit on any file.
CAP_MKNOD creates device nodes: make your own /dev/sda and read raw disk.
CAP_NET_ADMIN is the network equivalent of CAP_SYS_ADMIN: routing tables, firewall rules, raw socket options, interface configuration, sniffing flags. A process with CAP_NET_ADMIN can disable the host firewall, MITM all traffic, or quietly add an allow rule for an inbound shell.
Brad Spengler’s analysis in 2010 found roughly 20 of the (then) 35 capabilities could be used to gain full root from an exploitable program. The current list is 41. The ratio hasn’t improved.
How the false-security pattern actually works
Walk through the failure mode concretely. A team operates mydump, an internal utility that needs to back up files under /var/lib/app. Some of those files are owned by various service accounts. The original implementation ran the tool via sudo, which the security team rightfully flagged. The remediation:
chmod u-s /usr/local/bin/mydump
setcap cap_dac_read_search,cap_dac_override+ep /usr/local/bin/mydump
This looks great in a diff. The setuid bit is gone. The binary now declares precisely which superpowers it needs. The compliance scanner stops complaining. Everybody goes home.
What actually changed? mydump can still read and write any file on the system. CAPDAC_OVERRIDE turns off DAC entirely. Any code-execution vulnerability in mydump (a buffer overflow in its tar parser, a path-traversal bug, a command injection in its config handling) yields the same outcome as before: arbitrary read and write across the filesystem, which is two file edits away from a UID-0 shell. The compliance scanner stopped complaining because _it was looking for the setuid bit, and the setuid bit isn’t where the privilege lives anymore. The privilege moved into an xattr that most file-integrity tools don’t monitor by default.
This isn’t a hypothetical. CVE-2023-2640 and CVE-2023-32629 (the “GameOver(lay)” vulnerabilities Wiz disclosed in mid-2023) affected roughly 40% of Ubuntu cloud workloads and let an unprivileged user inside a user namespace write security.capability=cap_setuid+ep onto an attacker-controlled binary outside the namespace. The bug was in the file-capability machinery itself. The whole apparatus that’s supposed to make the system more auditable became the attack surface.
The interpreter rule
There’s one anti-pattern that shows up in pentest reports more than any other, and it’s worth a section to itself: never grant a capability to a script interpreter.
If python3, perl, node, ruby, bash, awk, or any other interpreter has any nontrivial capability set, the system is rootable by any user who can execute that interpreter. The exploits are one-liners and they’re documented openly on GTFOBins:
# python3 with cap_setuid+ep
python3 -c 'import os; os.setuid(0); os.execl("/bin/sh","sh")'
# perl with cap_setuid+ep
perl -e 'use POSIX qw(setuid); POSIX::setuid(0); exec "/bin/sh"'
# tar with cap_dac_read_search+ep: read /etc/shadow
tar -cf shadow.tar /etc/shadow && tar -xf shadow.tar
# vim with cap_dac_override+ep: add a UID-0 line to /etc/passwd
vim /etc/passwd
# tcpdump with cap_net_raw+ep: sniff loopback traffic, harvest creds
tcpdump -i any -w /tmp/capture.pcap
The temptation to grant a capability to an interpreter usually comes from a sysadmin who has a Python script that needs one specific privilege and doesn’t want to write a C wrapper. The right answer is to write the C wrapper, or to run the script under a systemd unit with AmbientCapabilities=, so the privilege is scoped to that one execution rather than to every invocation of Python on the system. There is no correct invocation of setcap on /usr/bin/python3.
Auditing what you already have
The first thing to run on any host you don’t fully trust:
secdev@testbench:$ getcap -r / 2>/dev/null
This walks the filesystem looking for the security.capability xattr and prints every binary that has one. The output should be short and uninteresting. A typical Ubuntu baseline looks like:
/usr/local/bin/mydaemon cap_dac_override=ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep
/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin=p
/usr/bin/mtr-packet cap_net_raw=ep
/usr/bin/ping cap_net_raw=ep
A few things to flag immediately when reading this output:
- Capabilities on shells or interpreters. Almost certainly malicious or grossly misconfigured.
- CAP_SYS_ADMIN, CAP_DAC_OVERRIDE, CAP_SETFCAP, CAP_SYS_MODULE, or CAP_SYS_PTRACE on anything. Treat as setuid-root and audit accordingly.
- Anything in
/tmp,/var/tmp,/dev/shm, or user home directories. Almost always malicious. Packaged software lives in/usr. +epon a binary you didn’t expect. Means the capability is effective fromexecveonward, nocapset()call required by the program. Often a sign that whoever set it didn’t understand the alternatives.
For running processes, /proc/<pid>/status exposes the capability sets as hex:
secdev@testbench:$ grep ^Cap /proc/$(pidof mydaemon)/status
CapInh: 0000000000000000
CapPrm: 0000000000003000
CapEff: 0000000000003000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
secdev@testbench:$ capsh --decode=0000000000003000
0x0000000000003000=cap_net_admin,cap_net_raw
CapBnd is the bounding set: the upper bound on what this process can ever have. If it’s 000001ffffffffff (all bits) you have no bounding-set defense, and any execve of a file-capability binary can re-acquire whatever it wants.
The most useful tool I know for understanding what a daemon actually needs is BCC’s capable. It attaches a kprobe to cap_capable() and logs every capability check the kernel performs:
# capable-bpfcc -p $(pidof mydaemon)
TIME UID PID COMM CAP NAME AUDIT
14:02:11 0 4421 mydaemon 12 CAP_NET_ADMIN 1
14:02:11 0 4421 mydaemon 13 CAP_NET_RAW 1
Run it for a representative working period and you get a precise white-list of capabilities the daemon ever exercises. Everything else granted is over-grant. This is the only way I know to derive a genuinely minimal capability set empirically rather than from what the upstream documentation suggests.
Hardening: systemd is the single highest-leverage lever
On modern Linux, the systemd unit file is where capability hardening actually lives. A reasonable baseline for a network daemon that genuinely only needs to bind a low port:
[Service]
User=mydaemon
DynamicUser=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
# Filesystem
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ReadWritePaths=/var/lib/mydaemon /var/log/mydaemon
# Kernel
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
# Process
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
# Syscalls
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@debug @mount @swap @reboot @module @raw-io
A few of these settings deserve specific commentary:
AmbientCapabilities=versusCapabilityBoundingSet=alone. Running as a non-root user with ambient capabilities is genuinely better than running as root and bounding. Root with a bounding set still owns most of the files on the system; non-root with ambient does not. The bounding set is a ceiling, not a floor.NoNewPrivileges=yesis free hardening. It sets theno_new_privsbit, which means subsequentexecvecalls cannot grant new privileges: neither setuid nor file capabilities will work. Any daemon that doesn’t legitimately need toexecvea setuid helper should have this set. Systemd implies it when you useUser=without CAP_SYS_ADMIN, but set it explicitly anyway.SystemCallFilter=is multiplicative with capabilities. An attacker who somehow gains a dangerous capability still has to satisfy the seccomp filter. A daemon that doesn’t needmount,init_module,reboot, orioplshould refuse to call them at all.
Run systemd-analyze security to get an exposure score for every unit on the system. It will tell you exactly which hardening directives you haven’t set and rank your units from worst to best. Aim for an exposure score under 3.0 on anything that handles untrusted input.
For daemons that genuinely need a dangerous capability (and they do exist: tcpdump needs CAP_NET_RAW, some monitoring agents need CAP_SYS_PTRACE), layer an LSM on top. AppArmor profiles and SELinux policies can scope an over-broad capability to specific paths and operations: yes, this process has CAP_DAC_OVERRIDE, but it can only exercise it against /var/lib/foo. Capabilities are a flat list with no notion of “which file.” That’s exactly the gap an LSM fills.
Detection
Most operators have never enabled audit rules on capability operations. A minimal auditd configuration that costs almost nothing:
# Capability set changes
-a always,exit -F arch=b64 -S capset -k cap-change
# Anyone running setcap
-a always,exit -F arch=b64 -S execve -F path=/usr/sbin/setcap -k setcap-run
# Setuid execution by non-root
-a always,exit -F arch=b64 -S execve -F euid=0 -F uid!=0 -k setuid-exec
The Elastic and Sigma rule repositories have published detections for “setcap with cap_setuid+ep or cap_setgid+ep being set” and “process executing with CAP_NET_RAW by a non-root user.” Both are worth pulling into whatever your SIEM is. The first catches the most common persistence technique; the second catches passive sniffing setups.
A control I’d recommend putting in CI: any build that writes a file capability onto an interpreter, a shell, or a binary outside an explicit allowlist fails. Hard stop, with the offending setcap surfaced in the build log. Capability assignments deserve the same scrutiny as setuid bits. Grant them deliberately, review them loudly, and keep a paper trail.
What “good” actually looks like
To put a fine point on the contrast, here are some examples where capabilities are working as intended versus working against you.
Working as intended. A webserver that needs to bind port 443 runs as the www-data user with AmbientCapabilities=CAP_NET_BIND_SERVICE and a bounding set restricted to that one capability. A code-execution bug in the request parser yields a shell as www-data with no other privileges: no ability to read /etc/shadow, no ability to modify system files, no ability to attach to other processes. This is a real, meaningful reduction in blast radius compared to running as root.
ping shipped with cap_net_raw+p (not +ep) is similar: the binary is capability-aware, raises the capability into the effective set only for the socket(AF_INET, SOCK_RAW, …) call, and drops it immediately. A bug in ping’s argument parsing doesn’t yield raw-socket privilege because the capability isn’t effective at that point in execution.
Working against you. Any binary with cap_dac_override+ep or cap_sys_admin+ep “to get around a permissions issue.” The capability isn’t scoping anything. It’s a backdoor with a different shape.
A daemon that needs to read one specific privileged file should not get CAP_DAC_OVERRIDE. It should get a small setuid wrapper that opens the file, drops privilege, and passes the FD over a Unix-domain socket via SCM_RIGHTS. Or the file’s group ownership should be fixed. Or the configuration should be reorganized so the privileged read happens at startup and the daemon drops privileges immediately afterward. There are at least three correct answers; “grant CAP_DAC_OVERRIDE” is none of them.
A monitoring agent that wants to trace processes should not get CAP_SYS_PTRACE on a system with other privileged daemons running. Either run it in a tightly scoped namespace, or accept that ptrace-everything is functionally root and treat the agent accordingly.
The framing that helps
The mental shift that takes the longest to land is this: a capability grant is a privilege assertion that needs to be justified, not a hardening measure that needs to be approved. When you see setcap in a postinstall script, the default reaction shouldn’t be “good, they’re using capabilities.” It should be “okay, which capability, and is it actually narrower than root?”
If the answer is CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_IPC_LOCK, CAP_SYS_TIME, or CAP_SYS_NICE, you’re almost certainly looking at real least-privilege engineering. If the answer is CAP_DAC_OVERRIDE, CAP_SYS_ADMIN, CAP_SYS_MODULE, CAP_SYS_PTRACE, CAP_SETFCAP, or CAP_SETUID, you’re looking at root with a different label. The compliance scan saying “no SUID binaries” doesn’t mean what you think it means.
The capability model is sound in principle. It just has approximately twenty trapdoors, and the audit tooling most teams rely on doesn’t know about any of them. Treat getcap -r / as a peer of find / -perm -4000, take CAP_SYS_ADMIN seriously every time you see it, and never grant a capability to a script interpreter. Never. That covers most of what goes wrong in practice.