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:
| Field | Pod level | Container level | Precedence |
|---|---|---|---|
runAsUser, runAsGroup, runAsNonRoot | Yes | Yes | Container value overrides pod value for that container |
seccompProfile, appArmorProfile, seLinuxOptions, windowsOptions | Yes | Yes | Container overrides pod |
fsGroup, fsGroupChangePolicy, supplementalGroups, supplementalGroupsPolicy | Yes | No | Applies to volumes and to every container’s supplementary groups |
sysctls | Yes | No | Kernel parameters are per pod network/IPC namespace |
seLinuxChangePolicy, hostUsers (top-level spec) | Yes | No | Pod-wide |
capabilities, privileged, allowPrivilegeEscalation, readOnlyRootFilesystem, procMount | No | Yes | Per 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:
| Set | What it means |
|---|---|
| Effective | What the kernel checks when the thread tries a privileged operation. This is the one that matters at runtime |
| Permitted | The superset the thread may raise into its effective set. Drop a bit here and it is gone until the next execve grants it back |
| Inheritable | Bits that can survive execve if the executed file’s inheritable bits also contain them |
| Bounding | A ceiling: nothing can be gained during execve that is outside this set. Container runtimes set this from your capabilities field |
| Ambient | Since 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:
| Capability | Unlocks | Why security teams care |
|---|---|---|
SYS_ADMIN | mount, namespaces, many ioctls, BPF on old kernels, and dozens more | The “new root”. A container with it and a writable device or host mount is a node compromise |
SYS_PTRACE | ptrace arbitrary processes, read other processes’ memory | With hostPID: true, read secrets out of any process on the node |
NET_RAW | Raw and packet sockets | ARP spoofing and sniffing inside the pod network; still in the containerd default set |
NET_ADMIN | Interfaces, routes, firewall rules, promiscuous mode | Rewrite the node’s networking from inside a pod |
DAC_OVERRIDE / DAC_READ_SEARCH | Bypass file permission checks (write/read) | Read any file reachable through a mounted volume |
SYS_MODULE | Load kernel modules | Kernel-level rootkit |
SYS_CHROOT | chroot, join mount namespaces via setns | Part of several escape chains; CRI-O dropped it from its defaults |
MKNOD | Create device nodes | Combined with a device mount, raw disk access |
SETUID / SETGID / SETFCAP | Change UIDs, set file capabilities | Privilege juggling; SETFCAP is also needed to map UID 0 in a new user namespace on Linux 5.12 and later |
BPF / PERFMON | BPF programs, performance counters (split out of SYS_ADMIN in Linux 5.8) | Kernel introspection and some bypass primitives |
SYS_TIME | Set the system clock | Breaks TLS validation and log integrity cluster-wide |
NET_BIND_SERVICE | Bind ports below 1024 | The 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:
| Runtime | Default capabilities granted to every container | Count |
|---|---|---|
containerd (defaultUnixCaps) | CHOWN, DAC_OVERRIDE, FSETID, FOWNER, MKNOD, NET_RAW, SETGID, SETUID, SETFCAP, SETPCAP, NET_BIND_SERVICE, SYS_CHROOT, KILL, AUDIT_WRITE | 14 |
CRI-O (default_capabilities in crio.conf) | CHOWN, DAC_OVERRIDE, FSETID, FOWNER, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE, KILL | 9 |
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:
| Option | How | Trade-off |
|---|---|---|
| Listen on an unprivileged port (8080) and let the Service map 80 to it | ports.targetPort: 8080 | Cleanest; nothing to add. The usual CKS answer |
Safe sysctl net.ipv4.ip_unprivileged_port_start=0 | pod-level securityContext.sysctls | Allowed by the Baseline profile (safe sysctl since 1.22); affects every container in the pod |
| File capabilities baked into the image | setcap cap_net_bind_service=+ep /usr/sbin/nginx in the Dockerfile | Needs 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_capabilities | Node-level crio.conf | Only helps binaries carrying matching file inheritable bits; off by default since CVE-2022-27652 |
| Run as root with everything else dropped | runAsUser: 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,mountand any other set-UID root helper (you see “effective uid is not 0, is sudo installed setuid root?”).pingon images where it relies on a set-UID bit orcap_net_raw+epfile capability.- Any “drop to non-root then regain capabilities through file caps” trick, which is why the earlier
setcapoption 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:
- All capabilities the runtime knows about, in every set.
- No masked paths and no read-only paths in
/procand/sys:/proc/kcore,/proc/sys,/sys/firmwareand friends become readable and writable. - Writable
/sysand writable cgroup filesystem. - No SELinux label, no AppArmor profile, and seccomp unconfined; a
seccompProfileyou set is simply not applied. - 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
runAsUsersets the UID of the first process.runAsGroupsets 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: trueis 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-rootUSER, fine. If it declares a named user or no user at all, the kubelet cannot prove the UID and rejects the container withCreateContainerConfigErrorand the messagecontainer has runAsNonRoot and image will run as root(or “image has non-numeric user”). The fix is a numericrunAsUserin the manifest or a numericUSERin the Dockerfile.- The Restricted profile requires
runAsNonRoot: true(pod level suffices if no container overrides it) and forbidsrunAsUser: 0anywhere 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:
| Value | Behaviour | When to use |
|---|---|---|
Always (default) | Recursively chown/chmod the volume on every mount | Small volumes, or when ownership may have drifted |
OnRootMismatch | Only recurse when the volume’s root directory does not already have the expected owner and permissions | Large 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_rmemandtcp_wmem(1.32),net.ipv4.tcp_slow_start_after_idleandtcp_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(orallowedUnsafeSysctlsinKubeletConfiguration), which accepts globs such askernel.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.typeisRuntimeDefault,Localhost(withlocalhostProfilerelative to the kubelet’s seccomp directory) orUnconfined. Baseline forbids explicitUnconfined; Restricted requiresRuntimeDefaultorLocalhostto be set explicitly.appArmorProfile.type(the field replaced the beta annotation) isRuntimeDefault,LocalhostorUnconfined. If you setRuntimeDefaultexplicitly and the node has AppArmor disabled, the pod is rejected; leave it unset and the profile is applied only where AppArmor exists.seLinuxOptionssets the MCS label on containers and volumes. Kubernetes can relabel volumes with a-o contextmount option instead of a recursive walk;seLinuxChangePolicy: Recursiveopts a pod out when pods with different labels share a volume.SELinuxChangePolicygraduated in 1.36 andSELinuxMountin 1.37. Baseline restrictstypeto the container types (container_t,container_init_t,container_kvm_t,container_engine_t) and forbids customuserandrole.
Field-by-field mapping to Pod Security Standards
| Field | Baseline | Restricted (in addition to Baseline) |
|---|---|---|
privileged | unset or false | same |
capabilities.add | unset, or only from: AUDIT_WRITE, CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, MKNOD, NET_BIND_SERVICE, SETFCAP, SETGID, SETPCAP, SETUID, SYS_CHROOT | unset or NET_BIND_SERVICE only |
capabilities.drop | no rule | must contain ALL |
allowPrivilegeEscalation | no rule | must be false (Linux pods) |
runAsNonRoot | no rule | true at pod level, or on every container |
runAsUser | no rule | must not be 0 |
seccompProfile.type | not Unconfined | RuntimeDefault or Localhost, explicitly set |
appArmorProfile.type | unset, RuntimeDefault or Localhost | same |
seLinuxOptions | type limited to container types; no user/role | same |
procMount | unset or Default | same |
sysctls | only the safe list | same |
windowsOptions.hostProcess | unset or false | same |
hostNetwork, hostPID, hostIPC, hostPath, hostPort | all forbidden (hostPort may be 0) | same, plus volumes limited to configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret |
readOnlyRootFilesystem, fsGroup*, supplementalGroups* | no rule | no 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: {}
| Line | What it defends against |
|---|---|
runAsNonRoot + numeric runAsUser/runAsGroup | Root in the container; kubelet can verify the UID; primary GID is not 0 |
drop: ["ALL"] with no add | The 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: false | set-UID helpers and file capabilities (no_new_privs) |
readOnlyRootFilesystem + emptyDir mounts | Tool drops and persistence; nginx still gets its writable paths |
seccompProfile: RuntimeDefault | The runtime’s syscall denylist (mount, ptrace, reboot, and so on) |
appArmorProfile: RuntimeDefault | File and capability confinement on AppArmor nodes |
supplementalGroupsPolicy: Strict | GIDs smuggled in through the image’s /etc/group |
fsGroupChangePolicy: OnRootMismatch | Multi-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
| Scenario | Diagnosis | Fix |
|---|---|---|
Pod stuck in CreateContainerConfigError: “container has runAsNonRoot and image will run as root” | Image USER is absent or non-numeric | Add numeric runAsUser, or a numeric USER in the Dockerfile |
drop: ["ALL"], add: ["NET_BIND_SERVICE"], runAsUser: 1000, server cannot bind port 80 | Capability is in bounding but not effective for non-root | Safe 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 container | no_new_privs set by allowPrivilegeEscalation: false | Expected; remove the need for sudo, not the control |
Falco alerts on raw-socket use from a pod that never asked for NET_RAW | containerd grants NET_RAW by default | drop: ["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 reverse | Runtime 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 privileged | capabilities.add: ["NET_ADMIN"], keep privileged unset |
Privileged pod with hostPID found on a node | Full node compromise possible via nsenter -t 1 | Remove, enforce Baseline on the namespace, alert via audit logs |
seccompProfile: Localhost set on a privileged container but no syscalls are blocked | Privileged sets seccomp unconfined | Drop privileged |
| Pod takes four minutes to start on a 2 TB PVC | Recursive fsGroup chown on every mount | fsGroupChangePolicy: 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 SupplementalGroupsPolicyNotSupported | Node 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 starts | Unsafe 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.* sysctl | net.* sysctls forbidden with host networking | Drop host networking or the sysctl |
Baseline namespace rejects a pod with procMount: Unmasked | Only Default allowed; Unmasked needs hostUsers: false | Use user namespaces in a namespace that permits it, or remove |
Restricted namespace rejects a pod whose only problem is an init container with no securityContext | Rules apply to init and ephemeral containers too | Add the same block to the init container |
Common mistakes
- Treating
allowPrivilegeEscalation: falseas “drop capabilities”. It setsno_new_privs; it never removes capabilities already held. Usecapabilities.drop. - Assuming Kubernetes has a default capability set. The runtime does: 14 on containerd, 9 on CRI-O. Write explicit lists.
- Expecting
capabilities.addto work for non-root users. Without file capabilities or an ambient set, the added bit is in bounding only. Verify withCapEff. - Forgetting
runAsGroup. The primary GID defaults to 0. - Using
runAsNonRootwith a namedUSER. The kubelet needs a number. - Reaching for
privileged: trueto get one capability. It also disables seccomp, AppArmor, SELinux,/procmasking and exposes every device. - Putting
capabilitiesorreadOnlyRootFilesystemat the pod level. They are container-only fields; the API server rejects the manifest with an unknown-field error. - Hardening only
containersand leavinginitContainersor akubectl debugephemeral container wide open. - Mixing
fsGroupexpectations across volume types.hostPathand most network filesystems ignore it; CSI drivers withVOLUME_MOUNT_GROUPhandle it themselves. - Relying on
readOnlyRootFilesystemwithout anemptyDirfor/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.