Lab CKS-05 — Seccomp, AppArmor & Immutability

Three kernel-level controls that limit what a compromised container can do: seccomp filters system calls, AppArmor confines file and capability access, and a read-only root filesystem stops an attacker from changing the container at all.

Concepts: Seccomp and AppArmor, seccomp, AppArmor and SecurityContext.

Tested on kind v0.31 with Kubernetes v1.35.0 (the version the CKS exam used on 2026-10-13). Not re-run on v1.37; expect small differences in output there. The AppArmor section was not run at all; see its warning.

The seccomp and immutability sections were verified on kind v0.31 with Kubernetes v1.35.0.

AppArmor section not run on kind

A kind node shares the host kernel and does not expose the AppArmor interface inside the node container, so section 4 was written from the upstream documentation and not executed. Run it on a virtual machine node, such as the cluster from Lab CKA-03.


1. Prerequisites & Starting State

  • Working k8s-lab cluster from Lab CKAD-01.
  • A worker node shell:
docker exec -it k8s-lab-worker bash
# host
kubectl create namespace sandbox-lab

2. Seccomp: Unconfined, RuntimeDefault, Localhost

Step 1: See the default

On this cluster a Pod with no seccomp setting runs unconfined. That is the default only while the kubelet’s seccompDefault option is off; with it on, the same Pod gets RuntimeDefault. The check below shows which you have, and the restricted Pod Security Standard accepts either RuntimeDefault or Localhost:

kubectl -n sandbox-lab run none --image=busybox:1.36 -- sleep 3600
kubectl -n sandbox-lab exec none -- grep Seccomp: /proc/1/status
Seccomp:	0

0 means no filter, 2 means a filter is active.

Step 2: RuntimeDefault

apiVersion: v1
kind: Pod
metadata:
  name: runtime-default
  namespace: sandbox-lab
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: c
      image: busybox:1.36
      command: ["sleep", "3600"]
kubectl apply -f runtime-default.yaml
kubectl -n sandbox-lab exec runtime-default -- grep Seccomp: /proc/1/status
kubectl -n sandbox-lab exec runtime-default -- unshare -U true
kubectl -n sandbox-lab exec none -- unshare -U true

The filter is active (Seccomp: 2), and creating a user namespace fails with Operation not permitted in the confined Pod while the unconfined one allows it. RuntimeDefault is the container runtime’s built-in profile and is what the restricted Pod Security Standard requires.

Step 3: A custom Localhost profile

Profiles live under the kubelet’s seccomp directory on each node that may run the Pod:

# worker node
mkdir -p /var/lib/kubelet/seccomp/profiles
 
cat > /var/lib/kubelet/seccomp/profiles/deny-mkdir.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    { "names": ["mkdir", "mkdirat"], "action": "SCMP_ACT_ERRNO" }
  ]
}
EOF

localhostProfile is a path relative to /var/lib/kubelet/seccomp:

apiVersion: v1
kind: Pod
metadata:
  name: localhost-profile
  namespace: sandbox-lab
spec:
  nodeName: k8s-lab-worker
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/deny-mkdir.json
  containers:
    - name: c
      image: busybox:1.36
      command: ["sleep", "3600"]
kubectl apply -f localhost-profile.yaml
kubectl -n sandbox-lab exec localhost-profile -- mkdir /tmp/x
kubectl -n sandbox-lab exec none -- mkdir /tmp/x
mkdir: can't create directory '/tmp/x': Operation not permitted

The profile blocks mkdir in the first Pod only. nodeName pins the Pod to the node that has the file; in the exam the task tells you which node holds the profile.

This profile allows everything except two calls, which makes the effect easy to see. A real profile does the opposite: defaultAction: SCMP_ACT_ERRNO and an explicit allow list.


3. Immutable Containers

“Ensure immutability of containers at runtime” means the container cannot modify its own filesystem or gain privileges:

apiVersion: v1
kind: Pod
metadata:
  name: immutable
  namespace: sandbox-lab
spec:
  containers:
    - name: web
      image: nginxinc/nginx-unprivileged:1.28-alpine
      securityContext:
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
        runAsNonRoot: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}
kubectl apply -f immutable.yaml
kubectl -n sandbox-lab exec immutable -- touch /etc/x
kubectl -n sandbox-lab exec immutable -- touch /tmp/ok
touch: /etc/x: Read-only file system

Writing under /etc fails and writing under /tmp works, because that path is an emptyDir. The method for any image is the same: turn on readOnlyRootFilesystem, see where the application fails to write, and mount an emptyDir on exactly those paths.

Exam tasks on this topic often ask the reverse: given several Pods, find the ones that are not immutable and delete them. The markers are a missing readOnlyRootFilesystem: true, privileged: true, or a writable hostPath.

kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{"\t"}{.spec.containers[*].securityContext.readOnlyRootFilesystem}{"\n"}{end}'

4. AppArmor

Run this section on a node whose kernel exposes AppArmor: cat /sys/module/apparmor/parameters/enabled prints Y and aa-status works.

Step 1: Load a profile on the node

# node
cat > /etc/apparmor.d/k8s-deny-write <<'EOF'
#include <tunables/global>
 
profile k8s-deny-write flags=(attach_disconnected) {
  #include <abstractions/base>
 
  file,
  deny /** w,
}
EOF
 
apparmor_parser -q /etc/apparmor.d/k8s-deny-write
aa-status | grep k8s-deny-write

apparmor_parser loads the profile into the kernel. The name that matters afterwards is the one after the profile keyword, not the file name. Like seccomp profiles, an AppArmor profile must be loaded on every node that may run the Pod.

Step 2: Attach it to a container

apiVersion: v1
kind: Pod
metadata:
  name: apparmor
  namespace: sandbox-lab
spec:
  containers:
    - name: c
      image: busybox:1.36
      command: ["sleep", "3600"]
      securityContext:
        appArmorProfile:
          type: Localhost
          localhostProfile: k8s-deny-write
kubectl apply -f apparmor.yaml
kubectl -n sandbox-lab exec apparmor -- cat /proc/1/attr/current
kubectl -n sandbox-lab exec apparmor -- touch /tmp/x

The first command prints k8s-deny-write (enforce) and the second is denied.

The securityContext.appArmorProfile field was added in Kubernetes 1.30 and became stable in 1.31. Older material uses the annotation container.apparmor.security.beta.kubernetes.io/<container>: localhost/<profile>, which you may still meet in existing manifests.


5. Controlled Failure Scenario: A Profile That Is Not on the Node

Reference a seccomp profile that does not exist:

kubectl -n sandbox-lab run missing --image=busybox:1.36 --overrides='
{"spec":{"securityContext":{"seccompProfile":{"type":"Localhost","localhostProfile":"profiles/nope.json"}}}}' -- sleep 3600
kubectl -n sandbox-lab get pod missing
kubectl -n sandbox-lab describe pod missing | tail -5

The Pod is accepted by the API server and then fails on the node with CreateContainerError. The event names the path the kubelet looked for. The API server cannot validate a node-local file, so this class of mistake only shows up at runtime.

Three causes to check, in order: the file is on a different node than the Pod, the path is not relative to /var/lib/kubelet/seccomp, or the JSON is invalid.


6. Cleanup

# host
kubectl delete namespace sandbox-lab
# worker node
rm -rf /var/lib/kubelet/seccomp/profiles

Further reading

Next Lab

Proceed to Lab CKS-06 — Falco Runtime Detection, or return to the CKS Track.