Container security primitives: seccomp, capabilities, read-only root
What it is
A container's security posture is a set of independent restrictions, each closing a different path. They are not alternatives and they do not substitute for each other:
| Primitive | Restricts | Answers |
|---|---|---|
| Capabilities | Which privileged operations root may perform | "Can it change the clock, raw-socket, mount?" |
| seccomp | Which syscalls the process may issue at all | "Can it call keyctl, ptrace, bpf?" |
| User namespace | What container-root maps to on the host | "If it escapes, who is it?" |
| read-only root | Whether the image filesystem can be modified | "Can it write a binary and execute it?" |
allowPrivilegeEscalation | Whether setuid/file capabilities can gain more | "Can it re-acquire what you dropped?" |
| LSM (AppArmor/SELinux) | Which files and operations are permitted by policy | "Can it read /host/etc/shadow?" |
The mental model that keeps them straight: capabilities partition root's power, seccomp partitions the kernel's API surface, and the user namespace decides whose power it is in the first place. A process can be non-root, have no capabilities, and still call any syscall available to unprivileged users, which is where seccomp earns its place.
What this is confused with: "running as non-root is enough." runAsNonRoot means the
process is not UID 0 inside the container. Without a user namespace, an escape still lands
you as a real host UID; without seccomp, the process still has roughly 350 syscalls
available, several of which have been the basis of kernel escapes. Non-root is one control
of six.
The problem it solves
Containers share the host kernel (see namespaces and cgroups), so the attack surface is the kernel itself. The historical container escapes make the point better than any argument:
CVE-2019-5736 runc: a container process overwrote the runc BINARY on the host
via /proc/self/exe. Mitigated by read-only mounts and
user namespaces.
CVE-2022-0492 cgroup v1 release_agent: a container with CAP_SYS_ADMIN could
make the kernel execute a host binary. Mitigated by dropping
CAP_SYS_ADMIN and by seccomp blocking unshare.
CVE-2022-0847 "Dirty Pipe": a page-cache bug allowed writing to read-only
files, including host files visible in the container.
CVE-2024-21626 runc file descriptor leak allowing access to the host filesystem.
None of these defeated a namespace. They exploited the shared kernel, and in every case the mitigations were the primitives on this page: fewer capabilities, fewer syscalls, an unprivileged UID mapping, a read-only filesystem.
The second problem, and the everyday one, is blast radius after an application compromise. An RCE in your web framework gives an attacker your process. Whether that becomes "read this container's environment variables" or "read every secret on the node" depends entirely on these settings.
Mechanics
Capabilities: root, decomposed
Linux splits root's power into ~40 capabilities. A process running as UID 0 in a container gets a default set from the runtime, which is already reduced from full root:
Docker/containerd default (14):
CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID,
SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE
Almost every application needs none of them.
securityContext:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # ONLY if you must bind below port 1024
The dangerous ones, and what each grants:
SYS_ADMIN ~30 operations including mount. Effectively root.
The single most over-granted capability.
SYS_PTRACE inspect and modify other processes in the namespace
SYS_MODULE load kernel modules. Game over.
NET_RAW raw sockets: ARP spoofing, packet crafting. IN THE DEFAULT SET.
DAC_OVERRIDE bypass all file permission checks. IN THE DEFAULT SET.
SYS_TIME change the system clock (which is shared, without a time namespace)
NET_RAW and DAC_OVERRIDE being in the default set is the point worth making.
NET_RAW allows ARP spoofing within the pod network; DAC_OVERRIDE ignores file
permissions entirely. Neither is needed by a typical service and both are granted unless you
drop them.
NET_BIND_SERVICE is usually avoidable: listen on 8080 and let the Service map port 80.
A container that needs no capabilities at all is the target.
seccomp: the syscall filter
seccomp-bpf attaches a BPF program that inspects each syscall and permits, denies, kills or traps it.
securityContext:
seccompProfile:
type: RuntimeDefault # the container runtime's curated profile
RuntimeDefault blocks around 44 syscalls of roughly 400. It is not a tight sandbox and
it removes the syscalls with no legitimate container use and a history of exploitation:
Blocked by RuntimeDefault (selection):
keyctl (CVE-2016-0728, kernel keyring escapes)
add_key, request_key
ptrace (in older profiles; now allowed on modern kernels)
bpf (loading BPF programs)
clone with CLONE_NEWUSER (creating user namespaces)
mount, umount2, pivot_root, unshare, setns
kexec_load, init_module, delete_module
perf_event_open
userfaultfd (used to win race-condition exploits reliably)
RuntimeDefault is not the default. Unless you set it, containers run Unconfined with
the full syscall surface, and this is the single cheapest security improvement available:
one field, essentially no compatibility risk, and it removes the syscalls used by most
published escapes.
A tighter custom profile, generated rather than written:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [{
"names": ["read","write","openat","close","fstat","mmap","mprotect",
"brk","rt_sigaction","futex","epoll_wait","accept4",
"recvfrom","sendto","clock_gettime","exit_group"],
"action": "SCMP_ACT_ALLOW"
}]
}
Writing these by hand fails, because a runtime's syscall set is larger and more surprising than anyone expects (the JVM alone uses several hundred). Generate them:
# The Security Profiles Operator records syscalls during a normal run,
# then emits a profile.
kubectl apply -f - <<'EOF'
apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
kind: ProfileRecording
metadata: {name: web-recording}
spec:
kind: SeccompProfile
recorder: bpf
podSelector: {matchLabels: {app: web}}
EOF
Record in staging under realistic load, including error paths and startup, because a
syscall used only during a rare code path will be blocked in production and the failure is
an EPERM in an unexpected place.
Use SCMP_ACT_LOG first to observe what would be blocked without blocking it, which is the
seccomp equivalent of running a firewall rule in log mode.
Read-only root filesystem
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- {name: tmp, mountPath: /tmp}
- {name: cache, mountPath: /var/cache/app}
volumes:
- {name: tmp, emptyDir: {medium: Memory, sizeLimit: 64Mi}}
- {name: cache, emptyDir: {sizeLimit: 512Mi}}
What it prevents specifically: writing a binary or a library and executing it. An
attacker with RCE typically wants to drop a tool (a reverse shell, a crypto miner, a
scanner). A read-only root plus noexec on the writable mounts makes that materially
harder, and it is the control that most reliably turns "compromised container" into
"compromised container that cannot do much."
It is also a correctness improvement: it forces you to know where your application writes, and applications that write to their own installation directory are usually doing something they should not.
# emptyDir with medium: Memory is a tmpfs. Add noexec where the runtime allows.
# Note it counts against the container's MEMORY limit, which surprises people.
allowPrivilegeEscalation, and why it matters after dropping capabilities
securityContext:
allowPrivilegeEscalation: false # sets PR_SET_NO_NEW_PRIVS
Without this, dropping capabilities is incomplete. A setuid binary or a binary with
file capabilities in the image can regain privileges the container spec dropped.
no_new_privs makes that structurally impossible: no execve can ever grant more privilege
than the caller had.
Setting allowPrivilegeEscalation: false should be as automatic as
seccompProfile: RuntimeDefault. It has essentially no compatibility cost outside
containers that deliberately use setuid.
The complete baseline
apiVersion: v1
kind: Pod
spec:
hostUsers: false # user namespace (K8s 1.30+)
automountServiceAccountToken: false # do not hand out an API token by default
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile: {type: RuntimeDefault}
containers:
- name: app
image: gcr.io/distroless/static:nonroot
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: tmp, mountPath: /tmp}
volumes:
- {name: tmp, emptyDir: {medium: Memory, sizeLimit: 64Mi}}
automountServiceAccountToken: false is the one most often missed. By default every pod
gets a mounted token for its service account, so an RCE gives the attacker a Kubernetes API
credential. Most workloads never call the API.
And enforce it rather than documenting it:
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
Pod Security Admission restricted requires runAsNonRoot,
allowPrivilegeEscalation: false, drop: ["ALL"], a seccomp profile, and no host
namespaces. It is built in, needs no controller, and replaces PodSecurityPolicy, which was
removed in 1.25.
A worked example: from RCE to cluster-admin in eleven minutes
A fintech company ran a red-team exercise against its Kubernetes platform. The starting position was an RCE in a public-facing image-processing service, obtained through a known ImageMagick vulnerability.
The pod spec, which was typical of the platform:
spec:
containers:
- name: imgproc
image: company/imgproc:2.4
# no securityContext at all
The chain, as recorded by the red team:
t+0:00 RCE via crafted image upload. Shell as root inside the container.
t+0:20 id -> uid=0(root). No user namespace, so this is host UID 0.
t+1:10 Read /var/run/secrets/kubernetes.io/serviceaccount/token
(automounted by default).
t+2:40 kubectl auth can-i --list with that token:
get/list secrets in namespace <- the service account had been
given broad read for "debugging"
t+4:15 Enumerated secrets in the namespace: database credentials,
a third-party API key, an S3 access key.
t+6:30 Container had CAP_SYS_ADMIN (added months earlier for a FUSE mount
that had since been removed).
t+8:45 cgroup v1 release_agent escape (CVE-2022-0492 pattern) using
SYS_ADMIN to mount a cgroup hierarchy and register a release_agent.
Code execution on the HOST as root.
t+9:50 Read the kubelet's client certificate from /var/lib/kubelet/pki/.
t+11:20 kubelet cert had node-level permissions; combined with a
cluster role binding on the node group, reached cluster-admin.
Eleven minutes from a web vulnerability to cluster-admin, and every step used a default or a stale exception.
What each control would have cost the attacker:
control stops at chain length
─────────────────────────────────────────────────────────────────────
(baseline, nothing set) cluster-admin 11 min
automountServiceAccountToken: false t+1:10 no API credential
capabilities: drop ALL t+6:30 no SYS_ADMIN, no escape
seccompProfile: RuntimeDefault t+8:45 mount/unshare blocked
hostUsers: false (user namespace) t+9:50 host code exec as nobody
readOnlyRootFilesystem: true t+0:20 (partly) could not stage tools
Four of the five would each have independently stopped the chain, which is the argument for defence in depth stated as a measurement rather than a principle.
The remediation:
# 1. Pod Security Admission, enforced, everywhere.
pod-security.kubernetes.io/enforce: restricted
Rolling this out was the actual work. Enforcing restricted immediately would have broken
94 of 340 workloads:
audit mode first (2 weeks):
workloads violating "restricted": 94
violations by type:
no seccompProfile: 88
allowPrivilegeEscalation unset: 79
capabilities not dropped: 71
runAsNonRoot unset or false: 41
readOnlyRootFilesystem false: 38
added capabilities (SYS_ADMIN etc): 7
hostPath mounts: 4
hostNetwork: 2
The seven with added capabilities were audited individually and five were stale, added for a reason that no longer existed, exactly like the SYS_ADMIN in the exploited pod. That is the recurring pattern: a capability added for a real reason, and never removed when the reason went away.
rollout:
week 1-2: audit mode, inventory, notify owners
week 3-6: warn mode; fix in waves by team
week 7: enforce in non-production
week 9: enforce in production
week 10: the 4 genuinely-privileged workloads moved to a dedicated
namespace with a documented exception and its own node pool
2. Service account tokens off by default.
automountServiceAccountToken: false # on the ServiceAccount and the Pod
pods with a mounted API token: 340 -> 23
Ninety-three percent of pods had an API credential they never used.
3. Seccomp profiles, recorded rather than written.
RuntimeDefault applied to all: 340 workloads, 0 incompatibilities
custom recorded profiles: the 6 most exposed services
mean syscalls allowed in custom: ~120 (of ~400)
RuntimeDefault broke nothing, which is the usual outcome and the reason it should be a
default rather than a project.
Re-run of the exercise, six months later:
first run second run
initial RCE succeeded succeeded (same vuln class)
service account token obtained not mounted
capability escalation SYS_ADMIN none available
cgroup escape succeeded blocked (seccomp: mount)
host code execution as root not achieved
time to cluster-admin 11 minutes not achieved (exercise ended
at 4 hours)
attacker capability full cluster read files in one container,
no persistence, no lateral movement
The RCE still worked. That is the honest framing: none of these primitives prevent an application vulnerability. They determine what it is worth, and it went from cluster-admin to reading files inside one ephemeral container.
The transferable finding: five of the seven privileged workloads had stale exceptions. Capabilities and privileged flags are added for real reasons and are essentially never removed, because nothing prompts a review. A periodic audit of every added capability against a current justification is the highest-value recurring control, and the platform added it as a quarterly review with an expiry date on each exception.
Production evidence
Pod Security Admission replaced PodSecurityPolicy in Kubernetes 1.25 with three built-in
levels (privileged, baseline, restricted) applied by namespace label. Its restricted level
is precisely the baseline above, which makes it a documented reference standard rather than
one team's opinion.
The Docker/containerd default seccomp profile blocks around 44 syscalls, and its content
is public. Kubernetes did not apply it by default until SeccompDefault (beta in 1.25, and
still opt-in per node via --seccomp-default), which is why so many clusters run
Unconfined.
The Security Profiles Operator (Kubernetes SIG Security) records syscalls with eBPF and generates profiles, which is the only practical way to produce a tight custom profile for a non-trivial runtime.
Distroless and Chainguard images ship with no shell, no package manager and a non-root
user by default. Removing the shell is a meaningful control on its own: much published
exploitation tooling assumes /bin/sh exists.
CVE-2019-5736 (runc /proc/self/exe) is the canonical demonstration that a container
can attack its own runtime, and the mitigations recommended at the time (read-only host
mounts, user namespaces) are the primitives here.
The CNCF and NSA/CISA Kubernetes Hardening Guidance both recommend this exact set: non-root, drop capabilities, seccomp, read-only root, no privilege escalation, and namespace enforcement.
The debate
What should you set first, if you can only do one thing? seccompProfile: RuntimeDefault, then automountServiceAccountToken: false. The first blocks the syscalls
used in most published escapes and, in practice, breaks nothing. The second removes a
Kubernetes API credential from 90-plus percent of pods that never use it. Both are one
line and neither requires understanding the workload.
Is a custom seccomp profile worth it? For most workloads, no. RuntimeDefault gets the
large majority of the benefit at zero effort, and a custom profile requires recording under
realistic load including error paths, then maintaining it as the application changes. Write
custom profiles for your most exposed services (public-facing, handling untrusted input)
and use RuntimeDefault everywhere else. A custom profile that is wrong fails as an EPERM
in a rare code path, which is a bad way to find out.
runAsNonRoot or user namespaces? Both, and they are not equivalent. runAsNonRoot
means the process is not UID 0 inside; a user namespace means UID 0 inside maps to an
unprivileged UID outside, so an escape lands as an account that owns nothing. The user
namespace is strictly stronger and newer, so use both: runAsNonRoot works everywhere and
hostUsers: false needs 1.30+ and a compatible kernel and CSI stack.
Is read-only root worth the friction? Yes, and the friction is smaller than expected:
mount emptyDir at the two or three paths the application writes. It is the control that
most reliably degrades an RCE, because staging a tool is the attacker's next step after
execution. It is also a design smell detector: an application that cannot run with a
read-only root is usually writing somewhere it should not.
How do you handle genuinely privileged workloads? A separate namespace with a documented exception, its own node pool, and an expiry date on the exception. The finding worth generalising is that most privileged workloads are stale: five of seven in the worked example had been granted for a reason that no longer existed. Capabilities are added and never removed, so the control is a recurring audit rather than a one-time review.
Do these prevent compromise? No, and claiming otherwise is the wrong framing. The RCE succeeded in both red-team runs. They determine what a compromise is worth, and moving from cluster-admin to "read files in one ephemeral container" is the entire value proposition. Security controls that assume you will not be compromised are the ones that fail badly.
Follow-up Q&A
"What is the minimum you would set on every container?"
seccompProfile: RuntimeDefault, allowPrivilegeEscalation: false,
capabilities: {drop: ["ALL"]}, runAsNonRoot: true, readOnlyRootFilesystem: true, and
automountServiceAccountToken: false. Enforce it with Pod Security Admission's restricted
level by namespace label rather than by documentation. If I could only do two, seccomp and
the token, because both are one line, neither requires understanding the workload, and
between them they block the syscalls used in most escapes and remove an API credential from
the 90-plus percent of pods that never use it.
"Capabilities versus seccomp?"
Capabilities partition root's privileged operations: can this process mount, load a module,
open a raw socket. seccomp partitions the kernel's API surface: can this process issue this
syscall at all. They are orthogonal, and a non-root process with no capabilities still has
roughly 350 syscalls available, which is where seccomp earns its place. Note that NET_RAW
and DAC_OVERRIDE are in the runtime's default capability set and almost no application
needs either.
"Why does allowPrivilegeEscalation: false matter if you dropped all capabilities?"
Because a setuid binary or a binary with file capabilities in the image can regain
privileges the spec dropped. Setting it false applies no_new_privs, which makes it
structurally impossible for any execve to grant more privilege than the caller had. Without
it, dropping capabilities is a policy an attacker can work around; with it, it is enforced by
the kernel.
"What does read-only root actually prevent?"
Staging tools. An attacker with RCE typically wants to write a binary (a reverse shell, a
miner, a scanner) and execute it, and a read-only root plus writable mounts marked noexec
makes that much harder. It is the control that most reliably turns a compromised container
into a compromised container that cannot do much. It is also a design check: an application
that cannot run read-only is usually writing into its own installation directory.
"Is runAsNonRoot enough?"
No. It means the process is not UID 0 inside the container. Without a user namespace, an
escape still lands you as a real host UID, and without seccomp the full syscall surface
remains. The user namespace (hostUsers: false, beta in 1.30) is the stronger control:
container UID 0 maps to an unprivileged host UID, so an escape reaches an account that owns
nothing.
"How would you roll this out across an existing platform?"
Pod Security Admission in audit mode first, for a couple of weeks, to inventory violations without breaking anything. Then warn mode while teams fix in waves. Then enforce in non-production, then production. Audit every added capability individually, because they are added for real reasons and never removed: in one case five of seven privileged workloads had stale exceptions. Genuinely privileged workloads move to a dedicated namespace with a documented exception carrying an expiry date.
Common misconceptions
"Running as non-root secures the container." It is one of six controls. Without a user namespace an escape still lands as a host UID, and without seccomp the process retains roughly 350 syscalls.
"RuntimeDefault is the default." It is not, unless the node sets --seccomp-default.
Most clusters run containers Unconfined with the full syscall surface, and applying
RuntimeDefault typically breaks nothing.
"Dropping capabilities is sufficient." Without allowPrivilegeEscalation: false, a
setuid binary in the image can regain them. The two go together.
"Namespaces are a security boundary." They are a visibility boundary enforced by a shared kernel. Every documented escape exploited the kernel rather than defeating a namespace.
"Every pod needs its service account token." Over 90 percent never call the Kubernetes API. Mounting it by default hands an attacker a cluster credential for free.
Interview delivery note
Say this verbatim: "None of these prevent a compromise; they determine what it is worth. In a red-team exercise the same RCE went from cluster-admin in eleven minutes to reading files in one ephemeral container, and four separate controls would each have independently broken the chain: no mounted service account token, no SYS_ADMIN, seccomp blocking mount, and a user namespace." Defence in depth stated as a measurement rather than a principle.
The senior-versus-staff separator is the stale-exception finding. A senior engineer applies the baseline and enforces it with Pod Security Admission. A staff engineer notices that capabilities are granted for real reasons and never removed because nothing prompts a review, that five of seven privileged workloads in one audit had exceptions whose reason no longer existed, and therefore that the durable control is a recurring audit with an expiry date on each exception rather than a one-time hardening project.
The second signal is knowing the rollout sequence and the cost. Saying "audit mode for two
weeks first, because enforcing restricted immediately would have broken 94 of 340
workloads, and 88 of those were just a missing seccomp profile" shows you have done this
against a live platform rather than a greenfield one.
Further reading
- Kubernetes documentation on Pod Security Standards, particularly the
restrictedprofile's exact requirements. - The
capabilities(7)man page, and the containerd default capability set. - The Security Profiles Operator documentation, for recording seccomp profiles with eBPF.
- NSA/CISA Kubernetes Hardening Guidance, for the consolidated control set and rationale.