Capabilities Dropping and Privilege Reduction in Sandbox Containers
Dropping Linux capabilities shrinks the attack surface attackers can exploit.

Container security incidents keep tracing back to the same root cause: containers running with more privilege than the workload inside them ever needed. Excessive privileges are widely cited as a leading cause of container security incidents, and a Red Hat Kubernetes adoption report found that 42% of respondents cite security as a top concern, with nine in ten organizations reporting some kind of container incident. The pattern behind most of those incidents isn't exotic: an overprivileged container plus a garden-variety application bug equals lateral movement, and sometimes full infrastructure takeover, rather than a breach that stays contained where it started.
Capability dropping and privilege reduction sit at the center of fixing this, and the problem has taken on new urgency now that a growing share of code running inside containers is generated by an LLM rather than written and reviewed line by line by an engineer. That code arrives at runtime, sometimes shaped by inputs an attacker controls, and it executes paths nobody on the team has actually read. So the privilege surface that code can touch, not the code itself, becomes the real attack surface. This piece walks through what capabilities are, which ones to drop first, and how that single control layers with non-root users, seccomp, AppArmor or SELinux, and namespaces to build something close to genuine isolation.
What Linux capabilities actually are and what the default Docker set grants
Before Linux kernel 2.2, the security model was binary: a process was either root, with total control over the machine, or it wasn't, with a fixed and much narrower set of permissions. Kernel 2.2 broke that binary apart into capabilities, roughly 40-odd independent privilege units that can be granted or withheld one at a time. The useful way to think about it: root by itself no longer means all-powerful. The real formula is root plus whatever capabilities that root process still holds, and stripping capabilities away from a root process meaningfully constrains what it can actually do, even though the UID still reads as 0.
Docker starts every container with 14 of these enabled by default: CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FOWNER, CAP_FSETID, CAP_KILL, CAP_SETGID, CAP_SETUID, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_NET_RAW, CAP_SYS_CHROOT, CAP_MKNOD, CAP_AUDIT_WRITE, and CAP_SETFCAP. That list wasn't built with attackers in mind. It was built so that ordinary applications, database servers, web servers, batch jobs, work out of the box without an engineer having to hand-tune permissions before anything runs. Compatibility was the design goal, not a minimized attack surface, and the difference between those two goals is basically the subject of this whole article.
Worth sitting with for a second: the Linux kernel exposes something like 340 syscalls in total. Every capability left switched on is a set of those syscalls that stays reachable from inside the container. Drop a capability, and you close off a slice of that 340-syscall surface before an attacker even gets to try anything.
Which capabilities to drop first and why each one matters for sandbox workloads
OWASP's Container Security Cheat Sheet puts it plainly in its third rule: drop everything by default, then add back only what's proven necessary. That flips the usual posture. Instead of asking "what could go wrong if I leave this on," the engineer has to justify each addition on its own merits. It's a small shift in framing that produces a very different default outcome.
Some capabilities are safe to drop for almost any sandbox workload, full stop. CAP_AUDIT_WRITE lets a process write to the kernel audit log, something application code essentially never legitimately does, but that an attacker can abuse to plant misleading entries and cover tracks after the fact. CAP_MKNOD, the ability to create special device files, is a classic preparatory step in container escape chains and has no place in a sandbox. CAP_NET_RAW opens the door to raw and packet sockets, which matters if someone genuinely needs ping or tcpdump inside the container, but otherwise just hands a sandboxed process a way to craft raw IP packets and slip past DNS-based egress controls. CAP_SETFCAP and CAP_SETPCAP both touch privilege escalation mechanics directly (the former sets file capabilities on binaries, the latter modifies another process's capability set) and neither has a legitimate reason to exist in a sandboxed workload. CAP_SYS_CHROOT rounds out the list: chroot inside a container is not something normal applications do, but it shows up repeatedly in escape attempts.
Other capabilities depend on what the workload actually needs. CAP_CHOWN and CAP_FOWNER matter for something like a database container that has to take ownership of its data directory on first boot, but a stateless API server has no use for either. CAP_SETUID and CAP_SETGID only matter if a process starts as root and deliberately drops down to a less-privileged user afterward; skip that pattern and run as non-root from the start, and neither capability is needed at all. CAP_NET_BIND_SERVICE exists purely to let a process bind to a port under 1024, so running the service on a high port, 8080 instead of 80, removes the need entirely. CAP_DAC_OVERRIDE deserves particular suspicion: it's the broadest file-permission bypass in the default set, letting a process read, write, or execute files regardless of who owns them. If a workload genuinely needs it, that's often a sign the filesystem ownership setup needs fixing, not a sign the capability should stay.
And then there's a short list that has no business being added to a sandbox container under any circumstance. CAP_SYS_ADMIN alone covers an exceptionally broad set of operations — mounting filesystems, configuring namespaces, poking at kernel internals — and granting it basically dissolves the isolation model the container was supposed to provide. CAP_SYS_PTRACE lets a process read and write the memory of anything it can trace, which is the mechanism behind most in-process escape exploits. CAP_SYS_MODULE loads kernel modules directly, a fast lane to full host compromise. CAP_NET_ADMIN reconfigures network interfaces and routing tables, breaking tenant isolation in any shared environment. CAP_DAC_READ_SEARCH bypasses normal read and directory-search permission checks, making it another capability with serious escape-amplification potential if added to a sandbox container.
The workflow that makes all this manageable in practice: start with --cap-drop=ALL, run the container, and add capabilities back one at a time only when something actually breaks. That inverts the usual debugging effort. Instead of hardening after the fact, justification becomes the default posture from the first run. To check what's actually in effect at runtime, capsh --print, getpcaps, and /proc/1/status | grep Cap all show the live capability set, and capsh --decode=<hex> translates the raw bitmask from /proc into names an engineer can actually read.
Running containers as non-root and setting no-new-privileges alongside capability drops
Capability dropping leaves one gap wide open: a process running as UID 0, even with every capability stripped, can still execute any setuid binary sitting in the image, and it can still exploit kernel bugs that check for UID 0 rather than checking capability bits. Dropping capabilities narrows what root can do through the sanctioned channels. It does nothing about a badly chosen base image handing the attacker a shortcut around those channels entirely.
And base images are frequently exactly that shortcut. Research from NSFOCUS Security Labs found that 76% of Docker Hub images carry known vulnerabilities, and 67% carry high-severity ones. Some of that risk sits in content that capability dropping simply doesn't touch, because privilege can be embedded in the image itself rather than in the container's capability set.
Running as non-root closes that gap directly. In practice that means creating a dedicated user and group in the Dockerfile with a fixed UID and GID, something like 5000:5000, rather than letting the system auto-assign one; a fixed UID keeps volume mount permissions consistent across container restarts and rebuilds, where auto-assigned UIDs tend to drift. Use COPY --chown=appuser:appgroup directly rather than a separate RUN chown afterward, since the separate step leaves behind an image layer still owned by root. The USER directive belongs after the steps that genuinely need root, package installs, file setup, not before them. For the tightest possible restriction, run as UID 65534, the traditional "nobody" account: no home directory, no shell, no group memberships beyond its own.
Non-root alone still isn't the full story, because a process can gain new privileges mid-execution through exec calls or setuid binaries even after starting as a low-privilege user. That's what the no-new-privileges flag closes off. Setting security_opt: no-new-privileges:true in Compose sets the PR_SET_NO_NEW_PRIVS flag on the process, and once that flag is set, no child process it spawns, including anything launched via exec, can gain capabilities or setuid escalation beyond what the parent already had. In Kubernetes, allowPrivilegeEscalation: false in a pod spec maps to that same kernel flag, and its absence is a frequently observed misconfiguration in production clusters.
Pair that with a read-only root filesystem (read_only: true), plus explicit tmpfs mounts for the few paths that genuinely need write access, like /tmp, and a standard post-exploitation move, writing a persistent payload to the container's filesystem, simply has nowhere to land. Stack all four together (non-root UID, dropped capabilities, no-new-privileges, read-only root) and the result is a process that can't escalate, can't write anything durable to the image, and can't gain new privilege even if it manages to run a setuid binary that somehow made it into the image.
Why seccomp is a different control than capability dropping, and why both are needed
Capabilities and seccomp look similar on the surface, both restrict what a process can do, but they operate on different axes entirely. Capabilities gate which classes of privilege a process holds. Seccomp gates which syscalls it's allowed to make in the first place, independent of whatever privilege it holds. Two different filters on the same underlying kernel surface, and dropping one doesn't substitute for skipping the other.
Docker's default seccomp profile blocks around 44 syscalls out of the large pool the kernel exposes, things like ptrace, personality, and keyctl, syscalls that ordinary applications essentially never call but that show up constantly in exploitation and privilege-escalation chains. What seccomp doesn't do matters just as much: every syscall it still allows runs the full host kernel code path behind it, so a vulnerability in, say, the kernel's write implementation or its network stack can't be blocked by seccomp at all, because the syscall triggering that code was permitted in the first place.
The two controls reinforce each other rather than overlap redundantly. Dropping CAP_NET_RAW blocks raw socket creation through the capability check. A custom seccomp profile can independently block the socket() syscall itself, filtered by specific arguments, adding a second, separate enforcement point against the same underlying threat. For a workload built to run untrusted code, an agent executing arbitrary Python scripts, say, the legitimate syscall surface it actually needs is usually far narrower than Docker's default profile allows. That workload has no business calling mount, unshare, or clone with arbitrary flags, and a custom profile can say so explicitly instead of relying on the default's broader allowances.
Kubernetes handled this differently than Docker for a long stretch. Docker has shipped a default seccomp profile since early on, but Kubernetes applied no seccomp profile at all by default until the SeccompDefault feature reached general availability in version 1.27, after starting as an alpha feature back in v1.22. Even at GA, that default only takes effect if an administrator explicitly passes the --seccomp-default flag to the kubelet. Clusters running older versions, or newer versions without that flag set, run with no seccomp coverage at all unless it's configured per pod.
AppArmor and SELinux as the third enforcement layer: what they add and where they fall short
Mandatory access control systems add something neither capabilities nor seccomp can provide on their own: enforcement tied to specific files, paths, or labels rather than to privilege classes or syscall names. A process can have CAP_DAC_OVERRIDE dropped entirely and still, in theory, have a misconfigured MAC profile let it wander somewhere it shouldn't. Or, run the scenario the other way: a well-written SELinux or AppArmor profile catches an access attempt that the capability and seccomp layers had no reason to block, because neither of them was ever built to reason about file paths or labels in the first place.
AppArmor, the default on Debian and Ubuntu, writes its rules against file paths. That makes profiles relatively easy to write and read, but it opens a door: a symlink or a directory rename can move a path outside whatever the profile covers, and the enforcement simply doesn't follow. SELinux, standard on RHEL, CentOS, and Fedora, enforces by security label instead of path, which closes that particular bypass, and it extends mandatory access control across processes, files, and network sockets alike. The tradeoff shows up in how hard it is to configure correctly; SELinux's complexity is well known enough that plenty of administrators just flip it to permissive mode, or disable it outright, rather than invest the time to write it properly.
That's the case for running all three layers together rather than picking one. If an attacker finds a zero-day that slips past the seccomp filter, whatever AppArmor or SELinux profile is in place still governs what that syscall access can actually touch. None of the three, capabilities, seccomp, MAC, cover identical ground. They overlap in places and diverge in others, and that's what makes stacking them worthwhile rather than redundant.
Kubernetes' Pod Security Standards more or less codify this stack as the expected baseline. The Baseline level disallows privileged containers, hostPID, hostNetwork, hostIPC, hostPath, and hostPort, requires seccomp profiles that aren't set to Unconfined, and enforces some baseline expectations around AppArmor and SELinux. The Restricted level goes further, requiring non-root users and tightening the capability set beyond what Baseline demands.
Worth being honest about, though: an AppArmor profile that's too permissive, or missing key rules, offers essentially no protection at all. Operationally, an incomplete profile behaves the same as no profile, and under delivery pressure, teams ship exactly that kind of permissive profile far more often than they invest the time to profile real application behavior properly.
How namespaces and cgroups relate to capability reduction, and what they cannot do
Namespaces are what make a container look like an isolated machine in the first place. Linux provides eight types: PID namespaces isolate the process tree, Mount namespaces isolate filesystem mount points, Network namespaces isolate interfaces and routing tables, User namespaces isolate UID and GID mappings, UTS namespaces isolate the hostname, IPC namespaces isolate shared memory and semaphores, Cgroup namespaces isolate the cgroup root, and Time namespaces isolate monotonic and boot clocks.
But namespaces draw visibility walls, not security boundaries, and that distinction matters more than almost anything else in this piece. A namespace stops a process from seeing resources outside its own slice: it can't see other containers' process IDs, other containers' mounts, other containers' network interfaces. What a namespace does not do is stop a process from exploiting a vulnerability in the kernel code that implements the namespace in the first place. The wall controls what's visible. It says nothing about what's exploitable underneath.
That's the sharp edge worth sitting with. Every container on a given host, no matter how namespaced, no matter how many capabilities have been stripped, makes its syscalls to the exact same host kernel as every other container on that machine. A vulnerability in how that kernel handles any syscall a container is still allowed to make is a vulnerability every container on the host shares, regardless of how carefully namespaces have partitioned what each one can see. Namespaces make isolation visible. They don't make it airtight, and that's precisely why capability dropping, seccomp filtering, and mandatory access control all exist as separate, overlapping layers rather than as one another's substitutes. Each one closes a different door. None of them, alone, closes all of them.


