TT Lab
Get started
Learn Learning paths Courses

CKS — Kubernetes Security Specialist

CKS Mock Exam B

Continue in TT Lab

Goal

This is round B. There is not a single question that overlaps with round A. It asks about the same domains in the same proportions, but the skills it checks are all different. If you take it right after working through A, it will reveal which domain is still weak.

You solve 17 tasks within 120 minutes under the same conditions as the real CKS. The passing score is 67%, and since it uses partial credit, passing 12 of the 17 counts as complete.

This is a practice exam. Don't look at the hints or the answer key; try to finish it through to the end first. It is better to mark a task you are stuck on, move past it, and come back in the remaining time. You can press grading at any time, and pressing it several times doesn't change the result.

Why it matters

The CKS is the only exam with passing the CKA as a prerequisite. It assumes you already have the hands to work with Kubernetes, so the questions are closer to "find what is dangerous in this configuration and fix it" than to "create this." Defense and diagnosis are what is tested, and attack techniques are not covered.

This round especially asks about how to narrow a boundary. Narrowing a permission down to a single name, attaching an audience and a lifetime to a token, and writing a rule once but cutting its effect down to one namespace. Opening things broadly and narrowing them later almost never happens in practice, so hands that open narrowly from the start are the real skill.

Exam environment (facts confirmed in the real exam)

What is different in this practice exam environment

This lab's cluster is a single-user cluster that runs inside a Pod. The kube-apiserver is real, so manifests, RBAC, Pod security admission, and admission policies really work and really reject. However, there is no runtime that actually runs containers, so the following three things can't be confirmed.

Everything else is recomputed and graded on the live cluster. Permissions are asked again with kubectl auth can-i, and admission policies and Pod security admission are asked again with a server dry-run for the verdict at that moment.

Steps

Cluster Setup

  1. Create the namespace staging, and in it create a NetworkPolicy api-allow that applies to Pods labeled app=api, not app=web. For ingress, allow only TCP 8080 coming from Pods labeled role=web inside namespaces labeled env=frontend, and block all other ingress. For egress, allow only UDP 53 and TCP 53 and block everything else.
  2. Save the SHA-256 hashes of /usr/local/bin/kubectl and /usr/local/bin/helm to /root/exam/verify/binaries.sha256 exactly in the sha256sum output format. The paths must be absolute paths, and both entries must pass when verified with sha256sum -c.
  3. Write a kubelet configuration in /root/exam/kubelet/config.yaml. apiVersion is kubelet.config.k8s.io/v1beta1 and kind is KubeletConfiguration. Turn off anonymous access, turn on webhook authentication, set the authorization mode to Webhook, close the unauthenticated read-only port, and turn on kernel defaults protection.

Cluster Hardening

  1. In the namespace staging, create a Role web-deployer and a RoleBinding ci-deployer-web so that the user ci-deployer can get, patch, and update only the single Deployment web. It must not be able to touch other Deployments in the same namespace, and must not be able to see the list of Deployments either.
  2. In the namespace staging, create a service account agent and a Pod collector. The Pod uses the service account agent but turns off default automatic token mounting, and instead, with a projected volume token, has a service account token issued with the audience vault and a validity period of 3600 seconds under the file name token, and mounts it at /var/run/secrets/vault. The container name is app.
  3. Create a ClusterRole namespace-reader that can get, list, and watch pods, services, and configmaps in the core group. And create a RoleBinding oncall-reader so that the user oncall has that permission only inside the namespace staging. It must not be able to read anything in other namespaces, and even inside staging it must not be able to read Secrets.

System Hardening

  1. Write a seccomp profile in /root/exam/seccomp/net-deny.json. The default action is SCMP_ACT_LOG, and the socket and connect system calls must be blocked with SCMP_ACT_ERRNO. And create a Pod sensor in the namespace staging, putting the RuntimeDefault profile at the Pod level and making only the container app use this profile in the Localhost way at the path profiles/net-deny.json.
  2. Write a sudo rule in /root/exam/sudoers.d/deploy. The user deploy must be able to run only the single command /usr/bin/systemctl restart kubelet as root without being asked for a password. Don't include any rule that uses ALL in the command position. The file must pass the syntax check of visudo -cf.

Minimize Microservice Vulnerabilities

  1. Create the namespace payments and apply Pod security admission in stages. enforce is baseline and its version is pinned to v1.31. audit and warn are restricted and their version is latest. As a result, a Pod that violates baseline must actually be rejected, and a Pod that violates only restricted must be created while receiving a warning.
  2. Create a Secret db-cred in the namespace payments. The type is the default (Opaque) and the keys are username and password. And create a Pod reporter that mounts this Secret as the volume cred at /etc/db read-only, with file permissions of octal 0400. Don't put the secret values in environment variables. The container name is app.
  3. Write an encryption-at-rest configuration in /root/exam/encryption/config.yaml. apiVersion is apiserver.config.k8s.io/v1 and kind is EncryptionConfiguration. The encryption target is secrets, and for the providers, aescbc must be first and identity last. The aescbc key must have a name and its value must be 32 bytes written in base64.

Supply Chain Security

  1. Create the namespace supply, and create the credentials for accessing the registry registry.internal as the user deployer as a Secret regcred. Connect the service account puller so that it pulls images with that Secret, and make the Deployment catalog use that service account. The container name is app and the image is registry.internal/catalog:1.4.2.
  2. Block moving tags in the namespace supply. Create a ValidatingAdmissionPolicy no-latest-tag and a ValidatingAdmissionPolicyBinding no-latest-tag, and have them require that the images of all containers and all init containers of a Pod do not end with :latest. The binding's validationActions is Deny and the scope of application is only the namespace supply. Other namespaces must not be affected.
  3. Write an admission configuration file in /root/exam/imagepolicy/admission-config.yaml. apiVersion is apiserver.config.k8s.io/v1, kind is AdmissionConfiguration, and the only plugin is ImagePolicyWebhook. In that configuration's imagePolicy, write /etc/kubernetes/imagepolicy/kubeconfig.yaml as the kubeConfigFile, set allowTTL 50, denyTTL 50, and retryBackoff 500, and set defaultAllow to false so that images are not allowed when the webhook can't be reached.

Monitoring, Logging and Runtime Security

  1. Write runtime detection rules in /root/exam/falco/rules.yaml. The file is a list of entries, and it must contain one list entry named trusted_writers and one rule entry named Write below etc. The list must have at least one item. The rule's priority is WARNING, desc and tags must not be empty, the condition must contain all of open_write, /etc, and trusted_writers, and the output must contain %proc.name and %fd.name.
  2. Write an audit policy in /root/exam/audit/quiet-policy.yaml. apiVersion is audit.k8s.io/v1 and kind is Policy, and there are exactly four rules, with order mattering. First, discard with None the watch by the user system:kube-proxy on endpoints and services of the core group. Second, discard with None access by the group system:authenticated to the non-resource paths /api* and /version. Third, record the core group secrets of the namespace payments at RequestResponse. Fourth, catch everything else with a Metadata rule that lists no targets.
  3. Create the namespace runtime, and force every container of the Pods created in it to have readOnlyRootFilesystem: true. Create a ValidatingAdmissionPolicy immutable-root and a ValidatingAdmissionPolicyBinding immutable-root; the binding's validationActions is Deny and the scope of application is only the namespace runtime. Other namespaces must not be affected.

Notes

An allowlist with two selectors together

Create the namespace staging, and create a NetworkPolicy api-allow that applies to Pods labeled app=api. For ingress, allow only TCP 8080 coming from Pods labeled role=web inside namespaces labeled env=frontend, and block all other ingress. For egress, allow only UDP 53 and TCP 53 and block everything else.

If you write a namespaceSelector and a podSelector together inside one from entry, only Pods that satisfy both conditions are selected. If you split them into two entries, it opens when either one alone is satisfied, so the scope widens. You must list both Ingress and Egress in policyTypes for egress to be controlled too. If you write only ports in an egress rule and no to, it means opening only the port without restricting the destination.

Verify binaries before deployment

Save the SHA-256 hashes of /usr/local/bin/kubectl and /usr/local/bin/helm to /root/exam/verify/binaries.sha256 exactly in the sha256sum output format. The paths must be absolute paths, and both entries must pass when verified with sha256sum -c.

The output of sha256sum is exactly the input format of sha256sum -c. If you give several files at once, several lines come out. If you copy hashes by hand, verification fails on even one wrong character, so save the output as is with redirection. If you write a relative path, the file can't be found at verification time depending on the working directory.

Strengthen the kubelet configuration

Write a kubelet configuration in /root/exam/kubelet/config.yaml. apiVersion is kubelet.config.k8s.io/v1beta1 and kind is KubeletConfiguration. Turn off anonymous access, turn on webhook authentication, set the authorization mode to Webhook, close the unauthenticated read-only port, and turn on kernel defaults protection.

Anonymous access and webhook authentication are separate settings under authentication, and the authorization mode is authorization.mode. If you leave authorization as AlwaysAllow, no matter how tightly you clamp down authentication, anyone can do anything. The unauthenticated read port is readOnlyPort and the value that closes it is 0. Kernel defaults protection is protectKernelDefaults, and it stops the kubelet from changing kernel parameters on its own.

A permission narrowed to a single name

In the namespace staging, create a Role web-deployer and a RoleBinding ci-deployer-web so that the user ci-deployer can get, patch, and update only the single Deployment web. It must not be able to touch other Deployments in the same namespace, and must not be able to see the list of Deployments either.

If you write resourceNames in a rule, the rule applies only to the object with that name. A list request is a request with no name, so it isn't allowed by a rule narrowed with resourceNames. Deployment is in the apps group, not the core group. The subject kind is User and the apiGroup is rbac.authorization.k8s.io. To check, attach a name after the resource and ask like kubectl auth can-i patch deployments/web.

A token with a fixed audience and lifetime

In the namespace staging, create a service account agent and a Pod collector. The Pod uses the service account agent but turns off default automatic token mounting, and instead, with a projected volume token, has a service account token issued with the audience vault and a validity period of 3600 seconds under the file name token, and mounts it at /var/run/secrets/vault. The container name is app.

If you put a serviceAccountToken in the projected volume's sources, the kubelet issues a short-lived token and puts it in. If you write an audience, it becomes a token usable only for that audience, and expirationSeconds sets the lifetime. The file name inside the volume is set with path. What turns off the default token is the Pod spec's automountServiceAccountToken, and even if you turn this off, a projected token you created explicitly still goes in.

A rule once, its effect in one namespace

Create a ClusterRole namespace-reader that can get, list, and watch pods, services, and configmaps in the core group. And create a RoleBinding oncall-reader so that the user oncall has that permission only inside the namespace staging. It must not be able to read anything in other namespaces.

A RoleBinding's roleRef can point to a ClusterRole as well as a Role, and in that case the effect of the rules is cut down to the namespace where that RoleBinding sits. If you bind the same ClusterRole with a ClusterRoleBinding, it spreads across the whole cluster, so you must check that another namespace gives no in order to tell the two apart. The subject kind is User and the apiGroup is rbac.authorization.k8s.io.

Overriding seccomp at the container level

Write a seccomp profile in /root/exam/seccomp/net-deny.json. The default action is SCMP_ACT_LOG, and the socket and connect system calls must be blocked with SCMP_ACT_ERRNO. And create a Pod sensor in the namespace staging, putting the RuntimeDefault profile at the Pod level and making only the container app use this profile in the Localhost way at the path profiles/net-deny.json.

A seccomp profile sets the default verdict with defaultAction and lists exceptions in the syscalls array. If the default is log (SCMP_ACT_LOG), you only need to pick what to block and write it as SCMP_ACT_ERRNO. securityContext exists on both the Pod and the container, and the container side wins. The localhostProfile of the Localhost approach is a path relative to the node's seccomp root.

Minimize host account privileges

Write a sudo rule in /root/exam/sudoers.d/deploy. The user deploy must be able to run only the single command /usr/bin/systemctl restart kubelet as root without being asked for a password. Don't include any rule that uses ALL in the command position. The file must pass the syntax check of visudo -cf.

The shape of one rule line is 'user host=(run-as account) tag: command'. The tag that stops it from asking for a password is NOPASSWD:, and if you write ALL in the command position, every command is opened up. Write the command as an absolute path, and if you include the arguments, it is restricted to just those arguments. Check the syntax with visudo -cf filename.

Pod security admission raised in stages

Create the namespace payments and apply Pod security admission in stages. enforce is baseline and its version is pinned to v1.31. audit and warn are restricted and their version is latest. As a result, a Pod that violates baseline must actually be rejected, and a Pod that violates only restricted must be created while receiving a warning.

Pod security admission is turned on with namespace labels, and you can set the level separately for each mode. Only enforce blocks; audit and warn only leave records and warnings. That is why the order in practice is to first apply the level you will raise to on audit and warn, see how much gets caught, and then raise enforce. The version is a label with -version added to the same prefix. That makes six labels.

Secrets only as files

Create a Secret db-cred in the namespace payments. The type is the default (Opaque) and the keys are username and password. And create a Pod reporter that mounts this Secret as the volume cred at /etc/db read-only, with file permissions of octal 0400. Don't put the secret values in environment variables. The container name is app.

A Secret put in as an environment variable also shows up in the process list, in crash dumps, and in kubectl describe. If you put it in as a file, only processes that can read that path see it. You set a volume's file permissions with defaultMode, and in YAML a number starting with 0 is read as octal. A volume mount also has its own readOnly.

The encryption-at-rest configuration

Write an encryption-at-rest configuration in /root/exam/encryption/config.yaml. apiVersion is apiserver.config.k8s.io/v1 and kind is EncryptionConfiguration. The encryption target is secrets, and for the providers, aescbc must be first and identity last. The aescbc key must have a name and its value must be 32 bytes written in base64.

In the provider list, order is policy. For writing it uses the first provider, and for reading it tries them in turn from the top. So if you put identity first, the file looks fine but Secrets are stored in plaintext, and if you leave identity out entirely, you can't read what was already stored in plaintext. The aescbc key length must be exactly 32 bytes, and you make it with head -c 32 /dev/urandom | base64.

Private registry credentials

Create the namespace supply, and create the credentials for accessing the registry registry.internal as the user deployer as a Secret regcred. Connect the service account puller so that it pulls images with that Secret, and make the Deployment catalog use that service account. The container name is app and the image is registry.internal/catalog:1.4.2.

kubectl create secret docker-registry takes --docker-server, --docker-username, and --docker-password and creates a Secret of type kubernetes.io/dockerconfigjson. If you create it as a generic Secret, the type is Opaque and the kubelet doesn't recognize it as credentials. A service account has an imagePullSecrets field, and a Pod uses that account with serviceAccountName. Instead of writing it on each Pod, if you attach it once to the account, it follows to every Pod that uses that account.

Block moving tags

Block moving tags in the namespace supply. Create a ValidatingAdmissionPolicy no-latest-tag and a ValidatingAdmissionPolicyBinding no-latest-tag, and have them require that the images of all containers and all init containers of a Pod do not end with :latest. The binding's validationActions is Deny and the scope of application is only the namespace supply. Other namespaces must not be affected.

CEL's all() checks whether every item in a list satisfies the condition. But object.spec.initContainers is a field that doesn't exist at all on a Pod with no init containers, so referencing it as is makes evaluation fail. Wrap it in has() to handle the case when it is absent. To actually block, the binding's validationActions must have Deny, and if you put only Warn, only a warning goes out and the Pod is created. You narrow the scope with the binding's matchResources.

An image policy webhook configuration

Write an admission configuration file in /root/exam/imagepolicy/admission-config.yaml. apiVersion is apiserver.config.k8s.io/v1, kind is AdmissionConfiguration, and the only plugin is ImagePolicyWebhook. In that configuration's imagePolicy, write /etc/kubernetes/imagepolicy/kubeconfig.yaml as the kubeConfigFile, set allowTTL 50, denyTTL 50, and retryBackoff 500, and set defaultAllow to false so that images are not allowed when the webhook can't be reached.

defaultAllow is the verdict when the webhook can't be reached. If you set it to true, every image passes the moment the webhook dies, so the meaning of the policy is reversed. Don't rely on the default; state it explicitly. The plugin configuration can be written inline in the configuration of a plugins array entry, and the key name inside it is imagePolicy.

Runtime detection rules

Write runtime detection rules in /root/exam/falco/rules.yaml. The file is a list of entries, and it must contain one list entry named trusted_writers and one rule entry named Write below etc. The list must have at least one item. The rule's priority is WARNING, desc and tags must not be empty, the condition must contain all of open_write, /etc, and trusted_writers, and the output must contain %proc.name and %fd.name.

A Falco rules file is a list of entries, and the kind of an entry is determined by its first key. A list has a name and items, and a rule has desc, condition, output, priority, and tags. If you wrap a list name in parentheses inside a condition, it means asking whether it is one of the items of that list. The things in output that start with % are the spots that pull values out of the event, and what wrote to which file must remain there for an investigation to be possible later.

Clearing noise from the audit log

Write an audit policy in /root/exam/audit/quiet-policy.yaml. apiVersion is audit.k8s.io/v1 and kind is Policy, and there are exactly four rules, with order mattering. First, discard with None the watch by the user system:kube-proxy on endpoints and services of the core group. Second, discard with None access by the group system:authenticated to the non-resource paths /api* and /version. Third, record the core group secrets of the namespace payments at RequestResponse. Fourth, catch everything else with a Metadata rule that lists no targets.

An audit policy is scanned from the top and stops at the first matching rule. So if you don't put what you want to discard first, the catch-all rule after it records even that. A rule can narrow its targets not only by resources but also by users, userGroups, verbs, namespaces, and nonResourceURLs. If the level is None, that request isn't recorded. Non-resource paths are written separately, not together with resources.

Enforce a read-only root at the admission stage

Create the namespace runtime, and force every container of the Pods created in it to have readOnlyRootFilesystem: true. Create a ValidatingAdmissionPolicy immutable-root and a ValidatingAdmissionPolicyBinding immutable-root; the binding's validationActions is Deny and the scope of application is only the namespace runtime. Other namespaces must not be affected.

Making one workload read-only and making every Pod that will come in later read-only are different things. The latter must be done at the admission stage. securityContext may be absent and the fields inside it may be absent, so in CEL, if you don't check both levels with has(), a Pod with nothing written slips through as an evaluation error or gets blocked outright. You narrow the scope with the binding's matchResources, and to pick just one namespace, use the kubernetes.io/metadata.name label that is attached automatically to every namespace.