Certified Kubernetes Security Specialist (CKS) #20 Full-Length Practice Exam — 16 Tasks with Solutions
From #1 the exam environment through #19 exam tips, we have circled all six domains once. The final post of this series is not one you read but one you solve. Just like the real CKS, it gathers 16 tasks that integrate every domain in one place. These are not multiple choice — they are hands-on scenarios where you make a cluster more secure, directly in an empty terminal and on the nodes, and each task carries a point value.
The recommended time limit is 2 hours, the same as the real exam. The pass line is 67%, scored by summing the point values of all 16 tasks. If you get stuck on a task, mark it, move on, and bank points from the high-value tasks you have a feel for first — that is the way over the pass line.
Because CKS also has multiple clusters, making context switching the very first thing you do prevents wrong answers. Each task is only graded if you solve it in the specified context, and since many tasks — AppArmor, seccomp, Falco — take you inside the nodes to work directly with Linux security tools, you also need a feel for SSH, systemctl, and editing files. For each task, solve it fully on your own first, then unfold the solution. If you read the solution first, your hands never learn it.
How to take it #
- Since CKA is a prerequisite, tasks that SSH into a node to run
systemctlor edit files should feel familiar. If that is hard locally, stand up one control plane and one worker on two or three cloud VMs. AppArmor, seccomp, and Falco don’t build the right feel on a single-node minikube, so solving them on an ordinary Linux node with kernel security modules enabled is closest to the real thing. - For each task, switch to the specified context first. As this series has repeated, a misconfigured context scores 0 even if your answer is correct.
k config use-context <the context the question specifies>- Some tasks have you SSH into the nodes, so check ahead of time that you can connect using the hostnames the exam presents (such as
node01). If you need root on a node, switch withsudo -i. - Solve all 16 to the end, then unfold the solutions and grade them in one pass. Peeking at solutions mid-exam dulls your sense of the real thing. Since kubernetes.io/docs along with the official Falco, Trivy, AppArmor, and gVisor docs are allowed, learning ahead of time where each tool’s docs cover profile syntax and command options will save you time.
Domain distribution #
The 16 tasks are arranged to match the domain weights of the real CKS. The last three domains (Microservices, Supply Chain, Runtime) are 20% each, together making up 60%, so more tasks are loaded onto them.
| # | Domain | Tasks | Task numbers |
|---|---|---|---|
| 1 | Cluster Setup | 2 | 1, 2 |
| 2 | Cluster Hardening | 2 | 3, 4 |
| 3 | System Hardening | 3 | 5, 6, 7 |
| 4 | Minimize Microservice Vulnerabilities | 3 | 8, 9, 10 |
| 5 | Supply Chain Security | 3 | 11, 12, 13 |
| 6 | Monitoring, Logging and Runtime Security | 3 | 14, 15, 16 |
The points reflect the domain weights and task difficulty, totaling 100. The scoring criteria are laid out at the end of the post.
Task 1 (6 points): Cluster Setup #
In namespace payments of context cluster1, apply a default deny. Every Pod in this namespace must have ingress and egress blocked by default, except that every Pod must be able to reach cluster DNS (port 53 in kube-system, TCP and UDP).
Solution
Apply a default deny that blocks all ingress and egress together with an allowance for DNS egress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: payments
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53k apply -f deny.yaml
k -n payments get netpolExplanation: An empty podSelector: {} targets every Pod in the namespace. Putting both Ingress and Egress in policyTypes and leaving the rule body empty makes it a bidirectional default deny. NetworkPolicy is additive-allow, so the second policy reopens just DNS. The most common trap is that blocking egress also cuts off DNS lookups, making all communication fail at the name-resolution stage — so never drop the port 53 allowance.
Task 2 (6 points): Cluster Setup #
Running kube-bench on the control plane node to check against the CIS benchmark produced FAILs for --profiling being on and --insecure-port not being 0. Fix these two kube-apiserver settings per the CIS recommendations to clear the FAILs.
Solution
Edit the apiserver static Pod manifest on the control plane node.
ssh cluster1-controlplane
sudo -i
vim /etc/kubernetes/manifests/kube-apiserver.yamlSet the two offending flags as follows.
- --profiling=false
- --insecure-port=0Wait for kubelet to detect the manifest change and recreate the apiserver Pod, then check again.
crictl ps | grep kube-apiserver
kube-bench run --targets masterExplanation: --profiling=false is the CIS recommendation to close the debugging profiling endpoint and reduce information exposure, and --insecure-port=0 is the recommendation to turn off the unauthenticated plaintext port. The apiserver is a static Pod, so editing the manifest makes kubelet recreate it automatically; if a syntax error keeps the Pod from coming up, check the cause with crictl logs. In recent versions --insecure-port itself may have been removed, so following the exact flag name in the check output is the safe move.
Task 3 (7 points): Cluster Hardening #
In context cluster1, the ClusterRole cluster-admin is bound via a ClusterRoleBinding to the ServiceAccount dev:ci-runner, granting excessive privileges. Remove this binding and replace it with a least-privilege Role and RoleBinding that can only get, list, and watch Pods and Deployments in namespace dev.
Solution
Delete the excessive ClusterRoleBinding and bind a new least-privilege Role.
k delete clusterrolebinding ci-runner-admin
k -n dev create role ci-reader \
--verb=get,list,watch \
--resource=pods,deployments.apps
k -n dev create rolebinding ci-runner-read \
--role=ci-reader \
--serviceaccount=dev:ci-runnerVerify the privileges have been narrowed as intended.
k -n dev auth can-i list pods --as=system:serviceaccount:dev:ci-runner
k -n dev auth can-i delete pods --as=system:serviceaccount:dev:ci-runnerExplanation: The principle of least privilege is granting a subject only the verbs, resources, and namespace it needs. cluster-admin permits every action across the entire cluster, which is excessive for a CI runner. In the verification, list pods should return yes and delete pods should return no to confirm the narrowing worked as intended. The key is leaving the ClusterRole alone and removing only the binding — deleting the ClusterRole itself could affect other subjects.
Task 4 (7 points): Cluster Hardening #
In namespace legacy, the Pod report, which runs with the default ServiceAccount and its mounted token, accesses the API server unnecessarily. Stop the report Pod from auto-mounting the ServiceAccount token. You may recreate the Pod.
Solution
Add automountServiceAccountToken: false to the Pod manifest and recreate it.
k -n legacy get pod report -o yaml > report.yamlAdd the following field under spec.
spec:
automountServiceAccountToken: false
containers:
- name: report
image: report:1.0k -n legacy delete pod report
k apply -f report.yamlConfirm the mount is gone.
k -n legacy exec report -- ls /var/run/secrets/kubernetes.io/serviceaccountExplanation: automountServiceAccountToken: false keeps the token from being injected at the Pod’s /var/run/secrets/kubernetes.io/serviceaccount path, so that even if the container is compromised, the API server credential doesn’t leak. This field can sit on both the Pod spec and the ServiceAccount, and the Pod spec takes precedence. If the verification command errors that the directory doesn’t exist, that is normal — it means the token is no longer mounted.
Task 5 (8 points): System Hardening #
Load the AppArmor profile k8s-deny-write onto the worker node node01, and make the Pod guarded (image nginx) in namespace apps apply this profile to the container web. The profile is at /etc/apparmor.d/k8s-deny-write and is an enforce-mode profile that denies file writes.
Solution
Load the profile in enforce mode on the node.
ssh node01
sudo -i
apparmor_parser -q /etc/apparmor.d/k8s-deny-write
aa-status | grep k8s-deny-write
exitAssign the AppArmor profile to the Pod.
apiVersion: v1
kind: Pod
metadata:
name: guarded
namespace: apps
spec:
containers:
- name: web
image: nginx
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-writek apply -f guarded.yaml
k -n apps get pod guardedExplanation: An AppArmor profile applies only if it is already loaded on the node where the container lands, so if the apparmor_parser enforce-load step is missing, the Pod gets stuck in a Blocked state. Recent Kubernetes uses the securityContext.appArmorProfile field, with type: Localhost and the profile name (not the file path) in localhostProfile. The older container.apparmor.security.beta.kubernetes.io/<container> annotation approach has the same effect, but follow the exam version’s approach.
Task 6 (8 points): System Hardening #
In namespace apps, create a Pod seccomp-web (image nginx) and make the container use the RuntimeDefault seccomp profile. This profile restricts dangerous system calls using the container runtime’s default blocklist.
Solution
Set securityContext.seccompProfile to RuntimeDefault.
apiVersion: v1
kind: Pod
metadata:
name: seccomp-web
namespace: apps
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: web
image: nginxk apply -f seccomp-web.yaml
k -n apps get pod seccomp-webExplanation: RuntimeDefault applies the default seccomp profile provided by the container runtime (containerd and the like) to block dangerous system calls without a separate file. Put it in the Pod-level securityContext and it is inherited by all containers; put it at the container level and only that container gets it. If you need a custom profile, point type: Localhost and localhostProfile at a JSON file under the node’s /var/lib/kubelet/seccomp/profiles, but this task uses the default profile, so no file is needed.
Task 7 (7 points): System Hardening #
The Deployment api in namespace apps runs with excessive Linux capabilities. Fix the securityContext so this Deployment’s container drops all capabilities and adds back only NET_BIND_SERVICE, and so privilege escalation is blocked.
Solution
Edit the Deployment’s container securityContext.
k -n apps edit deploy apiPlace the following in the container spec.
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICEAfter the rollout completes, confirm it took effect on the new Pods.
k -n apps rollout status deploy api
k -n apps get pod -l app=api -o jsonpath='{.items[0].spec.containers[0].securityContext}'Explanation: The pattern of capabilities.drop: [ALL] to first drop every privilege, then adding back only what’s needed with add, is the least-privilege pattern. NET_BIND_SERVICE is the privilege required to bind ports below 1024, so it is commonly kept on a web server that opens port 80. allowPrivilegeEscalation: false blocks a process from gaining more privilege than its parent via setuid binaries and the like. Editing the Deployment changes the template, so it takes effect only once Pods are recreated.
Task 8 (7 points): Minimize Microservice Vulnerabilities #
Enforce the restricted standard of Pod Security Admission in enforce mode on namespace restricted. Then confirm that creating a Pod that violates this standard (runs as root) is rejected.
Solution
Apply the PSA labels to the namespace.
k label namespace restricted \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latestAttempt to create a violating Pod and confirm it is rejected.
k -n restricted run rooty --image=nginxAfter confirming the rejection message, verify with a Pod that passes the standard.
apiVersion: v1
kind: Pod
metadata:
name: compliant
namespace: restricted
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: web
image: nginx
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALLExplanation: Pod Security Admission works via namespace labels and has three modes: enforce, audit, and warn. enforce=restricted outright rejects the creation of violating Pods. The restricted standard requires all of runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: [ALL], and seccompProfile, so a plain nginx Pod is rejected because it runs as root. The label key must be exactly pod-security.kubernetes.io/enforce to take effect.
Task 9 (7 points): Minimize Microservice Vulnerabilities #
On the control plane, turn on Secret encryption for data at rest in etcd. Configure an EncryptionConfiguration so that new Secrets are encrypted at rest with the AES-CBC (aescbc) method, and apply it to the apiserver.
Solution
Create the encryption config file on the control plane node.
ssh cluster1-controlplane
sudo -i
vim /etc/kubernetes/enc/enc.yamlapiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded 32-byte key>
- identity: {}Link the config file in the apiserver manifest.
- --encryption-provider-config=/etc/kubernetes/enc/enc.yamlAfter mounting /etc/kubernetes/enc into the apiserver container as a hostPath volume, re-encrypt the existing Secrets too.
k get secrets --all-namespaces -o json | kubectl replace -f -Explanation: The order of the providers list matters. The first provider is used for write encryption, and reads are tried from the top down, so put aescbc first and identity (plaintext) last. The key must be 32 bytes base64-encoded, generated with head -c 32 /dev/urandom | base64. Turning on the config does not auto-re-encrypt existing Secrets, so you must rewrite them with replace for them to actually become ciphertext.
Task 10 (6 points): Minimize Microservice Vulnerabilities #
For a workload that needs runtime isolation, create a RuntimeClass gvisor (handler runsc) that uses gVisor, and make the Pod sandboxed (image nginx) in namespace apps come up with this RuntimeClass. The runsc handler is already installed on the node.
Solution
Create the RuntimeClass and reference it from the Pod.
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runsc
---
apiVersion: v1
kind: Pod
metadata:
name: sandboxed
namespace: apps
spec:
runtimeClassName: gvisor
containers:
- name: web
image: nginxk apply -f gvisor.yaml
k -n apps get pod sandboxedExplanation: gVisor is a sandbox runtime that intercepts system calls in user space, greatly reducing the risk of container escape. The RuntimeClass handler must exactly match the handler name (runsc) registered in the node runtime (containerd) config. If it is scheduled onto a node without the handler, the Pod fails at the container-creation stage, so a nodeSelector or toleration pointing at the isolation nodes is often needed alongside it.
Task 11 (7 points): Supply Chain Security #
The Deployment legacy-app in namespace apps uses the heavy node:18 image. Switch the same app to the distroless base image gcr.io/distroless/nodejs18-debian12 to reduce the attack surface. You only need to swap the image; assume the build is already done.
Solution
Swap the Deployment’s image to distroless.
k -n apps set image deploy/legacy-app \
legacy-app=gcr.io/distroless/nodejs18-debian12
k -n apps rollout status deploy legacy-appConfirm the swapped image.
k -n apps get deploy legacy-app -o jsonpath='{.spec.template.spec.containers[0].image}'Explanation: A distroless image drops the shell, package manager, and unnecessary OS utilities, keeping only the runtime needed to run the app, which greatly reduces the number of vulnerabilities and the attack surface. The traps during debugging are that kubectl exec ... -- sh won’t work because there’s no shell, and that the ENTRYPOINT is fixed to the app binary. set image must name the container exactly; if the name is wrong, the command fails rather than adding a new container.
Task 12 (7 points): Supply Chain Security #
On the control plane node, scan the image nginx:1.21.0 with Trivy, narrow it to only HIGH and CRITICAL severity vulnerabilities, and save it to the file /opt/trivy-report.txt. Trivy is already installed on the node.
Solution
Filter by severity in the scan and save the results.
ssh cluster1-controlplane
sudo -i
trivy image --severity HIGH,CRITICAL nginx:1.21.0 > /opt/trivy-report.txt
cat /opt/trivy-report.txtTo pass only clean results with no vulnerabilities, use the exit code.
trivy image --severity CRITICAL --exit-code 1 nginx:1.21.0Explanation: --severity narrows results by joining severities with commas, and --exit-code 1 exits with 1 if there is even one vulnerability of those severities, which is used in a CI pipeline to block deployment. The exam often bundles scanning and swapping into “find the image with a CRITICAL and swap its Deployment to a safe image,” so a flow of reading scan results and tracing back which workload uses that image with kubectl get is needed alongside.
Task 13 (6 points): Supply Chain Security #
Verify with cosign whether the image registry.local/web@sha256:abc... you intend to deploy to namespace apps is signed with a trusted key. The public key is at /opt/cosign.pub. Assume the policy of deploying only images that pass verification.
Solution
Verify the image signature with the public key.
cosign verify --key /opt/cosign.pub registry.local/web@sha256:abc...If verification succeeds, deploy by that digest.
k -n apps set image deploy/web web=registry.local/web@sha256:abc...Explanation: cosign verify checks whether the image signature was made with the given public key; on success it prints the signature payload and exits with 0. You must verify and deploy by digest (@sha256:...) rather than a tag (:latest), so that the exact image you verified is the one deployed. The key trap is that a tag can later be overwritten with a different image, which voids the meaning of signature verification. To block unsigned images at the cluster level, enforce it at the admission stage with Kyverno’s verifyImages or the sigstore policy-controller.
Task 14 (8 points): Monitoring, Logging and Runtime Security #
Kyverno is installed in the cluster. Create a ClusterPolicy disallow-latest-tag in enforce mode that rejects container images that use the latest tag or omit the tag, across all namespaces.
Solution
Create a Kyverno ClusterPolicy that validates the image tag.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-latest-tag
spec:
validationFailureAction: Enforce
rules:
- name: require-image-tag
match:
any:
- resources:
kinds:
- Pod
validate:
message: "An explicit image tag is required and latest is forbidden."
pattern:
spec:
containers:
- image: "!*:latest"k apply -f disallow-latest.yaml
k run probe --image=nginx:latestExplanation: validationFailureAction: Enforce rejects policy-violating resources at the admission stage (Audit only records a warning). The pattern !*:latest blocks the latest tag with a negative match, and since omitting the tag makes the runtime interpret it as latest, that is caught too. The nginx:latest in the verification command above should be rejected to be correct. Writing the same policy with OPA/Gatekeeper splits it into two resources, a ConstraintTemplate and a Constraint — that is the difference from Kyverno.
Task 15 (7 points): Monitoring, Logging and Runtime Security #
Falco is installed on the worker node node01. Add a custom Falco rule Shell in container that logs at Warning when a shell (bash or sh) runs inside a container, and restart Falco to apply the rule.
Solution
Add the rule to the node’s Falco local rules file.
ssh node01
sudo -i
vim /etc/falco/falco_rules.local.yaml- rule: Shell in container
desc: Detect a shell running inside a container
condition: >
spawned_process and container
and proc.name in (bash, sh)
output: >
Shell spawned in container
(user=%user.name container=%container.id command=%proc.cmdline)
priority: WARNINGRestart Falco and confirm the rule loaded.
systemctl restart falco
journalctl -u falco -e | grep "Shell in container"Explanation: A custom rule should go in falco_rules.local.yaml, not the main falco_rules.yaml, so it isn’t overwritten on a package upgrade. The condition ANDs spawned_process (process creation) and container (container context), and priority uses the uppercase severity name. The rule takes effect only after you restart Falco, and you verify by spawning a shell with kubectl exec <pod> -- bash and checking that a Warning log appears.
Task 16 (8 points): Monitoring, Logging and Runtime Security #
On the control plane, turn on apiserver audit logging. Create an audit policy that records all requests for the Secret resource at the RequestResponse level and all other requests at the Metadata level, and apply it to the apiserver so the log lands at /var/log/kubernetes/audit.log.
Solution
Create the audit policy file on the control plane node.
ssh cluster1-controlplane
sudo -i
vim /etc/kubernetes/audit/policy.yamlapiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
resources:
- group: ""
resources:
- secrets
- level: MetadataAdd the audit flags to the apiserver manifest and mount the policy and log directories as hostPath.
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit.log
- --audit-log-maxage=30crictl ps | grep kube-apiserver
tail -f /var/log/kubernetes/audit.logExplanation: The rules in an audit policy apply by the first rule that matches, from the top, so put the more specific Secret rule (RequestResponse) above and the catch-all rule (Metadata) below. RequestResponse records down to the request and response bodies, making it the most detailed, while Metadata leaves only who did what. A common trap is failing to mount the policy file and log directory into the apiserver container as hostPath volumes — the container then can’t find the paths and the apiserver won’t come up.
Scoring criteria #
Grade by summing each task’s points. The total is 100, and 67 or higher is the passing zone.
| Domain | Tasks , points | Subtotal |
|---|---|---|
| Cluster Setup | 1(6) , 2(6) | 12 |
| Cluster Hardening | 3(7) , 4(7) | 14 |
| System Hardening | 5(8) , 6(8) , 7(7) | 23 |
| Minimize Microservice Vulnerabilities | 8(7) , 9(7) , 10(6) | 20 |
| Supply Chain Security | 11(7) , 12(7) , 13(6) | 20 |
| Monitoring, Logging and Runtime Security | 14(8) , 15(7) , 16(8) | 23 |
| Total | (total) | 112 |
The subtotals sum to 112 as in the table above, and the actual grading sums each task’s points scaled to a 100-point maximum. The CKS pass line is 67%, a notch higher than the 66% of CKA and CKAD, so banking points reliably from the tasks whose tools are in your hands matters even more.
Grading is result-based, just like the real exam. It looks not at how you typed the commands but at whether the resources you created and the more secure cluster state match the requirements. Even within a single task, partial credit is split by item — labels, fields, profile application, flags — so even when you’re stuck on one item, filling in the parts you can to the end is better for your score.
Reviewing weak domains #
After grading, go back to the corresponding post in the table below for any low-scoring domain and review it.
| Domain | Related tasks | Posts to review |
|---|---|---|
| Cluster Setup | 1, 2 | #2 , #3 |
| Cluster Hardening | 3, 4 | #4 , #5 |
| System Hardening | 5, 6, 7 | #6 , #7 , #8 |
| Minimize Microservice Vulnerabilities | 8, 9, 10 | #9 , #10 , #11 |
| Supply Chain Security | 11, 12, 13 | #13 , #14 , #15 |
| Monitoring, Logging and Runtime Security | 14, 15, 16 | #16 , #17 , #18 |
If you ran short on time on a particular task, it may be a matter of hand speed rather than domain knowledge. In that case, re-read #19 exam tips and solve the same 16 tasks once more against the clock. Loading AppArmor, applying seccomp, encrypting etcd, and adding Falco rules all drop noticeably in time per task once they’re in your hands.
Closing the series #
Starting from the exam environment in #1, we passed through all six CKS domains across 20 posts — NetworkPolicy, kube-bench, RBAC least privilege, ServiceAccount tokens, AppArmor, seccomp, capabilities, Pod Security Admission, etcd encryption, gVisor, distroless, Trivy, cosign, Kyverno, Falco, and audit logs. If you cleared 67 points on this mock, you have built hands that can clear the pass line in the real exam room too. Congratulations.
If CKAD is the developer’s hands-on exam for workloads and CKA the operator’s hands-on exam for the cluster, CKS is the security specialist’s hands-on exam for keeping that cluster safe from attack. If you have finished all three series, you have passed the full scope of all three CNCF Kubernetes certifications (CKAD, CKA, CKS) with your own hands — heartfelt congratulations on completing all three.