15 KiB
CKS Mock Exam — k8s v1.35
Format mirrors the real thing: 17 tasks, 2 hours, ~66% to pass. Each task shows a weight and a domain. In the real exam every task tells you which cluster/context to kubectl config use-context into first — here it's all one cluster (kind-cks), but the habit of reading the context line first is baked in anyway. Tasks touching control-plane files run via docker exec cks-control-plane ...; host/kernel tasks run on the node container or your Ubuntu VM as noted.
Ground rules (same as real exam):
- Bookmark allowed docs only: kubernetes.io/docs, kubernetes.io/blog, falco.org, and the tool projects. Practice with those tabs only.
- No stopping the clock. Do the fast, formulaic tasks first (NetworkPolicy, RBAC, PSA labels), leave apiserver-restart tasks for a block where you can afford the ~1 min downtime.
- Every task:
kubectl config use-context kind-cksbefore you start (muscle memory).
Setup: ./bootstrap.sh then ./seed.sh. Per-task extra setup is in each Setup block below.
Weight tally: Cluster Setup 15 · Cluster Hardening 15 · System Hardening 10 · Microservice Vuln 20 · Supply Chain 20 · Monitoring/Logging/Runtime 20.
Task 1 — Default-deny + selective NetworkPolicy · Cluster Setup · 4%
Context: kind-cks
Setup: seeded by seed.sh (namespace prod: db, frontend, attacker).
Task:
In namespace prod, the db workload must only be reachable from the frontend workload.
- Create a NetworkPolicy
default-denyinprodthat denies all ingress to every pod in the namespace. - Create a NetworkPolicy
allow-frontendinprodthat allows ingress to pods labelledapp=dbonly from pods labelledapp=frontend, on TCP 5432.
Do not modify the deployments. attacker must NOT be able to reach db; frontend must.
Verify: exec into
frontendandattacker,curl db:5432— frontend succeeds, attacker times out.
Task 2 — TLS-terminated Ingress · Cluster Setup · 4%
Context: kind-cks
Setup: seeded (web ns: hello Deployment + Service). ingress-nginx installed by bootstrap.
Task:
Expose the hello Service in namespace web over HTTPS.
- Create a TLS Secret
hello-tlsinwebfrom a self-signed cert for hosthello.cks.local(you generate the cert/key on the VM). - Create an Ingress
helloinwebrouting hosthello.cks.localpath/to Servicehello:80, usinghello-tlsfor TLS.
Verify:
curl -k --resolve hello.cks.local:443:127.0.0.1 https://hello.cks.local/returnshello-tls(adjust port to the ingress-nginx NodePort/hostPort in your setup).
Task 3 — kube-bench: remediate CIS findings · Cluster Setup · 5%
Context: kind-cks (control-plane node)
Setup: none beyond the cluster.
Task:
Run kube-bench against the control-plane node and remediate the following FAIL items so a re-run passes:
- kubelet:
--anonymous-authmust befalse. - kubelet:
--authorization-modemust not beAlwaysAllow(useWebhook). - kubelet: read-only port must be disabled (
--read-only-port=0). - kube-apiserver:
--profilingmust befalse.
Apply the kubelet changes via the kubelet config on the node and restart kubelet; apply the apiserver change via the static manifest. Re-run kube-bench to confirm those four checks pass.
The kubelet config on a kind node is
/var/lib/kubelet/config.yaml; the apiserver manifest is/etc/kubernetes/manifests/kube-apiserver.yaml.
Task 4 — RBAC least privilege · Cluster Hardening · 5%
Context: kind-cks
Setup: seeded (dev ns: SA ci-runner, Role ci-runner-role with */*/*, RoleBinding ci-runner-rb).
Task:
The ci-runner ServiceAccount in dev is bound to a cluster-admin-equivalent Role. Restrict it to least privilege:
ci-runnermust be able to get, list, watch onlypodsandconfigmapsindev.- It must have no other permissions anywhere.
Do not delete the SA or the RoleBinding — reshape the Role (or replace it) so the binding still points at the right role name. Verify with kubectl auth can-i.
Verify:
kubectl auth can-i delete pods --as=system:serviceaccount:dev:ci-runner -n dev→ no;... get pods ...→ yes.
Task 5 — API server hardening · Cluster Hardening · 5%
Context: kind-cks (control-plane)
Setup: none.
Task: Edit the kube-apiserver static manifest so that:
- Anonymous auth is disabled (
--anonymous-auth=false). - The
NodeRestrictionadmission plugin is enabled. - Profiling is disabled (
--profiling=false) — if you already did this in Task 3, confirm it's present.
Wait for the apiserver static pod to come back healthy before moving on.
Verify:
kubectl -n kube-system get pod -l component=kube-apiserverRunning;curl -k https://127.0.0.1:6443/healthzfrom inside the node returns anonymous 401/403 rather thanok.
Task 6 — Disable ServiceAccount token automount · Cluster Hardening · 5%
Context: kind-cks
Setup: seeded (apps ns: SA web-sa, Deployment web).
Task:
Pods in namespace apps should not receive an automounted ServiceAccount token unless they explicitly need one.
- Set
automountServiceAccountToken: falseon theweb-saServiceAccount. - Ensure the running
webpods no longer mount the token (roll them if needed).
Do not change the ServiceAccount name or the Deployment's serviceAccountName.
Verify:
kubectl -n apps exec deploy/web -- ls /var/run/secrets/kubernetes.io/serviceaccount→ no such file/dir.
Task 7 — AppArmor profile enforcement · System Hardening · 5%
Context: kind-cks (worker node + cluster)
Setup: run this to stage the profile file on a worker (host must have AppArmor — Ubuntu VM does):
# on the VM
cat > /tmp/deny-write <<'EOF'
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
#include <abstractions/base>
file,
deny /** w,
}
EOF
docker cp /tmp/deny-write cks-worker:/root/deny-write
Task:
- Load the AppArmor profile
k8s-deny-writein enforce mode on nodecks-worker. - Create a Pod
lockedin namespacesyshrunningimage: busybox:1.36, commandsleep→ use["sh","-c","sleep 1h"], that runs under thek8s-deny-writeprofile using the AppArmor securityContext API (not the deprecated annotation). Pin it tocks-worker.
Verify:
kubectl -n sysh exec locked -- sh -c 'echo x > /tmp/x'→ Permission denied.
Task 8 — seccomp custom profile · System Hardening · 5%
Context: kind-cks (worker node + cluster)
Setup: stage a custom seccomp profile on the worker:
cat > /tmp/audit.json <<'EOF'
{ "defaultAction": "SCMP_ACT_LOG" }
EOF
docker exec cks-worker mkdir -p /var/lib/kubelet/seccomp/profiles
docker cp /tmp/audit.json cks-worker:/var/lib/kubelet/seccomp/profiles/audit.json
Task:
Create a Pod traced in namespace sysh (image: busybox:1.36, ["sh","-c","sleep 1h"], pinned to cks-worker) that uses the Localhost seccomp profile profiles/audit.json. Then create a second Pod defaulted in sysh that uses the RuntimeDefault seccomp profile.
Verify:
kubectl -n sysh get pod traced -o jsonpath='{.spec.securityContext.seccompProfile}'shows the Localhost profile.
Task 9 — Enforce restricted Pod Security Standard · Microservice Vuln · 5%
Context: kind-cks
Setup: seeded (restricted-ns: Deployment payments that is privileged → violates restricted).
Task:
- Label namespace
restricted-nsto enforce therestrictedPod Security Standard (latest version), and to warn/audit atrestrictedtoo. - The existing
paymentsDeployment currently violates it. Modify the pod template so it complies withrestricted(drop privilege, set the required securityContext fields) and rolls out successfully.
Verify:
kubectl -n restricted-ns get deploy paymentshas ready replicas; creating a privileged pod in the ns is rejected.
Task 10 — Encrypt Secrets at rest · Microservice Vuln · 6%
Context: kind-cks (control-plane)
Setup: create a secret that currently sits in etcd in plaintext:
kubectl create ns vault-ns --dry-run=client -o yaml | kubectl apply -f -
kubectl -n vault-ns create secret generic pre-existing --from-literal=api=SUPERSECRET
Task:
- Configure an
EncryptionConfiguration(provideraescbcwith a 32-byte key,identityas fallback) at/etc/kubernetes/enc/enc.yamlon the control-plane, and point the apiserver at it via--encryption-provider-config(mount it into the static pod). - Ensure all existing Secrets cluster-wide are rewritten so they're stored encrypted.
Verify: read the raw etcd value for
secret/vault-ns/pre-existingwithetcdctlinside the control-plane — it must start withk8s:enc:aescbc:and not containSUPERSECRET.
Task 11 — RuntimeClass for sandboxed workload · Microservice Vuln · 4%
Context: kind-cks
Setup: none.
Task:
- Create a RuntimeClass
gvisorwith handlerrunsc. - Create a Pod
sandboxedin namespaceruntime(image: nginx:1.27) that runs under thegvisorRuntimeClass.
Note: kind's containerd has no
runschandler, so the pod will stayPending/ContainerCreating— that's expected. The graded artifact is the correct RuntimeClass + pod spec. (On a VM with gVisor + a containerdrunscruntime configured, it would actually run.)
Verify:
kubectl get runtimeclass gvisorexists with handlerrunsc; pod references it.
Task 12 — ValidatingAdmissionPolicy (in-tree, CEL) · Microservice Vuln · 5%
Context: kind-cks
Setup: none.
Task:
Using the built-in ValidatingAdmissionPolicy API (no external webhook, no Kyverno/OPA), block the creation of any Pod that:
- sets
privileged: trueon any container, or - uses a
hostPathvolume.
Enforce it cluster-wide with failurePolicy: Fail. Bind it so it's active.
Verify: applying a pod with
privileged: trueor ahostPathvolume is denied by the policy; a clean pod is admitted.
Task 13 — Trivy image scan & evict vulnerable pods · Supply Chain · 6%
Context: kind-cks
Setup: seeded (images ns: legacy-app [nginx:1.19.0], old-debian [debian:10], clean-app [nginx:1.27]).
Task:
Using trivy on the VM, scan the images used by the pods in namespace images. Delete every pod whose image has at least one HIGH or CRITICAL severity, OS-level vulnerability. Leave the rest running.
Verify:
legacy-appandold-debiangone;clean-app(or whichever scans clean of HIGH/CRITICAL) remains.
Task 14 — Kyverno policy: no :latest, trusted registries only · Supply Chain · 6%
Context: kind-cks
Setup: install Kyverno (latest) — do this at the start of the task:
# latest install manifest
kubectl create -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=180s
kubectl create ns team-a --dry-run=client -o yaml | kubectl apply -f -
Task: Author Kyverno ClusterPolicies (enforce mode) that:
- Reject any Pod whose container image uses the
:latesttag or has no tag. - Reject any Pod whose image does not come from registry
registry.cks.local(allow that registry only).
Apply them cluster-wide.
Verify:
kubectl -n team-a run bad --image=nginx:latest→ denied;kubectl -n team-a run bad2 --image=docker.io/nginx:1.27→ denied (wrong registry);... --image=registry.cks.local/nginx:1.27→ admitted (may fail to pull, but admission passes).
Task 15 — ImagePolicyWebhook admission control · Supply Chain · 4%
Context: kind-cks (control-plane)
Setup: stage the admission config + webhook kubeconfig (you'll write these as part of the task; skeleton paths below):
/etc/kubernetes/admission/admission-config.yaml/etc/kubernetes/admission/imagepolicy-kubeconfig.yaml
Task:
Enable the ImagePolicyWebhook admission plugin on the kube-apiserver and wire it to an AdmissionConfiguration file that:
- references the webhook via a kubeconfig,
- sets
defaultAllow: falsein the ImagePolicy plugin config,
Mount both files into the static pod and enable the plugin. (No live backend is required — grading is on the admission plugin being enabled and the config file being correctly referenced. With defaultAllow: false and no reachable backend, new pod creation should be rejected — demonstrate that, then set defaultAllow: true to restore usability if you want the cluster functional for later tasks.)
Verify: with
defaultAllow: false,kubectl run t --image=nginx:1.27is rejected by the ImagePolicyWebhook.
Task 16 — Falco: custom runtime rule · Monitoring/Logging/Runtime · 7%
Context: Ubuntu VM (host-installed Falco) — this mirrors the classic CKS Falco task.
Setup: install Falco on the VM (latest, v0.44.x, modern eBPF):
curl -fsSL https://falco.org/repo/falcosecurity-packages.asc | sudo gpg --dearmor -o /usr/share/keyrings/falco-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] https://download.falco.org/packages/deb stable main" | sudo tee /etc/apt/sources.list.d/falcosecurity.list
sudo apt update && sudo apt install -y falco
(If you'd rather do it in-cluster: helm install falco falcosecurity/falco -n falco --create-namespace --set driver.kind=modern_ebpf — but the on-VM path is closer to the exam.)
Task:
- Add a custom Falco rule (in
/etc/falco/falco_rules.local.yaml) namedShell in containerthat fires at priorityWARNINGwhenever a shell (sh,bash) is spawned inside any container, with output including the container id, container name, and the spawned process name. - Restart Falco, trigger the rule (spawn a shell in a container), and capture the alert line to
/opt/course/falco-hits.txt.
Verify: the rule appears in Falco's loaded rules; triggering a shell produces a
Shell in containeralert containing the container name and proc name.
Task 17 — API server audit logging · Monitoring/Logging/Runtime · 6%
Context: kind-cks (control-plane)
Setup: none.
Task: Configure kube-apiserver audit logging:
- Write an audit policy at
/etc/kubernetes/audit/policy.yamlthat:- logs
Metadatalevel forsecrets,configmapsin all namespaces, - logs
RequestResponselevel forpodschanges (create/update/delete), - logs everything else at
None(don't log read-only noise).
- logs
- Enable it on the apiserver:
--audit-policy-file,--audit-log-path=/var/log/kubernetes/audit/audit.log,--audit-log-maxage=7,--audit-log-maxbackup=2,--audit-log-maxsize=50. Mount the policy (read-only) and the log dir (read-write) into the static pod.
Verify: after the apiserver restarts,
kubectl -n vault-ns get secret pre-existingthen check/var/log/kubernetes/audit/audit.loginside the control-plane contains a metadata-level entry for that secret and noRequestResponsebodies for it.
Scoring yourself
- 12+/17 fully correct ≈ comfortable pass.
- Time-box: if a control-plane restart task (5, 10, 15, 17) breaks the apiserver, you lose access to everything — practice reverting fast (
docker exec ...edit back, orcpa backup of the manifest you took first). Alwayscpthe manifest before editing.
Answers in answers/solutions.md. Don't peek until you've timed a full run.