securityContext Hardening for the CKS non-root, read-only, no escalation, no capabilities, restricted
Ask the kernel, not the YAML: /proc/1/status says who the process really is.
The problem
A team ships a web service as a Pod, the smallest deployable unit in Kubernetes, and its YAML file has no securityContext at all. A reviewer asks four plain questions. Which user ID does the process run as? Can it write to its own image filesystem? Which Linux capabilities, the separate pieces of root's power, does it hold? Could a setuid binary, an executable that runs with its owner's identity, raise its privileges? Nobody on the team can answer without starting the Pod and looking.
Later the cluster administrator labels the namespace with the restricted Pod Security Standard, and the next deploy is refused with a Forbidden error that names fields nobody remembers setting. The Certified Kubernetes Security Specialist (CKS) exam, run by the Cloud Native Computing Foundation (CNCF), tests exactly this skill under time pressure: harden a Pod, then read the admission errors. The matching curriculum v1.34 entry is the competency "Use appropriate pod security standards" in the domain "Minimize Microservice Vulnerabilities" (20%). The item "Minimize host OS footprint (reduce attack surface)" from the domain "System Hardening" (10%) is context only, because every setting below shrinks what a compromised container can do to its host operating system (OS).
The idea, in plain words
A security context defines privilege and access control settings for a Pod or a container. It can be set for the whole Pod under spec.securityContext and for each container under containers[].securityContext, and where the two overlap the container setting overrides the Pod setting. The kubelet, the agent on each node that starts containers, and the container runtime apply these settings when the container starts, so they shape the process itself. Pod Security Admission is a separate gate: before a Pod is created, it checks the Pod against the Pod Security Standard selected by labels on the namespace, a named partition of the cluster, and it rejects any Pod that fails. Both halves matter, because otherwise the first deploy into a labelled namespace fails.
The default, and who the process is
All output below was captured on k3s v1.34.12+k3s1 (Kubernetes v1.34.12), a small Kubernetes distribution, running in a throwaway local container. The client was kubectl v1.33.9 (Kustomize v5.6.0) and the image was busybox:1.36. The client is one minor version behind the server, which is inside kubectl's supported skew. The cluster was deleted afterwards.
A user ID (UID) is the number the kernel uses to identify the user a process runs as, and UID 0 is root. The first Pod has no securityContext and serves as the baseline:
apiVersion: v1
kind: Pod
metadata:
name: plain
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
This Pod, named plain, runs the busybox:1.36 image with a sleep command that keeps it alive for an hour. Neither the Pod nor the container sets any security field, so every security-relevant setting takes its default. The Pod exists so that commands can be run inside it and the kernel's answers compared with the hardened version later, one setting at a time.
$ kubectl exec plain -- sh -c 'id; grep -E "^(NoNewPrivs|CapEff)" /proc/1/status; touch /probe && echo root-fs-writable'
uid=0(root) gid=0(root) groups=0(root),10(wheel)
CapEff: 00000000a80425fb
NoNewPrivs: 0
root-fs-writable
The command prints the user identity, then pulls two lines from /proc/1/status, the kernel's status report for the container's first process, and finally tries to create a file at the filesystem root. CapEff is the effective capability bitmap, a hexadecimal number in which each set bit is one capability the process can use right now. The value a80425fb has 14 bits set, so this process holds a real share of root's power. It is still less than all of root: bits 12 and 25, which the Kubernetes security context task identifies as CAP_NET_ADMIN and CAP_SYS_TIME, are clear in this bitmap. NoNewPrivs is the no_new_privs flag, and 0 means it is off. The last line shows that the image filesystem accepts writes. Each answer is the unfavourable one.
The field runAsNonRoot is the first guard. Its API description says that if true, "the Kubelet will validate the image at runtime to ensure that it does not run as UID 0 (root) and fail to start the container if it does". If it is unset or false, no such validation is performed, so a Pod can run as root without anyone noticing. The field is a check, not a user switch, because it only validates whatever user the image declares.
apiVersion: v1
kind: Pod
metadata:
name: nonroot-noid
spec:
securityContext:
runAsNonRoot: true
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
This Pod sets runAsNonRoot: true at Pod level but names no user. The busybox:1.36 image defaults to root, so the kubelet's validation has nothing legitimate to approve. The Pod is accepted by the API server, because the check happens later, on the node, when the kubelet prepares the container. That timing explains why the failure shows up as a Pod status and not as an error from kubectl apply. The status and the kubelet event, read with kubectl describe pod, were captured 20 seconds after the apply:
NAME READY STATUS RESTARTS AGE
nonroot-noid 0/1 CreateContainerConfigError 0 20s
Warning Failed 9s (x3 over 20s) kubelet Error: container has runAsNonRoot and image will run as root (pod: "nonroot-noid_default(f5fbfb60-6da9-45f4-95ae-79d87751aaee)", container: app)
Symptom: A Pod sits in CreateContainerConfigError and the container logs are empty.
Cause: runAsNonRoot: true is set, the image's default user is root, and no runAsUser is given. The container never started, so it has no logs.
Fix: Set a non-zero runAsUser, or use an image that declares a non-root user. kubectl describe pod shows the kubelet event quoted above.
Explicit users bring the override rule into play. The next Pod sets a user at both levels:
apiVersion: v1
kind: Pod
metadata:
name: override
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext:
runAsUser: 2000
The Pod-level block asks for user 1000 and group 3000, while the container block asks for user 2000. Kubernetes documents that container settings "override settings made at the Pod level when there is overlap", and the API reference says that for runAsNonRoot the value in the container's SecurityContext takes precedence. The override works field by field, so the group, which the container did not set, still comes from the Pod. A reader who assumes the whole Pod block wins will predict the wrong user.
uid=2000 gid=3000 groups=3000
allowPrivilegeEscalation and capabilities
A setuid binary runs with the identity of its file owner instead of the user who launched it. The field allowPrivilegeEscalation controls whether a process can gain more privileges than its parent process, for example by executing such a binary. It directly controls the no_new_privs flag on the container process, but inverted: if the field is true, the flag is false. When the field is not defined it defaults to true, which matches the NoNewPrivs value of 0 in the baseline. Setting it to false is also inconsistent with a container that is privileged or has the capability CAP_SYS_ADMIN, so the API server refuses that combination.
Capabilities split root's power into pieces, so a process can receive some privileges without all of them. The capabilities field in a container's securityContext adds or drops them. Capability constants are written CAP_XXX, but a Pod definition must omit the CAP_ part, so the value is NET_BIND_SERVICE and not CAP_NET_BIND_SERVICE. Dropping ALL starts the container from zero, and anything the program needs must then be added back on purpose. Otherwise the container carries powers its code never uses.
This is the complete hardened Pod from the test. Later sections refer back to it.
apiVersion: v1
kind: Pod
metadata:
name: hardened
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
The Pod-level block fixes the user and group and selects a seccomp profile, where seccomp is a Linux kernel feature that filters the system calls a process may make. The container block turns off privilege escalation, makes the root filesystem read-only and drops every capability. The emptyDir volume mounted at /tmp gives the program one writable path, which the readOnlyRootFilesystem and the writable scratch directory section explains. Because runAsUser is set to a non-zero value, runAsNonRoot has a concrete user to validate and the Pod starts.
$ kubectl exec hardened -- sh -c 'id; grep -E "^(NoNewPrivs|CapEff|Seccomp:)" /proc/1/status'
uid=1000 gid=3000 groups=3000
CapEff: 0000000000000000
NoNewPrivs: 1
Seccomp: 2
The identity matches the Pod-level settings. CapEff is all zeros against a80425fb for the baseline, so the process has no effective capabilities. NoNewPrivs went from 0 to 1, the inversion of allowPrivilegeEscalation: false. The test did not run a setuid binary to watch an escalation fail. It checked the flag the kernel uses to block escalation, so the claim here concerns that flag and not a demonstrated attack.
The contradiction rule can be seen directly:
apiVersion: v1
kind: Pod
metadata:
name: inconsistent
spec:
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext:
privileged: true
allowPrivilegeEscalation: false
This container sets privileged: true and, in the same block, allowPrivilegeEscalation: false. The security context task lists exactly this pair as inconsistent: a container that is run as privileged, or that holds CAP_SYS_ADMIN, cannot also have privilege escalation turned off. The API server therefore rejects the Pod during validation, before it is stored or scheduled, and the rejection comes straight back to kubectl apply. That makes it the quickest of the four failures to diagnose, because the last clause of the message names both fields. The long JSON in the middle is the server echoing the container's security context, and only the final clause matters.
The Pod "inconsistent" is invalid: spec.containers[0].securityContext: Invalid value: {"Capabilities":null,"Privileged":true,"SELinuxOptions":null,"WindowsOptions":null,"RunAsUser":null,"RunAsGroup":null,"RunAsNonRoot":null,"ReadOnlyRootFilesystem":null,"AllowPrivilegeEscalation":false,"ProcMount":null,"SeccompProfile":null,"AppArmorProfile":null}: cannot set `allowPrivilegeEscalation` to false and `privileged` to true
Symptom: kubectl apply fails with an Invalid value error ending in "cannot set allowPrivilegeEscalation to false and privileged to true".
Cause: privileged: true together with allowPrivilegeEscalation: false is an inconsistent pair.
Fix: Drop privileged, which is almost always the right choice. Removing the allowPrivilegeEscalation line also clears the error but leaves a privileged container.
readOnlyRootFilesystem and the writable scratch directory
The root filesystem is the image's own file tree inside the container. The API reference describes readOnlyRootFilesystem as "Whether this container has a read-only root filesystem. Default is false." With it set to true, a bug or an attacker cannot change the image's binaries or leave files on the image filesystem. Programs that genuinely need scratch space get an explicit emptyDir, a volume that starts empty, mounted at a chosen path. Container-level settings do not affect the Pod's volumes, so the mounted path stays writable while the rest is sealed.
$ kubectl exec hardened -- sh -c 'touch /probe'
touch: /probe: Read-only file system
command terminated with exit code 1
$ kubectl exec hardened -- sh -c 'touch /tmp/probe && echo tmp-writable'
tmp-writable
The first command tries to create a file on the image filesystem and fails with "Read-only file system" and exit code 1, while the baseline Pod printed root-fs-writable for the same action. The second command writes into the emptyDir mounted at /tmp and succeeds. This split is the intended design, because the program's writable footprint shrinks to the paths listed in volumeMounts. The CKS curriculum lists "Ensure immutability of containers at runtime" under Monitoring, Logging and Runtime Security (20%), and this setting is one direct way to get it. That item is context here and was not tested beyond the two writes above.
Symptom: An application crashes at start with "Read-only file system" while writing to /tmp or a cache path.
Cause: readOnlyRootFilesystem: true is set and no writable volume is mounted at that path.
Fix: Mount an emptyDir at the path the application writes to. Do not turn the setting off, because that restores the writable image filesystem.
Pod Security Admission and the restricted profile
Pod Security Admission reads labels on a namespace and applies a Pod Security Standard to every Pod created there. The restricted profile is the strictest of the standards, and it demands these fields:
| Field | What restricted requires |
|---|---|
allowPrivilegeEscalation | false |
runAsNonRoot | true; container fields may be unset if the Pod-level field is true |
runAsUser | must not be 0 |
capabilities.drop | a list that includes ALL; only NET_BIND_SERVICE may be added back |
seccompProfile | explicitly set to an allowed value |
The enforcement label goes on a namespace like this:
apiVersion: v1
kind: Namespace
metadata:
name: locked
labels:
pod-security.kubernetes.io/enforce: restricted
The label key pod-security.kubernetes.io/enforce with the value restricted tells admission to reject any Pod in the namespace named locked that violates the profile. Admission runs when the Pod is submitted, so a violating Pod is never stored and never reaches a node. Applying the unchanged baseline Pod into this namespace shows what that rejection looks like in practice, error text included.
$ kubectl apply -n locked -f default.yaml
Error from server (Forbidden): error when creating "default.yaml": pods "plain" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "app" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "app" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
The message lists four violations, and each parenthesis names the exact field to set. The error therefore works as a checklist: allowPrivilegeEscalation false, capabilities dropped to ALL, runAsNonRoot true, and a seccomp profile of RuntimeDefault or Localhost. Reading it field by field is faster than rereading the whole profile. The same hardened Pod from the allowPrivilegeEscalation and capabilities section passes the gate:
$ kubectl apply -n locked -f hardened.yaml
pod/hardened created
Admission accepted it. The status listing taken right after showed ContainerCreating at age 0s, and the test did not wait for the Pod to become Ready in that namespace. The seccomp evidence comes from the earlier run of the same file in the default namespace, where the status file reported this line:
Seccomp: 2
That number shows a seccomp filter is active on the hardened container, and the test makes no stronger claim about it.
Symptom: After a namespace gets the enforce label, a Deployment's ReplicaSet, the object that creates its Pods, produces no Pods.
Cause: Admission rejects the Pods the ReplicaSet tries to create, and the error is recorded on the ReplicaSet's events, not on any Pod because no Pod exists. The test applied a bare Pod, so the rejection came straight back from kubectl apply. The Deployment case was not run, so treat this as guidance on where to look.
Fix: Add the four fields listed in the error to the Pod template.
Putting it together
A hardening task on the exam follows a fixed order, and each step ends in a command that confirms it.
- Read the namespace labels to learn which Pod Security Standard is enforced, because that decides the required fields.
- Add the Pod-level block:
runAsNonRoot: true, a non-zerorunAsUser, arunAsGroup, andseccompProfileof typeRuntimeDefault. - Add the container block:
allowPrivilegeEscalation: false,readOnlyRootFilesystem: true, andcapabilities.drop: ["ALL"]. - Mount an
emptyDirat every path where the application writes, so that the read-only root filesystem does not crash it. - Apply the file, then verify from inside the container.
kubectl exec <pod> -- id
kubectl exec <pod> -- sh -c 'grep -E "^(NoNewPrivs|CapEff|Seccomp:)" /proc/1/status'
The first command confirms the user and group. The second confirms the three kernel facts: CapEff of all zeros, NoNewPrivs of 1, and a nonzero Seccomp value. Checking from inside is worth the seconds, because the override rule means a field set at one level can be replaced at the other without any error.
Exam traps:
- A container-level
runAsUsersilently replaces the Pod-level one, while untouched fields such asrunAsGroupstill come from the Pod. runAsNonRoot: truewith a root-default image and norunAsUserends in CreateContainerConfigError.privileged: truewithallowPrivilegeEscalation: falseis refused by the API server.readOnlyRootFilesystem: trueneeds anemptyDirwherever the application writes.- Restricted demands an explicit seccomp profile, and it was one of the four errors the plain Pod hit.
- Capability names in a Pod definition omit the CAP_ prefix, and restricted wants
ALLdropped.
Check yourself
runAsNonRoot: true and the busybox:1.36 image shows CreateContainerConfigError and has no logs. What is wrong and where is the proof?runAsUser is set, so the kubelet refuses to start the container. kubectl describe pod shows the event "container has runAsNonRoot and image will run as root".runAsUser: 1000 and runAsGroup: 3000, and the container sets only runAsUser: 2000. What does id print?uid=2000 gid=3000 groups=3000. The container's user replaces the Pod's user, and the group is inherited from the Pod because the container did not set one./proc/1/status shows NoNewPrivs: 1 and CapEff: 0000000000000000. Which securityContext fields produced that?allowPrivilegeEscalation: false produces NoNewPrivs 1, because the field is the inverse of the flag. capabilities.drop: ["ALL"] produces the empty effective capability set./tmp after readOnlyRootFilesystem: true was added. What is the correct fix?emptyDir volume at /tmp. Turning the read-only setting off would restore the writable image filesystem.enforce: restricted. Which four settings does the error ask for?allowPrivilegeEscalation=false, capabilities.drop=["ALL"], runAsNonRoot=true, and a seccompProfile.type of RuntimeDefault or Localhost.Sources
- Kubernetes documentation, Configure a Security Context for a Pod or Container: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
- Kubernetes documentation, Pod Security Standards: https://kubernetes.io/docs/concepts/security/pod-security-standards/
- Kubernetes API reference, Pod v1 (SecurityContext and PodSecurityContext): https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/
- CNCF, CKS Curriculum v1.34: https://github.com/cncf/curriculum/blob/master/CKS_Curriculum%20v1.34.pdf