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:

PrimitiveRestrictsAnswers
CapabilitiesWhich privileged operations root may perform"Can it change the clock, raw-socket, mount?"
seccompWhich syscalls the process may issue at all"Can it call keyctl, ptrace, bpf?"
User namespaceWhat container-root maps to on the host"If it escapes, who is it?"
read-only rootWhether the image filesystem can be modified"Can it write a binary and execute it?"
allowPrivilegeEscalationWhether 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 restricted profile'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.