Back to Blog

Pod securityContext Internals for the CKS Exam: Linux Capability Sets, Runtime Default Capabilities, Why Added Capabilities Vanish for Non-Root Users, allowPrivilegeEscalation & no_new_privs, privileged Containers, fsGroup, fsGroupChangePolicy, supplementalGroupsPolicy, Sysctls, procMount & How Every Field Maps to Pod Security Standards

A hands-on CKS guide to what the Pod securityContext actually does at the kernel and runtime level: the five Linux capability sets and the execve transformation rules, the 14-capability containerd default versus the 9-capability CRI-O default, how to decode CapEff from /proc/1/status, why capabilities.add has no effect for a non-root user under runc and what to do instead, what allowPrivilegeEscalation: false really blocks via no_new_privs, everything a privileged container switches off, runAsUser/runAsGroup/runAsNonRoot semantics, fsGroup with fsGroupChangePolicy and the Strict supplementalGroupsPolicy, safe versus unsafe sysctls and allowedUnsafeSysctls, procMount Unmasked, pod-level versus container-level precedence, a field-by-field Baseline/Restricted mapping, a verification cheat sheet, scenario drills, common mistakes and an FAQ.

By Sailor Team , October 27, 2026

Every CKS candidate can type the restricted-profile boilerplate from memory: runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], seccompProfile.type: RuntimeDefault. Far fewer can answer the follow-up questions the exam, and production, actually ask. Which capabilities did the container have before you dropped them? Why does add: ["NET_BIND_SERVICE"] still not let your non-root process bind port 80? What exactly does allowPrivilegeEscalation: false switch off, and why does it make sudo and ping fail? Which six things does privileged: true disable at once? Why did fsGroup fix one volume and not another?

This guide answers those questions at the level the kernel and the container runtime see them. It assumes you know the basic field names (the CKAD securityContext and ServiceAccounts guide covers the entry-level view) and deliberately does not re-teach seccomp and AppArmor profiles, which the System Hardening deep dive owns, or Pod Security Admission mechanics, which the Pod Security Standards guide owns. It sits under two CKS curriculum bullets: “Use appropriate pod security standards” in Minimize Microservice Vulnerabilities and “Appropriately use kernel hardening tools” in System Hardening. Facts below are checked against the Kubernetes v1.37 documentation, the containerd and CRI-O source and configuration references, and the Linux capabilities(7) manual.

The field map: pod level versus container level

The pod-level spec.securityContext is a PodSecurityContext; each container’s securityContext is a SecurityContext. They are different types with overlapping fields, and the rules are:

FieldPod levelContainer levelPrecedence
runAsUser, runAsGroup, runAsNonRootYesYesContainer value overrides pod value for that container
seccompProfile, appArmorProfile, seLinuxOptions, windowsOptionsYesYesContainer overrides pod
fsGroup, fsGroupChangePolicy, supplementalGroups, supplementalGroupsPolicyYesNoApplies to volumes and to every container’s supplementary groups
sysctlsYesNoKernel parameters are per pod network/IPC namespace
seLinuxChangePolicy, hostUsers (top-level spec)YesNoPod-wide
capabilities, privileged, allowPrivilegeEscalation, readOnlyRootFilesystem, procMountNoYesPer container; init and ephemeral containers have their own

Two consequences matter on the exam. First, you cannot drop capabilities once for the whole pod; every container (including init containers and ephemeral debug containers) needs its own block, and Pod Security Admission checks all three lists. Second, the pod-level fsGroup and seLinuxOptions are the only fields that reach volumes; container-level settings never change volume ownership.

Linux capabilities, properly

Capabilities split root’s power into independent bits. The kernel (as of Linux 5.9 and later) defines 41 of them, from CAP_CHOWN (0) to CAP_CHECKPOINT_RESTORE (40); /proc/sys/kernel/cap_last_cap reports the highest one your kernel knows. Each thread carries five sets:

SetWhat it means
EffectiveWhat the kernel checks when the thread tries a privileged operation. This is the one that matters at runtime
PermittedThe superset the thread may raise into its effective set. Drop a bit here and it is gone until the next execve grants it back
InheritableBits that can survive execve if the executed file’s inheritable bits also contain them
BoundingA ceiling: nothing can be gained during execve that is outside this set. Container runtimes set this from your capabilities field
AmbientSince Linux 4.3: bits preserved across execve of an unprivileged program (one with no file capabilities and no set-UID bit). A bit can be ambient only if it is also permitted and inheritable

When a process runs execve, the kernel recomputes the sets with these rules (from capabilities(7)):

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)

F(...) are the file capability bits stored as an extended attribute on the binary; most binaries have none. The special case that makes containers work at all: when the process’s real or effective UID is 0, the kernel treats F(inheritable) and F(permitted) as all ones and F(effective) as set. For root, the formula collapses to permitted = inheritable OR bounding, effective = permitted, which is why a root container simply “has” whatever the runtime put in the bounding set.

Hold onto that formula; the non-root trap in the next section is nothing but this arithmetic.

The capabilities that matter for container escape

You will not memorise all 41. You should know the ones that appear in CKS scenarios and in the Pod Security Standards:

CapabilityUnlocksWhy security teams care
SYS_ADMINmount, namespaces, many ioctls, BPF on old kernels, and dozens moreThe “new root”. A container with it and a writable device or host mount is a node compromise
SYS_PTRACEptrace arbitrary processes, read other processes’ memoryWith hostPID: true, read secrets out of any process on the node
NET_RAWRaw and packet socketsARP spoofing and sniffing inside the pod network; still in the containerd default set
NET_ADMINInterfaces, routes, firewall rules, promiscuous modeRewrite the node’s networking from inside a pod
DAC_OVERRIDE / DAC_READ_SEARCHBypass file permission checks (write/read)Read any file reachable through a mounted volume
SYS_MODULELoad kernel modulesKernel-level rootkit
SYS_CHROOTchroot, join mount namespaces via setnsPart of several escape chains; CRI-O dropped it from its defaults
MKNODCreate device nodesCombined with a device mount, raw disk access
SETUID / SETGID / SETFCAPChange UIDs, set file capabilitiesPrivilege juggling; SETFCAP is also needed to map UID 0 in a new user namespace on Linux 5.12 and later
BPF / PERFMONBPF programs, performance counters (split out of SYS_ADMIN in Linux 5.8)Kernel introspection and some bypass primitives
SYS_TIMESet the system clockBreaks TLS validation and log integrity cluster-wide
NET_BIND_SERVICEBind ports below 1024The only capability the Restricted profile lets you add back

What a container starts with: runtime defaults

Kubernetes itself defines no default capability set. The capabilities.add and capabilities.drop lists are handed to the container runtime through CRI, and the runtime applies them on top of its own default. That default differs between runtimes, which is a classic exam distinction:

RuntimeDefault capabilities granted to every containerCount
containerd (defaultUnixCaps)CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE14
CRI-O (default_capabilities in crio.conf)CHOWN, DAC_OVERRIDE, FSETID, FOWNER, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE, KILL9

CRI-O dropped NET_RAW and SYS_CHROOT from its defaults in 1.18, and its current default list also omits MKNOD, SETFCAP and AUDIT_WRITE; containerd still ships the Docker-compatible 14. The practical lesson: a pod that “works on one cluster and fails on another” with a permission error on raw sockets or chroot is often a runtime-default difference, and the fix is to declare what you need explicitly rather than rely on defaults. The Pod Security Standards sidestep the question by requiring drop: ["ALL"] in the Restricted profile, so the runtime default becomes irrelevant.

Under the hood, containerd’s WithCapabilities option writes your list into the bounding, effective and permitted sets of the OCI spec, filters the inheritable set down to that list, and never touches the ambient set. add appends to those three sets; drop removes from all four. Remember which sets are populated; the next section depends on it.

Verifying from inside the container

# Hex masks of the five sets for PID 1
grep Cap /proc/1/status
# CapInh: 0000000000000000
# CapPrm: 00000000a80425fb
# CapEff: 00000000a80425fb
# CapBnd: 00000000a80425fb
# CapAmb: 0000000000000000

# Decode a mask (libcap-utils; install in a debug image, or run on the node)
capsh --decode=00000000a80425fb
# 0x00000000a80425fb=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,cap_setfcap

# Or ask the runtime what it was told to apply (on the node)
crictl inspect <container-id> | jq '.info.runtimeSpec.process.capabilities'

The mask a80425fb is the exact value the Kubernetes documentation shows for a default containerd container; after add: ["NET_ADMIN", "SYS_TIME"] it becomes aa0435fb, with bit 12 (NET_ADMIN) and bit 25 (SYS_TIME) switched on. On the exam, grep Cap /proc/1/status from kubectl exec is the fastest way to prove a hardening change took effect, and kubectl debug with a --profile=sysadmin ephemeral container is how you get capsh next to a distroless workload.

The non-root trap: why add: ["NET_BIND_SERVICE"] does nothing for UID 1000

Here is the scenario that surprises almost everyone. You follow the Restricted profile to the letter:

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
    add: ["NET_BIND_SERVICE"]
  seccompProfile:
    type: RuntimeDefault

Your web server then dies with bind: permission denied on port 80. Inside the container, grep Cap /proc/1/status shows CapBnd with the NET_BIND_SERVICE bit set and CapEff: 0000000000000000. Nothing is broken; this is the execve formula from earlier doing exactly what it says.

The runtime sets up the initial process with the capability in bounding, permitted and effective, then switches to UID 1000 and executes your binary. The binary has no file capabilities, so it is an unprivileged file: F(permitted) and F(inheritable) are zero and F(effective) is clear. The ambient set is empty because containerd never populates it. Plug in the numbers: permitted becomes (inheritable AND 0) OR (0 AND bounding) OR 0, which is zero, and effective follows. The capability is “allowed” (bounding) but not “held” (effective). For UID 0 the root special case would have rescued you; for UID 1000 it does not.

This is tracked as the long-open Kubernetes issue about configuring the ambient set, and the KEP to add ambient capabilities to the Pod API (KEP-2763) has been parked since its containerd and CRI-O implementation PRs were closed; it is still listed as stale on the SIG Node planning boards. The workable options today, with their trade-offs:

OptionHowTrade-off
Listen on an unprivileged port (8080) and let the Service map 80 to itports.targetPort: 8080Cleanest; nothing to add. The usual CKS answer
Safe sysctl net.ipv4.ip_unprivileged_port_start=0pod-level securityContext.sysctlsAllowed by the Baseline profile (safe sysctl since 1.22); affects every container in the pod
File capabilities baked into the imagesetcap cap_net_bind_service=+ep /usr/sbin/nginx in the DockerfileNeeds SETFCAP at build time and a filesystem that preserves xattrs. Ignored when allowPrivilegeEscalation: false, because no_new_privs makes the kernel skip file capabilities. So it conflicts with the Restricted profile
CRI-O add_inheritable_capabilitiesNode-level crio.confOnly helps binaries carrying matching file inheritable bits; off by default since CVE-2022-27652
Run as root with everything else droppedrunAsUser: 0, drop: ["ALL"], add: ["NET_BIND_SERVICE"]Works through the UID 0 special case, but violates Restricted’s runAsNonRoot

Exam framing: when a task says “the container must run as a non-root user and listen on port 80”, check whether the image already carries file capabilities (nginx images from some vendors do) and whether allowPrivilegeEscalation is false. If both are unknown, the sysctl or an unprivileged targetPort is the safe move, and grep Cap /proc/1/status is the proof.

allowPrivilegeEscalation is the no_new_privs bit, inverted

allowPrivilegeEscalation: false sets the Linux no_new_privs flag on the process; true (the default when unset) leaves it clear. With no_new_privs set, execve can never grant the new program more privilege than the caller had: set-UID and set-GID bits are ignored and file capabilities are ignored. Concretely it stops:

  • sudo, su, passwd, mount and any other set-UID root helper (you see “effective uid is not 0, is sudo installed setuid root?”).
  • ping on images where it relies on a set-UID bit or cap_net_raw+ep file capability.
  • Any “drop to non-root then regain capabilities through file caps” trick, which is why the earlier setcap option fails.

The field cannot be combined with privileged: true or with CAP_SYS_ADMIN in capabilities.add; a privileged container is by definition allowed to escalate. The Restricted profile requires false on every container, init container and ephemeral container (Linux pods only; Windows pods are exempt since 1.25). Verify with:

grep NoNewPrivs /proc/1/status
# NoNewPrivs: 1

Note the subtlety for the exam: allowPrivilegeEscalation: false does not remove capabilities the process already holds. A root container with the containerd defaults and no_new_privs still has 14 effective capabilities. Dropping is a separate field.

What privileged: true actually switches off

“Privileged” is not “root plus some capabilities”. Following containerd’s WithPrivileged, a privileged container gets, in one flag:

  1. All capabilities the runtime knows about, in every set.
  2. No masked paths and no read-only paths in /proc and /sys: /proc/kcore, /proc/sys, /sys/firmware and friends become readable and writable.
  3. Writable /sys and writable cgroup filesystem.
  4. No SELinux label, no AppArmor profile, and seccomp unconfined; a seccompProfile you set is simply not applied.
  5. Through CRI, access to all host devices under /dev.

From there, node takeover is two commands: mount the node’s root disk, or (with hostPID: true) nsenter -t 1 -m -u -i -n -p into PID 1’s namespaces. That is why the Baseline profile forbids privileged on all three container lists, why the threat model guide treats it as the shortest path to node compromise, and why a CKS task phrased as “the DaemonSet needs to load a kernel module, minimise its privileges” expects capabilities.add: ["SYS_MODULE"] rather than privileged: true. Prefer specific capabilities, device plugins, or a sandboxed runtime (gVisor or Kata through a RuntimeClass) over the privileged flag.

Identity: runAsUser, runAsGroup, runAsNonRoot

  • runAsUser sets the UID of the first process. runAsGroup sets the primary GID; if you omit it, the primary group is root (0) even for a non-root user, which surprises people who then find files group-owned by root. Set both.
  • runAsNonRoot: true is an assertion, not a setting. The kubelet validates at container start that the effective UID is non-zero. If the image declares a numeric non-root USER, fine. If it declares a named user or no user at all, the kubelet cannot prove the UID and rejects the container with CreateContainerConfigError and the message container has runAsNonRoot and image will run as root (or “image has non-numeric user”). The fix is a numeric runAsUser in the manifest or a numeric USER in the Dockerfile.
  • The Restricted profile requires runAsNonRoot: true (pod level suffices if no container overrides it) and forbids runAsUser: 0 anywhere since 1.23.
  • With user namespaces (hostUsers: false), “root” inside the container is an unprivileged host UID, and Pod Security Admission relaxes some of these checks; see the user namespaces guide for that interaction.

Volumes and groups: fsGroup, fsGroupChangePolicy, supplementalGroupsPolicy

fsGroup does two things: it is added as a supplementary group to every container process, and the kubelet changes the group ownership of supported volumes to that GID (with the set-GID bit, so new files inherit it). It only applies to volume types that support ownership management; it has no effect on secret, configMap and emptyDir ownership in the sense of the change policy below, and it does not touch hostPath or network filesystems that manage their own permissions. When a CSI driver advertises the VOLUME_MOUNT_GROUP capability, the driver applies the group at mount time and the kubelet’s recursive change is skipped.

fsGroupChangePolicy (stable since 1.23) controls the cost of that recursive change:

ValueBehaviourWhen to use
Always (default)Recursively chown/chmod the volume on every mountSmall volumes, or when ownership may have drifted
OnRootMismatchOnly recurse when the volume’s root directory does not already have the expected owner and permissionsLarge volumes with millions of files, where the default makes pods take minutes to start

supplementalGroups adds extra GIDs. The newer supplementalGroupsPolicy (feature gate SupplementalGroupsPolicy, beta and on by default since 1.33) fixes a real security problem: by default (Merge) the runtime also merges whatever groups the image’s /etc/group assigns to the primary user, so an image can silently grant itself a GID that happens to own a shared volume. Strict attaches only the GIDs from fsGroup, supplementalGroups and runAsGroup. Two conditions: the runtime must support it (containerd 2.0 and later, CRI-O 1.31 and later; nodes advertise .status.features.supplementalGroupsPolicy: true), and since beta the kubelet rejects a Strict pod on an unsupported node with the event reason SupplementalGroupsPolicyNotSupported instead of silently falling back. You can see the identity the first process actually got in .status.containerStatuses[].user.linux.

spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000
    fsGroupChangePolicy: OnRootMismatch
    supplementalGroups: [4000]
    supplementalGroupsPolicy: Strict

Inside the container, id then prints uid=1000 gid=3000 groups=2000,3000,4000 and nothing else, whatever /etc/group in the image says.

readOnlyRootFilesystem and immutability

readOnlyRootFilesystem: true mounts the container’s root filesystem read-only. Writes go only to volumes you mount, so the pattern is an emptyDir on /tmp (and on any cache directory the application needs). It is the single cheapest way to stop a compromised process from dropping tools or persisting, and it is the control behind “ensure immutability of containers at runtime”, covered with Falco detection in the runtime security guide. It is not part of either Pod Security Standards profile, which is why policy engines (Kyverno or ValidatingAdmissionPolicy) are usually what enforce it.

procMount: the masked /proc

OCI runtimes mask a list of /proc and /sys/firmware paths and make others read-only to reduce attack surface. procMount: Default keeps that; procMount: Unmasked removes the masks. Unmasked is only meaningful for nested container builders and requires the pod to run in a user namespace (spec.hostUsers: false); between 1.12 and 1.29 that requirement was not enforced, and the ProcMountType feature gate graduated to stable in 1.36. The Baseline profile allows only unset or Default.

Sysctls: safe, unsafe, and pod-level only

Kernel parameters are set through the pod-level securityContext.sysctls list. Kubernetes divides them into:

  • Safe sysctls, allowed by default because they are namespaced and isolated between pods: kernel.shm_rmid_forced, net.ipv4.ip_local_port_range, net.ipv4.tcp_syncookies, net.ipv4.ping_group_range (1.18), net.ipv4.ip_unprivileged_port_start (1.22), net.ipv4.ip_local_reserved_ports (1.27), net.ipv4.tcp_keepalive_time, tcp_fin_timeout, tcp_keepalive_intvl, tcp_keepalive_probes (1.29), net.ipv4.tcp_rmem and tcp_wmem (1.32), net.ipv4.tcp_slow_start_after_idle and tcp_notsent_lowat (1.37).
  • Unsafe sysctls: everything else that is still namespaced (kernel.msg*, kernel.sem, fs.mqueue.*, net.core.somaxconn, and so on). They are disabled by default and must be allowed per node with the kubelet flag --allowed-unsafe-sysctls (or allowedUnsafeSysctls in KubeletConfiguration), which accepts globs such as kernel.msg*. A pod that requests an unsafe sysctl on a node that has not allowed it is scheduled but fails to start, so the documented guidance applies: taint those nodes and add tolerations.
  • Node-level sysctls (not namespaced, for example vm.* tuning) cannot be set from a pod spec at all except through a privileged DaemonSet or node configuration.

Two rules that make good exam questions: net.* sysctls are refused when hostNetwork: true, and since 1.25 you can write names with either . or / separators. The Baseline profile’s allowed sysctl list is exactly the safe list above, so adding net.core.somaxconn to a Baseline namespace is rejected by admission before the kubelet ever sees it.

apiVersion: v1
kind: Pod
metadata:
  name: sysctl-demo
spec:
  securityContext:
    sysctls:
    - name: net.ipv4.ip_unprivileged_port_start   # safe
      value: "0"
    - name: net.core.somaxconn                     # unsafe: needs --allowed-unsafe-sysctls
      value: "1024"
  containers:
  - name: app
    image: registry.k8s.io/pause:3.10

seccomp, AppArmor, SELinux: the pointers

Three more fields live in securityContext; they are covered elsewhere, so only the exam-relevant deltas:

  • seccompProfile.type is RuntimeDefault, Localhost (with localhostProfile relative to the kubelet’s seccomp directory) or Unconfined. Baseline forbids explicit Unconfined; Restricted requires RuntimeDefault or Localhost to be set explicitly.
  • appArmorProfile.type (the field replaced the beta annotation) is RuntimeDefault, Localhost or Unconfined. If you set RuntimeDefault explicitly and the node has AppArmor disabled, the pod is rejected; leave it unset and the profile is applied only where AppArmor exists.
  • seLinuxOptions sets the MCS label on containers and volumes. Kubernetes can relabel volumes with a -o context mount option instead of a recursive walk; seLinuxChangePolicy: Recursive opts a pod out when pods with different labels share a volume. SELinuxChangePolicy graduated in 1.36 and SELinuxMount in 1.37. Baseline restricts type to the container types (container_t, container_init_t, container_kvm_t, container_engine_t) and forbids custom user and role.

Field-by-field mapping to Pod Security Standards

FieldBaselineRestricted (in addition to Baseline)
privilegedunset or falsesame
capabilities.addunset, or only from: AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOTunset or NET_BIND_SERVICE only
capabilities.dropno rulemust contain ALL
allowPrivilegeEscalationno rulemust be false (Linux pods)
runAsNonRootno ruletrue at pod level, or on every container
runAsUserno rulemust not be 0
seccompProfile.typenot UnconfinedRuntimeDefault or Localhost, explicitly set
appArmorProfile.typeunset, RuntimeDefault or Localhostsame
seLinuxOptionstype limited to container types; no user/rolesame
procMountunset or Defaultsame
sysctlsonly the safe listsame
windowsOptions.hostProcessunset or falsesame
hostNetwork, hostPID, hostIPC, hostPath, hostPortall forbidden (hostPort may be 0)same, plus volumes limited to configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret
readOnlyRootFilesystem, fsGroup*, supplementalGroups*no ruleno rule (enforce with a policy engine)

Read the table the way admission does: the rules apply to containers, initContainers and ephemeralContainers alike, so a hardened main container next to a careless init container still fails.

A Restricted-compliant pod, line by line

apiVersion: v1
kind: Pod
metadata:
  name: hardened-web
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    fsGroupChangePolicy: OnRootMismatch
    supplementalGroupsPolicy: Strict
    seccompProfile:
      type: RuntimeDefault
    sysctls:
    - name: net.ipv4.ip_unprivileged_port_start
      value: "80"
  containers:
  - name: web
    image: nginxinc/nginx-unprivileged:1.27
    ports:
    - containerPort: 80
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
      appArmorProfile:
        type: RuntimeDefault
    volumeMounts:
    - name: tmp
      mountPath: /tmp
    - name: cache
      mountPath: /var/cache/nginx
  volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}
LineWhat it defends against
runAsNonRoot + numeric runAsUser/runAsGroupRoot in the container; kubelet can verify the UID; primary GID is not 0
drop: ["ALL"] with no addThe 14 containerd defaults, including NET_RAW and SYS_CHROOT
ip_unprivileged_port_start: "80"Lets UID 10001 bind 80 without the capability that would not be effective anyway
allowPrivilegeEscalation: falseset-UID helpers and file capabilities (no_new_privs)
readOnlyRootFilesystem + emptyDir mountsTool drops and persistence; nginx still gets its writable paths
seccompProfile: RuntimeDefaultThe runtime’s syscall denylist (mount, ptrace, reboot, and so on)
appArmorProfile: RuntimeDefaultFile and capability confinement on AppArmor nodes
supplementalGroupsPolicy: StrictGIDs smuggled in through the image’s /etc/group
fsGroupChangePolicy: OnRootMismatchMulti-minute recursive chown on large volumes

Verification cheat sheet

# What did the container actually get?
kubectl exec hardened-web -- sh -c 'id; grep -E "Cap|NoNewPrivs|Seccomp" /proc/1/status'

# Decode a capability mask on the node (or in a debug container)
capsh --decode=00000000a80425fb

# The OCI spec the runtime applied (node)
crictl inspect $(crictl ps -q --name web) | jq '.info.runtimeSpec.process | {capabilities, noNewPrivileges, user}'

# Prove read-only root
kubectl exec hardened-web -- touch /etc/x   # Read-only file system

# Which node features support Strict supplemental groups?
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.features.supplementalGroupsPolicy}{"\n"}{end}'

# Check what the pod's first process became
kubectl get pod hardened-web -o jsonpath='{.status.containerStatuses[0].user.linux}'

# Dry-run against a Pod Security level before applying
kubectl label --dry-run=server --overwrite ns prod pod-security.kubernetes.io/enforce=restricted

Scenario drill

ScenarioDiagnosisFix
Pod stuck in CreateContainerConfigError: “container has runAsNonRoot and image will run as root”Image USER is absent or non-numericAdd numeric runAsUser, or a numeric USER in the Dockerfile
drop: ["ALL"], add: ["NET_BIND_SERVICE"], runAsUser: 1000, server cannot bind port 80Capability is in bounding but not effective for non-rootSafe sysctl net.ipv4.ip_unprivileged_port_start, unprivileged targetPort, or an image with file caps (only if allowPrivilegeEscalation is true)
sudo prints “effective uid is not 0” inside the containerno_new_privs set by allowPrivilegeEscalation: falseExpected; remove the need for sudo, not the control
Falco alerts on raw-socket use from a pod that never asked for NET_RAWcontainerd grants NET_RAW by defaultdrop: ["ALL"] (or at least drop: ["NET_RAW"])
Same manifest works on a CRI-O cluster, fails with chroot: Operation not permitted on containerd… or the reverseRuntime default sets differ (CRI-O drops SYS_CHROOT)Declare add/drop explicitly
Task: “DaemonSet must manage iptables with least privilege”Needs NET_ADMIN (and often NET_RAW), not privilegedcapabilities.add: ["NET_ADMIN"], keep privileged unset
Privileged pod with hostPID found on a nodeFull node compromise possible via nsenter -t 1Remove, enforce Baseline on the namespace, alert via audit logs
seccompProfile: Localhost set on a privileged container but no syscalls are blockedPrivileged sets seccomp unconfinedDrop privileged
Pod takes four minutes to start on a 2 TB PVCRecursive fsGroup chown on every mountfsGroupChangePolicy: OnRootMismatch
Process can read a shared volume it was never granted; id shows an unexpected GID/etc/group in the image merged a group (Merge policy)supplementalGroupsPolicy: Strict on supporting runtimes
Strict pod rejected with SupplementalGroupsPolicyNotSupportedNode runtime too old (containerd 2.0 / CRI-O 1.31 needed)Schedule to nodes advertising the feature, or upgrade
Pod requesting net.core.somaxconn is scheduled but never startsUnsafe sysctl not allowed on the node--allowed-unsafe-sysctls=net.core.somaxconn on the kubelet; taint and tolerate
Pod with hostNetwork: true rejected for a net.ipv4.* sysctlnet.* sysctls forbidden with host networkingDrop host networking or the sysctl
Baseline namespace rejects a pod with procMount: UnmaskedOnly Default allowed; Unmasked needs hostUsers: falseUse user namespaces in a namespace that permits it, or remove
Restricted namespace rejects a pod whose only problem is an init container with no securityContextRules apply to init and ephemeral containers tooAdd the same block to the init container

Common mistakes

  • Treating allowPrivilegeEscalation: false as “drop capabilities”. It sets no_new_privs; it never removes capabilities already held. Use capabilities.drop.
  • Assuming Kubernetes has a default capability set. The runtime does: 14 on containerd, 9 on CRI-O. Write explicit lists.
  • Expecting capabilities.add to work for non-root users. Without file capabilities or an ambient set, the added bit is in bounding only. Verify with CapEff.
  • Forgetting runAsGroup. The primary GID defaults to 0.
  • Using runAsNonRoot with a named USER. The kubelet needs a number.
  • Reaching for privileged: true to get one capability. It also disables seccomp, AppArmor, SELinux, /proc masking and exposes every device.
  • Putting capabilities or readOnlyRootFilesystem at the pod level. They are container-only fields; the API server rejects the manifest with an unknown-field error.
  • Hardening only containers and leaving initContainers or a kubectl debug ephemeral container wide open.
  • Mixing fsGroup expectations across volume types. hostPath and most network filesystems ignore it; CSI drivers with VOLUME_MOUNT_GROUP handle it themselves.
  • Relying on readOnlyRootFilesystem without an emptyDir for /tmp, then debugging a crash loop that is just the app failing to write a PID file.

Practice on a real cluster before exam day

The CKS is a performance exam: the grader checks the running pod, not your intentions, and grep Cap /proc/1/status is often the only way to know whether a hardening change did anything. Build the muscle memory by breaking things deliberately: add NET_BIND_SERVICE to a non-root container and watch it fail, flip allowPrivilegeEscalation and watch sudo die, set supplementalGroupsPolicy: Strict on a node that cannot support it and read the event. Sailor.sh’s Certified Kubernetes Security Specialist (CKS) Mock Exam Bundle gives you a browser-based, exam-style terminal with timed, hands-on security scenarios across all six domains, including pod-hardening tasks that are graded on the capability sets, flags and ownership the pod actually ends up with. To build a lab first, follow the CKS practice environment guide, and use the CKS Study Plan and the CKS Exam Guide 2026 to place this topic within the full curriculum. The RBAC privilege escalation guide covers the API-level side of the same “privilege escalation” theme.

Frequently Asked Questions

Which Linux capabilities does a Kubernetes container have by default?

Kubernetes does not define a default; the container runtime does. containerd grants 14: CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL and AUDIT_WRITE. CRI-O’s default list is 9: CHOWN, DAC_OVERRIDE, FSETID, FOWNER, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE and KILL. The Restricted Pod Security Standard requires you to drop ALL so the difference stops mattering.

Why does adding NET_BIND_SERVICE not let my non-root container bind port 80?

Because the runtime places added capabilities in the bounding, permitted and effective sets of the initial process, but your binary is executed as a non-root user without file capabilities and with an empty ambient set. The kernel’s execve rules then compute an empty permitted and effective set. Use the safe sysctl net.ipv4.ip_unprivileged_port_start, listen on an unprivileged port, or bake file capabilities into the image (which does not work together with allowPrivilegeEscalation: false).

What is the difference between allowPrivilegeEscalation: false and dropping capabilities?

allowPrivilegeEscalation: false sets the kernel’s no_new_privs flag, so execve cannot grant extra privileges through set-UID bits or file capabilities. It does not remove capabilities the process already holds. capabilities.drop removes bits from the bounding, permitted, effective and inheritable sets. The Restricted profile requires both.

What does privileged: true change besides capabilities?

It grants every capability, removes the masked and read-only paths in /proc and /sys, makes /sys and the cgroup filesystem writable, clears the SELinux label and AppArmor profile, sets seccomp to unconfined, and exposes all host devices. A privileged container with hostPID can enter the node’s PID 1 namespaces with nsenter.

When should I use fsGroupChangePolicy: OnRootMismatch?

When a volume is large and pod start-up is slow because the kubelet recursively changes ownership on every mount. OnRootMismatch skips the walk if the volume’s root directory already has the expected owner and permissions. It only affects volume types that support fsGroup-based ownership management, and CSI drivers with the VOLUME_MOUNT_GROUP capability bypass the kubelet’s change entirely.

What is supplementalGroupsPolicy: Strict and why would I use it?

By default Kubernetes merges the groups listed for the container user in the image’s /etc/group into the process’s supplementary groups, which lets an image grant itself access to a volume owned by that GID. Strict attaches only the GIDs you declared through fsGroup, supplementalGroups and runAsGroup. It is beta and enabled by default since 1.33, needs containerd 2.0 or CRI-O 1.31 on the node, and unsupported nodes reject the pod with SupplementalGroupsPolicyNotSupported.

How do I allow an unsafe sysctl in a pod?

Add it to the kubelet’s --allowed-unsafe-sysctls flag (or allowedUnsafeSysctls in the kubelet configuration) on the nodes that should run it, restart the kubelet, then request it in the pod’s securityContext.sysctls. Taint those nodes and add a toleration to the pod so it is not scheduled elsewhere, where it would fail to start. Only namespaced sysctls can be enabled this way, and net.* sysctls are refused with hostNetwork: true.

Conclusion

The securityContext is a thin YAML layer over three kernel mechanisms: capability sets governed by the execve transformation rules, the no_new_privs flag, and the discretionary ownership model of UIDs and GIDs. Once you can run that arithmetic in your head, the behaviour that looks mysterious becomes predictable: added capabilities evaporate for non-root users because the ambient set is empty; allowPrivilegeEscalation: false kills sudo because it ignores set-UID bits; privileged is dangerous because it is six controls off at once; fsGroup works only on volumes the kubelet owns. Pair that model with the Baseline and Restricted field tables, keep grep Cap /proc/1/status within reach, and the pod-hardening tasks on the CKS turn from guesswork into verification.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now