The setuid bit is the oldest privilege escalation in the Unix toolbox, and it is the bluntest instrument still in daily use: a binary marked setuid root runs every instruction with every root power, whether it needs all of them or one of them. tcpdump needs exactly one privilege, the ability to put a network interface into promiscuous mode, and the setuid answer to that need grants it, in the same stroke, the right to read every file, kill every process and rewrite every rule on the machine. Capabilities replace that all-or-nothing model with a granular one: root's power is split into about forty discrete units, and a binary can carry precisely the units it needs. This article builds the practical skill set: the model and its vocabulary, granting and inspecting capabilities with setcap and getcap, the tcpdump and nginx cases from their real-world shape, running services with partial privileges through systemd, and the operational realities, filesystems, package updates and the security review, that keep the granular model honest over years.

The model, its vocabulary and why it exists

Every process carries five capability sets, and two names cover most daily work. The permitted set is the ceiling, what the process could ever enable, and the effective set is what it can do right now. The other three, inheritable, bounding and ambient, govern the transitions across execve calls, and the one worth remembering for the operational mindset is ambient: capabilities that survive an exec into a non-setuid binary, the mechanism that lets a shell retain a specific power across a program launch without a setuid anywhere in sight.

The capability names read like a list of root's job description. CAP_NET_RAW grants raw socket access, the one tcpdump wants. CAP_NET_BIND_SERVICE grants binding to ports below 1024, the one a web server wants. CAP_NET_ADMIN rewrites routing and interfaces, CAP_DAC_OVERRIDE reads files despite permissions, CAP_SYS_PTRACE inspects other processes, and the rest cover the remaining powers from reboot to clock. The full list lives in the kernel headers and in human-plain form through one command:

capsh --decode=0

with capsh --print showing the current shell's own sets, which for an ordinary user is a lesson in itself: the bounding set holds everything, and the effective set holds nothing, which is the default state of an unprivileged process and the reason capability work is always a conversation about which powers to add.

The design goal of the model is worth stating because it shapes every decision that follows: a compromised binary with one capability is a contained problem, the attacker can sniff or can bind, while a compromised setuid binary is a full compromise. Granularity is containment, and the rest of this article is the craft of granting as little as the workload allows.

setcap and getcap on the tcpdump case

The tools ship in a small set of packages, libcap2-bin on Debian-family and libcap on RHEL-family, and the two commands that do the daily work are setcap and getcap. Granting tcpdump its single needed power is one line:

setcap cap_net_raw=ep /usr/bin/tcpdump

The syntax reads: grant the CAP_NET_RAW capability, with e meaning effective and p meaning permitted, to the binary at the given path. The distinction between the two letters is the difference between can and does, and for the tcpdump case both are set, because the binary needs the power active from its first instruction. Verification is the sibling command:

getcap /usr/bin/tcpdump /usr/bin/tcpdump cap_net_raw=ep

And the functional proof is running tcpdump as an ordinary user, capturing traffic from a non-privileged account, which works the moment the capability is in place and the file's ownership has nothing to do with it. The user running the binary does not become root, does not gain any other power, and the capture privilege exists only within that one process tree.

The removal command completes the toolkit, setcap -r /usr/bin/tcpdump, returning the binary to its plain state, and the one syntactic detail worth knowing is that setcap replaces the entire capability line on the file: setting cap_net_raw and then setting cap_net_admin does not stack them, the second call overwrites the first, and multiple capabilities are granted in a single command, cap_net_raw,cap_net_admin=ep.

Nginx, binding ports and the service-shape case

The second classic case arrives with every web server: binding port 80 or 443 is a privileged act, ports below 1024, and the historical answer was to start the master process as root, open the sockets, and drop to an unprivileged worker. Capabilities offer two sharper shapes. The first is the file-capability shape, familiar from tcpdump:

setcap cap_net_bind_service=ep /usr/sbin/nginx

after which the binary starts under an unprivileged account and binds port 80 directly, no root phase, no drop ceremony, one power granted and no others. The shape is honest and minimal, and it suits manually-run services, embedded corners and the odd binary that a small team manages directly.

The second shape is the systemd-native one, and on a server it is usually the better answer, because it keeps the privilege declaration in the unit file where the service's whole definition lives:

[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
User=www-data

CapabilityBoundingSet is the ceiling: everything not listed is removed, so the service starts with the smallest capability envelope it can have. AmbientCapabilities is the grant: the listed power is placed in the ambient set so it crosses the exec into the binary, which carries no file capabilities at all. The combination gives a service exactly one power, declared in text, versioned with the unit, and visible to any administrator who reads the file, which is why the systemd shape has become the default recommendation for anything managed by the init system.

The two shapes compose with the earlier topics of this series: a hardened web server with the kernel sysctl posture from the previous article and the capability envelope here runs with a smaller effective privilege than most distributions ship by default, and the difference between the two postures is measured in attack surface, not in functionality.

The security review and what capabilities really grant

Granularity is containment, but a capability is still a genuine power, and the honest security review asks what each grant makes possible in hostile hands. CAP_NET_RAW on tcpdump means a compromised binary can sniff every packet that crosses the wire, passwords on unencrypted protocols, sessions, metadata, which is exactly what tcpdump does by design and what an attacker would do with the same power. The grant is still a massive improvement over setuid, where the same compromise reads every file and installs persistence, but it is not zero risk, and the review names the residual exposure honestly.

CAP_NET_BIND_SERVICE on a web server is smaller, the power to take low ports, and its main abuse potential is shadowing, a hostile binary grabbing a port a legitimate service expected. CAP_DAC_READ_SEARCH, a tempting grant for backup tools, is the one worth resisting reflexively, because it reads every file regardless of permissions, recreating much of the file-reading half of root for any code that runs inside the binary. The capability list itself, read once end to end, is the best calibration exercise an administrator can do, because every subsequent grant decision draws on the vocabulary it builds.

The script caveat deserves its own paragraph because it surprises everyone once: the kernel ignores file capabilities on interpreted scripts, shell scripts, python files, anything with a shebang, because the interpreter, not the file, is the executed binary, and granting capabilities to the script would grant them to every script the interpreter later touches. The correct pattern for scripted tooling is the systemd shape with AmbientCapabilities on the service, or a small compiled wrapper, or a setuid-free redesign, and the rule of thumb is that file capabilities belong to binaries and ambient capabilities belong to services.

Filesystems, package updates and persistence

Capabilities are extended attributes on the file, the security.capability namespace, and the filesystem has to cooperate. ext4, XFS and Btrfs carry them natively, tmpfs does as well, and the historical trouble spots are FAT-family filesystems that have no xattr story at all and NFS mounts, whose xattr forwarding depends on the server configuration. Copying a capability-carrying binary with cp or rsync preserves or drops the attribute depending on flags, and tar needs --xattrs explicitly, which are the small frictions that make capability-deployment via file copy an unreliable pattern compared to the systemd-declared approach.

Package updates are the classic persistence trap: the update unpacks a fresh binary over the file, and extended attributes, including capabilities, vanish with the old inode. A tcpdump upgrade that silently strips cap_net_raw is a monitoring outage discovered at the next capture, and the same applies to every file-capability deployment. The production answers are two: the systemd unit, which re-grants at every service start and survives updates untouched, or an update hook, a dpkg trigger on Debian-family or a script in the package manager's lifecycle on RHEL-family, that reapplies the setcap line. Both are documented patterns, and choosing between them is choosing between declaring the privilege with the service definition, the preferred shape, and patching the package lifecycle, the fallback for the tool that must be a file capability.

Audit completes the operational picture. Finding every capability-carrying binary on a system is one command, getcap -r /, and its output belongs in the runbook next to the setuid inventory, find / -perm -4000, because the two lists together are the complete map of binaries with elevated powers. The quarterly review reads both, asks why each entry holds its grant, and removes what nobody can justify, which is the discipline that keeps the granular model from decaying into the scattered equivalent of the setuid mess it replaced.

The checklist for the capability-first server

The working order for a new service on the granular model is short. Write the systemd unit with User, the unprivileged account. List the power the service genuinely needs, usually one capability, rarely two. Declare the ceiling and the grant, CapabilityBoundingSet and AmbientCapabilities, in the unit. Start, verify functionally, and confirm the envelope with cat /proc/<pid>/status | grep Cap, whose Effective line should show exactly the bits granted and nothing more. For standalone binaries that a user runs by hand, grant with setcap, verify with getcap, and handle persistence with an update hook or a note in the deployment procedure. Audit both lists quarterly, and write the why of every grant next to it. The result is a server whose elevated binaries number in single digits, whose each power is one line of text, and whose setuid inventory is empty, the quiet end state that the capability model was built to make reachable.
## Reading the envelope out of /proc

The kernel publishes every process's capability state in plain sight, and the file is worth a close read because it is the ground truth behind every tool. For any running service:

grep Cap /proc/<pid>/status CapInh: 0000000000000000 CapPrm: 0000000000800000 CapEff: 0000000000800000 CapBnd: 000001ffffffffff CapAmb: 0000000000800000

Four of the five lines tell the story of a service granted a single power. The bounding set, CapBnd, holds nearly every capability bit, the ceiling the process could theoretically reach. The permitted and effective sets hold one bit, the granted power armed and active. The ambient line shows the same bit, the grant that crossed the exec boundary. The inheritable set is empty, which is the normal state for a service that grants nothing to its children. Decoding the hex into a name is one command, capsh --decode=0x800000, which answers CAP_NET_BIND_SERVICE, and the five-line readout together with the decode is the complete answer to what a running service can do, independent of any configuration file or documentation, straight from the kernel's own ledger.