How Linux Capabilities Actually Work
Try the interactive lab for this articleTake the quiz (6 questions · ~5 min)Linux capabilities are one of those kernel features that people can use for years without really trusting their mental model of them.
They know a few surface facts. CAP_NET_BIND_SERVICE lets a process bind port 80 without running as root. CAP_SYS_ADMIN is dangerous. Containers drop capabilities. setcap writes something to a file. /proc/$PID/status shows big hexadecimal masks that are hard to read quickly.
All of that is true, and it still misses the mechanism.
The mechanism is this: Linux took the giant blob of privilege historically attached to UID 0 and split large parts of it into bitmasks carried in thread credentials. Those bitmasks are then transformed again at execve() time, sometimes mixed with capability xattrs attached to a file, sometimes intersected with a bounding set, sometimes cleared by no_new_privs, sometimes evaluated relative to a user namespace rather than the host. If you do not track those state transitions precisely, capabilities feel random. If you do track them precisely, most of the weird behaviour stops being weird.
This article follows that mechanism from the kernel's point of view. We will stay close to the actual rules described in capabilities(7), execve(2), proc_pid_status(5), and the kernel header include/linux/capability.h. We will look at where the bitmasks live, when the kernel checks them, how file capabilities are stored, why execve() is the decisive moment, how user namespaces change the meaning of privilege, and why CAP_SYS_ADMIN remains the awkward gravitational centre of the whole design.
Root Was Too Coarse
Traditional UNIX privilege is brutally simple. If your effective UID is 0, the kernel treats you as privileged. If it is not, the kernel applies ordinary permission checks. That model is easy to implement and easy to explain, but it is terrible for least privilege.
Suppose a daemon only needs one special power: binding a socket below port 1024. In the old world, you either:
- ran it as root the whole time
- started as root and hoped it dropped privilege correctly later
- inserted a root helper somewhere in the path
That means a bug in a web server that only needed port 443 might also inherit the power to mount filesystems, reconfigure routing, load kernel modules, change the system clock, or trace other processes. The privilege surface is much larger than the job.
Linux started breaking that model apart in 2.2. The kernel introduced capabilities as independently controllable privilege units. Instead of asking only "is this process root?", kernel code can ask "does this thread hold the specific capability that governs this operation?"
That shift matters because it changes how you should model a privileged program.
A privileged program is no longer just:
- executable path
- owner UID and GID
- setuid bit
It is also:
- a set of capability bits in the thread credentials
- possibly another set of capability bits attached to the executable file
- a bounding mask limiting what
execve()may grant later - namespace context that decides which user namespace a capability is relative to
- optional securebits state that changes root semantics
The result is not a replacement for all permission logic. Linux still has DAC permissions, ACLs, LSM decisions, namespace isolation, seccomp filters, cgroups, and mount flags. Capabilities sit inside that larger permission system. They solve one specific problem: turning superuser privilege into smaller pieces that the kernel can reason about separately.
A Capability Check Is Really A Credential Check
The most important sentence in capabilities(7) is easy to overlook: capabilities are a per-thread attribute.
That has two consequences.
First, the kernel does not conceptually store "process capabilities" in one magical place. It stores credentials, and each thread points at a struct cred. That credential object contains the familiar UID and GID values plus the capability sets: effective, permitted, inheritable, bounding, and ambient. In kernel code, capability checks typically flow through helpers such as:
capable()ns_capable()file_ns_capable()security_capable()
capable(CAP_NET_RAW) is not asking a philosophical question. It is reading the calling thread's current credentials and checking whether the relevant bit is present in the effective set. ns_capable(user_ns, CAP_SYS_ADMIN) adds another condition: the capability must be valid in the target user namespace.
Second, capability state can diverge in ways people do not expect. Linux user space often talks about processes because that is the normal operational unit, but the kernel tracks credentials per thread. In practice many credential changes are coordinated across a thread group by libc and the kernel so that applications still get POSIX-like semantics, but the underlying model is thread-centric.
That is why /proc/$PID/task/$TID/status is the precise place to inspect one thread's capability masks. /proc/$PID/status shows the main thread's view. proc_pid_status(5) documents the fields:
CapInhCapPrmCapEffCapBndCapAmb
A real system inspection starts there:
grep '^Cap' /proc/$$/statusTypical output looks like this:
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000Those values are hexadecimal bitmasks. They are not symbolic names because the kernel interface is optimised for machines, not for sleepy humans debugging a broken service at 02:10 in Rotterdam.
The important operational habit is to stop treating capabilities as a vague property of "the app" and start treating them as part of the current thread credential state. Once you do that, behaviour that felt arbitrary usually turns into one of three things:
- the bit was never in the effective set
- the bit was lost during a UID or
execve()transition - the bit exists, but the check was relative to a different namespace or blocked later by another security mechanism
The Five Sets You Actually Need To Track
If you only remember one structural fact from this article, make it this table.
| Set | What it means | Directly checked on privileged operations? | Preserved across execve()? |
|---|---|---|---|
| Permitted | Ceiling of what the thread may enable in effective | No | Recomputed |
| Effective | Bits the kernel actually consults for most checks | Yes | Recomputed |
| Inheritable | Bits that may flow through execve() if the file allows it | No | Yes |
| Bounding | Ceiling on what file capabilities may grant on execve() | No | Yes |
| Ambient | Bits that survive execve() of an unprivileged file | Indirectly, after promotion | Recomputed |
That still sounds abstract, so let us walk through each one.
Permitted
The permitted set is the thread's privilege ceiling. If a capability is not in permitted, the thread cannot simply decide to enable it later in effective. capabilities(7) describes permitted as a limiting superset for effective.
This is why dropping something from permitted is serious. Once a thread removes a capability from permitted, it cannot get it back just by calling capset(2). Reacquisition normally requires an execve() path that grants the capability again, typically through a file capability or a setuid-root transition.
Effective
This is the set that matters for ordinary kernel permission checks. If a capability-gated code path asks whether you may open a raw socket, trace another process, or bind a low port, the effective set is the one that is usually consulted.
The simplest mental model is:
permitted = what this thread could use
effective = what this thread is using right nowThat is not the whole story, but it is close enough to reason with.
Inheritable
Inheritable is the most misunderstood set because its name sounds stronger than it is.
It does not mean "always survives exec" in a useful privilege-granting sense. It means this set can participate in capability transfer across execve() if the executed file has matching bits in its file inheritable set.
So inheritable is more like a whitelist for future exec transitions than a privilege set that does anything by itself.
This is why so many capability setups that look plausible still fail. People put a bit in permitted and assume it will follow helper binaries automatically. It does not. If the helper chain relies on execve(), you have to think explicitly about inheritable and, on modern systems, often ambient too.
Bounding
Bounding is the hard ceiling for what file capabilities may grant during execve().
This set exists so a process tree can permanently rule out future privilege gain for specific capabilities, even if some executable later carries them as file caps. capabilities(7) is blunt here: once a capability has been dropped from the bounding set with prctl(PR_CAPBSET_DROP, ...), it cannot be restored to that set.
That makes bounding extremely useful for service managers and container runtimes. If a runtime drops CAP_SYS_MODULE and CAP_SYS_ADMIN from the bounding set before launching a workload, then even a later execve() of a file carrying those capabilities cannot get them back through the file permitted path.
Ambient
Ambient was added in Linux 4.3 because the older model was clumsy for non-root programs that needed to pass a small capability through a chain of helper execve() calls.
Ambient capabilities survive execve() of an unprivileged file. They are automatically added to the new permitted set and, for unprivileged exec, effectively become usable without the file itself carrying capabilities.
But ambient has sharp invariants:
- a capability can be ambient only if it is also permitted and inheritable
- lowering the corresponding permitted or inheritable bit automatically lowers ambient too
- executing a privileged file clears ambient entirely
"Privileged file" here means a file with any capability xattr or a setuid or setgid transition. That one rule explains a lot of confusing behaviour in mixed systems.
Why there are five, not three
You will still see older explanations that talk about only permitted, effective, and inheritable. That was once a decent short version. It is no longer sufficient.
If you omit bounding, you cannot explain why a file capability does not land even though the executable looks correctly marked. If you omit ambient, you cannot explain why a non-root helper chain can preserve CAP_NET_BIND_SERVICE without putting file capabilities on every binary in the chain.
Modern Linux capability reasoning is five-set reasoning.
execve() Is Where The Real State Transition Happens
Most capability confusion is really execve() confusion.
A running thread can call capset(2) or libcap wrappers and reshape some of its current state, but the most important transformation happens when the kernel loads a new executable image. That is the moment when thread capability sets, file capability sets, ambient rules, and the bounding set are combined.
capabilities(7) gives the actual algorithm:
P'(ambient) = (file is privileged) ? 0 : P(ambient)
P'(permitted) = (P(inheritable) & F(inheritable)) |
(F(permitted) & P(bounding)) | P'(ambient)
P'(effective) = F(effective) ? P'(permitted) : P'(ambient)
P'(inheritable) = P(inheritable)
P'(bounding) = P(bounding)Where:
P(...)is the thread state beforeexecve()P'(...)is the state afterexecve()F(...)is the file capability state on the executable
That is the centre of the whole system. Read it slowly.
First rule: ambient is conditional
If the file is privileged, ambient becomes zero before anything else matters.
That means ambient is not a general-purpose "keep this capability forever" mechanism. It is explicitly designed for the unprivileged exec path. The moment you execute a file with capability xattrs, or a setuid or setgid binary, ambient is wiped.
Second rule: file permitted is masked by bounding
F(permitted) is ANDed with P(bounding).
That is why the bounding set matters so much operationally. You can attach cap_sys_ptrace+ep to a binary all you like. If the launching thread's bounding set no longer includes CAP_SYS_PTRACE, the new process does not get it from the file permitted path.
This is the kernel's irreversible brake pedal.
Third rule: file inheritable only helps if the thread already opted in
P(inheritable) & F(inheritable) means both sides must agree.
If the thread did not mark a capability inheritable, the file inheritable bit alone does nothing. If the file did not advertise the bit in its inheritable set, the thread inheritable bit alone does nothing. This is a deliberate two-sided gate.
Fourth rule: effective is not always equal to permitted
P'(effective) depends on the file effective bit. That bit is not a full set. It is a single on or off flag stored in the file capability xattr.
If the file effective bit is on, then all newly permitted capabilities are also raised in effective. If it is off, then the new process may have capabilities in permitted that are not immediately usable because they are not in effective.
That distinction exists because some programs understand capability juggling themselves. They may want the power available in permitted, but only enable it in effective for a narrow critical section. Other programs are older or simpler, so the file effective bit asks the kernel to raise the full resulting permitted set into effective automatically.
A concrete example: binding port 443 without root
Suppose a binary has:
- file permitted:
CAP_NET_BIND_SERVICE - file effective bit: on
- file inheritable: empty
And the launching thread has:
- inheritable: empty
- ambient: empty
- bounding: includes
CAP_NET_BIND_SERVICE
Then after execve():
P'(ambient) = 0
P'(permitted) = CAP_NET_BIND_SERVICE
P'(effective) = CAP_NET_BIND_SERVICEThe program can now bind port 443 without being root, and without having any other capability the file did not ask for.
That is the clean least-privilege story everyone wants capabilities to tell.
Another example: helper chain with ambient
Now suppose a service manager wants a non-root worker to keep CAP_NET_BIND_SERVICE across an execve() of a normal binary that has no file caps. It can:
- ensure the capability is in permitted
- ensure it is in inheritable
- raise it into ambient with
prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, ...)
If the executed file is unprivileged, then:
P'(ambient) = CAP_NET_BIND_SERVICE
P'(permitted) = CAP_NET_BIND_SERVICE
P'(effective) = CAP_NET_BIND_SERVICENo file xattr required.
That is precisely the niche ambient was designed to fill.
Why people lose capabilities accidentally
A few recurring failures all fall out of the math above:
- the capability was only in permitted, not effective
- the file effective bit was off
- the capability was missing from the bounding set
- the file was privileged, so ambient was cleared
- the capability never entered inheritable, so the file inheritable path contributed nothing
Once you can read the formula, those failures stop being mysterious. They become bookkeeping errors.
File Capabilities Live On Disk As security.capability
Capabilities do not live only in live thread credentials. Since Linux 2.6.24 they can also be attached to executable files.
The storage mechanism is a filesystem extended attribute called security.capability. Writing it requires CAP_SETFCAP. Tools such as setcap and getcap are convenient wrappers around that xattr.
A common example:
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/web
getcap /usr/local/bin/webWhich typically renders as:
/usr/local/bin/web cap_net_bind_service=epThat short text form hides some important details.
There are three file capability fields
The file side has:
- file permitted
- file inheritable
- file effective bit
That last part matters. The file effective field is not a second full bitmask. It is a single flag meaning "if the new permitted set contains anything, should those bits also be raised into effective immediately?"
This is why setcap 'cap_net_bind_service=+ep' works so often. The e asks for the file effective bit, and p puts the bit in file permitted. Without the effective bit, the executed program may land with the capability in permitted but not usable yet.
The xattr has versions
include/linux/capability.h defines the xattr formats:
VFS_CAP_REVISION_1VFS_CAP_REVISION_2VFS_CAP_REVISION_3
Version 2 expanded the masks to 64 bits once the capability set outgrew 32. Version 3, added in Linux 4.14, is the interesting modern one because it supports namespaced file capabilities.
A version 3 xattr stores not only the capability masks but also a root UID value associated with a user namespace. That lets a binary confer capabilities only when executed in a matching user namespace or one of its descendants.
This is how file capabilities became useful inside user-namespaced container setups without blindly granting the same privilege in the initial host namespace.
File capabilities are not magic superpowers
A file capability only influences the execve() transformation. It does not grant live power to an already running process. Marking a binary with setcap changes what may happen when the kernel loads it, not what happens to some unrelated process that already has the binary mapped.
That sounds obvious when written down, but it is behind many failed operational fixes. Someone sets a capability on disk and expects an already-running service to recover. It does not. The service has to go through a fresh execve() path.
File capabilities can be ignored
execve(2) is explicit here. The kernel ignores file capabilities in the same situations where it ignores setuid and setgid privilege gain, including:
- when
no_new_privsis set on the calling thread - when the filesystem is mounted
nosuid - when the calling process is being ptraced
This is not a minor corner case. It explains a lot of modern sandbox behaviour.
If a service manager sets NoNewPrivileges=yes, or a container runtime relies on no_new_privs, then file capabilities stop being a path to gain privilege on exec. That is the whole point.
Capability-dumb binaries get a safety check
capabilities(7) also describes a useful guardrail for old programs. If a file has the effective capability bit set, the kernel treats it as a "capability-dumb" binary for one specific safety check. If the bounding set masks out part of the file permitted set, and the process therefore does not receive all the capabilities the file asked for, execve() can fail with EPERM.
That failure is there to stop a legacy privileged program from running with less privilege than it silently expects. From the kernel's perspective, partial success would be more dangerous than a hard stop.
UID Changes Still Matter
Capabilities reduced the dominance of UID 0. They did not eliminate it.
Linux still carries compatibility rules that adjust capability sets when user IDs change. capabilities(7) documents several of them, and they matter in real service setups.
Dropping all zero UIDs can clear permitted, effective, and ambient
If one or more of the real, effective, or saved set UIDs was previously 0, and after a UID transition all of them are nonzero, the kernel clears all capabilities from:
- permitted
- effective
- ambient
That is a huge moment in a daemon lifecycle.
A service that starts as root, opens a low port, then switches permanently to a non-root account without arranging SECBIT_KEEP_CAPS or a different capability flow will lose the relevant capability state. That is usually what you want. Sometimes it is not what the developer expected.
Changing effective UID from 0 to nonzero clears effective
Even before the full "all UIDs nonzero" case, simply changing the effective UID from 0 to nonzero clears effective capabilities. So a process may still retain capabilities in permitted while losing the ability to use them until it explicitly raises them again, if policy allows.
SECBIT_KEEP_CAPS and friends exist for a reason
Linux securebits let a thread alter these compatibility behaviours.
The important ones are:
SECBIT_KEEP_CAPSSECBIT_NO_SETUID_FIXUPSECBIT_NOROOTSECBIT_NO_CAP_AMBIENT_RAISE
And each has a locked version that makes the choice irreversible for descendants.
Operationally:
SECBIT_KEEP_CAPSlets a thread retain permitted capabilities when it moves all UIDs to nonzero valuesSECBIT_NO_SETUID_FIXUPtells the kernel to stop auto-adjusting capability sets on zero and nonzero UID transitionsSECBIT_NOROOTdisables the old special treatment of UID 0 during exec
These are not everyday application knobs. They are for runtimes, launchers, and hardened privilege transitions.
If you read a container runtime, a sandbox helper, or a service manager and see prctl(PR_SET_SECUREBITS, ...), that code is deciding how much old root semantics it wants to keep alive.
Root still receives special treatment on exec unless you disable it
This is another place where people overestimate how "pure" the capability model is.
capabilities(7) says that when a process with UID 0 executes a program, or when a setuid-root program is executed, the kernel applies special handling to preserve traditional UNIX expectations. In simplified form, root can still emerge from execve() with all capabilities in the bounding set unless securebits disable that behaviour.
The simplified root case in the man page is:
P'(permitted) = P(inheritable) | P(bounding)
P'(effective) = P'(permitted)That is why securebits matter for hardened environments. If you want a capabilities-only world rather than a half-compatibility layer around root, you must say so explicitly.
User Namespaces Change The Meaning Of "Privileged"
Capabilities are not just bitmasks. They are bitmasks interpreted relative to a user namespace.
This is the part that trips up people moving from host administration to container internals.
CAP_SYS_ADMIN in one namespace is not CAP_SYS_ADMIN everywhere
Inside a user namespace, a thread may hold CAP_SYS_ADMIN relative to that namespace. That can be enough to do things such as:
- create or manage some subordinate namespaces
- mount certain filesystems inside the namespace
- perform namespace-local administration
It does not mean the thread suddenly has host-global administrative power.
Kernel code that uses ns_capable(target_ns, CAP_SYS_ADMIN) is asking about capability relative to target_ns. If the sensitive object belongs to the initial user namespace, then capability in some nested container user namespace is not enough.
This is the key to rootless containers. A process may be UID 0 inside the container and hold a meaningful capability set there, while still being an ordinary unprivileged UID on the host.
Namespaced file capabilities made this more usable
Linux 4.14 added version 3 namespaced file capabilities. These store the namespace root UID in the security.capability xattr so that the capability grant applies only when the executable is run in the matching namespace lineage.
That matters because without namespaced file caps, a file capability attached by a privileged host process was globally meaningful relative to the filesystem mount namespace and initial user namespace. With version 3, a container can have binaries that confer capability inside the container's user namespace without granting the same power to host processes that execute the same inode from outside that namespace context.
CAP_SETFCAP and namespace setup have tightened over time
The current man page notes one subtle rule change: since Linux 5.12, CAP_SETFCAP is also required to map UID 0 in a new user namespace. That is one example of the kernel steadily tightening the boundary around namespace privilege setup.
This is the general pattern with capabilities and namespaces on Linux. The model stays recognisable, but the exact blast radius of certain operations is refined release by release as maintainers find weaker, more accurate privilege splits.
Why containers still drop capabilities aggressively
A container already has namespaces. Why bother with capability drops too?
Because namespaces and capabilities solve different problems.
Namespaces change what a process can see and which objects it is relative to. Capabilities decide what privileged operations the process may request within that view. A container process with a broad capability set is still far more dangerous than one with a narrow set, especially if it finds a kernel bug in a reachable privileged path.
That is why runtimes usually keep a relatively small default set and drop the rest. The point is not only to stop obvious admin actions. The point is to reduce how much privileged kernel code the workload can exercise at all.
CAP_SYS_ADMIN Is Still The Problem Child
Read include/linux/capability.h or capabilities(7) and one thing becomes obvious immediately: CAP_SYS_ADMIN is overloaded to the point of embarrassment.
The man page flatly tells kernel developers not to choose it unless they absolutely must. It even calls it "the new root".
That reputation is deserved.
CAP_SYS_ADMIN covers a huge sprawl of operations, including parts of:
- mount and unmount
- namespace creation and joining in many paths
- privileged filesystem ioctls
- device administration
- performance and BPF compatibility fallbacks
- seccomp installation without
no_new_privs - many bits of historical and awkward system administration state
If you grant CAP_SYS_ADMIN, you have not granted one privilege. You have granted a moving basket of them.
The kernel has been slowly carving pieces out of it:
CAP_PERFMONsince Linux 5.8CAP_BPFsince Linux 5.8CAP_CHECKPOINT_RESTOREsince Linux 5.9
Those splits matter because they let systems grant narrower power. A tracing agent in Milan might need perf events but not mount administration. A BPF loader might need privileged BPF operations but not the whole CAP_SYS_ADMIN bundle.
But the history still shows. Large amounts of privileged kernel surface remain under CAP_SYS_ADMIN, and any threat model that treats it as a minor permission is not serious.
Capabilities Do Not Override Everything
Another common misunderstanding is treating capabilities as a universal bypass layer. They are not.
Having the right capability usually means "this particular capability gate will pass". It does not mean every later layer will also allow the operation.
LSMs can still deny
SELinux, AppArmor, Smack, and other LSMs can deny an operation even if the thread has the needed capability. Capability checks are only one hook path in the kernel's security decision flow.
That means "I have CAP_DAC_OVERRIDE and still got EACCES" is entirely possible if an LSM policy says no.
Seccomp can still deny
Seccomp runs at the syscall boundary. If the filter returns EPERM, KILL, TRAP, or another non-allow action, the syscall never reaches the normal privileged operation path in the way the caller expected.
A thread may hold CAP_SYS_ADMIN and still be unable to call mount(2) because seccomp blocked the syscall number first.
no_new_privs can block gains on exec
As execve(2) states, if no_new_privs is set, then setuid transitions and file capabilities are ignored for privilege gain. This is a crucial containment rule for sandboxes.
Mount flags matter
A filesystem mounted nosuid suppresses setuid behaviour and also causes file capabilities on that mount to be ignored for exec privilege gain. This is one reason why copying a correctly marked binary into a different runtime environment can stop working without any visible change to the xattr itself.
The operation may be gated by a different capability than you think
This sounds trivial, but it is a frequent failure mode. Examples:
- low ports need
CAP_NET_BIND_SERVICE, notCAP_NET_ADMIN - raw sockets need
CAP_NET_RAW - tracing other processes usually needs
CAP_SYS_PTRACE - setting file capabilities needs
CAP_SETFCAP - dropping bounding bits later needs
CAP_SETPCAP
The capability names are descriptive, but the exact mapping still deserves verification against capabilities(7) or the relevant man page before you ship a fix.
How To Inspect And Debug Capabilities Without Guessing
When a capability-related failure lands on your desk, there is a clean debugging order.
1. Inspect the live thread masks
Start with:
grep '^Cap' /proc/$PID/statusOr for a specific thread:
grep '^Cap' /proc/$PID/task/$TID/statusThis tells you the live inheritable, permitted, effective, bounding, and ambient masks. proc_pid_status(5) documents all five fields.
2. Decode symbolically
If libcap tools are available:
capsh --print
getpcaps $PIDThese are much easier to read than raw hexadecimal and immediately show whether the missing bit was absent from effective, missing from bounding, or never present at all.
2A. Verify the kernel's capability universe
On modern kernels, do not assume every host exposes the same highest capability bit. capabilities(7) notes that /proc/sys/kernel/cap_last_cap exposes the highest capability supported by the running kernel. On a recent system you should expect that list to include newer splits such as CAP_BPF, CAP_PERFMON, and CAP_CHECKPOINT_RESTORE. If a fleet spans older kernels, one machine may understand a capability name that another kernel simply cannot grant. That is rare on well-managed estates, but it matters in mixed environments, old appliances, and container hosts that lag their userspace.
3. Inspect the executable on disk
getcap -v /path/to/binaryIf the binary is supposed to gain capability through file xattrs, verify that the xattr is actually there on the deployed filesystem. A package upgrade, a copy operation, a build artefact tarball, or a container image layer rewrite can easily lose xattrs.
4. Check whether privilege gain is suppressed
Look for:
NoNewPrivsin/proc/$PID/status- mount options such as
nosuid - ptrace attachment
- sandbox policy that may be rewriting or filtering the exec path
A correct file capability on disk is irrelevant if execve() is in a state where privilege gain is intentionally disabled.
5. Verify namespace context
For container workloads, ask which user namespace the process lives in and which namespace the kernel object belongs to. CAP_SYS_ADMIN inside a nested namespace is not a universal answer.
6. Remember the operation may have passed the capability check and failed later
If the syscall returned EPERM or EACCES, do not assume the capability check was the failing gate. Audit logs, LSM denials, seccomp filters, and ordinary filesystem permission paths still matter.
A practical debug example
Suppose a service in Prague is supposed to bind port 443 as non-root and fails after a deployment.
Clean checklist:
getcap /usr/local/bin/servicemount | grep ' on /usr/local 'to spotnosuidgrep '^Cap\|^NoNewPrivs' /proc/$PID/status- verify whether a new launcher now sets
NoNewPrivileges=yes - confirm the service went through a fresh
execve()after the xattr was applied
That path will usually find the bug faster than staring at the source and hoping the privilege model becomes obvious by sympathy.
A Clean Pattern For Binding Port 80 Without Full Root
A lot of capability discussions stay abstract, so here is a concrete deployment pattern that actually matches how capabilities are meant to be used.
Say you want a web server to listen on 80 and 443, but you do not want it running as root.
One clean systemd pattern is:
[Service]
User=www-data
Group=www-data
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ExecStart=/usr/local/bin/webWhy this works:
AmbientCapabilities=carriesCAP_NET_BIND_SERVICEthrough the unprivileged exec pathCapabilityBoundingSet=removes unrelated future privilege gain pathsNoNewPrivileges=yesprevents later execs from acquiring new privilege via setuid or file caps- the process runs as
www-data, not as UID 0
This is a cleaner design than running the whole service as root forever, and often cleaner than putting file capabilities on the binary if the service manager is already the component responsible for privilege shaping.
The file-capability alternative is also valid:
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/webThat keeps the privilege attached to the executable rather than the service unit. Which choice is better depends on your operational model:
- attach privilege to the binary if the binary itself should always carry that narrow power
- attach privilege at launch time if the same binary should run with different privilege envelopes in different contexts
What you should not do is keep CAP_SYS_ADMIN, CAP_NET_ADMIN, and friends around just because "the service works that way" and nobody has taken the time to narrow the envelope.
The Mental Model That Actually Holds Up
Linux capabilities are not a replacement word for "root-lite". They are a credential calculus.
To reason about them correctly, keep this model in your head:
- capabilities are per-thread bits inside credentials
- most checks care about the effective set
- permitted is the ceiling for what can be made effective
- inheritable and ambient mainly matter across
execve() - file capabilities are xattrs on executables, not on running processes
- bounding is the irreversible ceiling on future gain from file caps
- user namespaces make capability checks relative, not universal
- UID 0 still has compatibility semantics unless securebits disable them
- capabilities do not bypass LSMs, seccomp,
no_new_privs, or mount flags
Once you internalise those nine rules, Linux capabilities stop looking like folklore and start looking like ordinary state transition logic.
That is the right way to learn them, because the kernel certainly treats them that way.
The paired lab for this topic steps through those transitions visually: live thread sets, file xattrs, bounding masks, ambient carry, namespace-relative checks, and the final allow or deny result. The quiz then checks whether you can read the execve() transformation and spot the common failure modes. If you can do those two things confidently, you understand capabilities well enough to operate them instead of cargo-culting them.