Lab: Kubernetes cluster security from zero to hero
This lab builds a real Kubernetes cluster on your laptop and walks through the five controls interviewers actually probe: RBAC, Pod Security, NetworkPolicy, secrets at rest, and RuntimeClass. It assumes you can run kubectl get pods and not much more. Each experiment asks one question, runs it, and reads the output with you. Budget about an hour.
Last verified2026-10 (kind v0.30.0, Kubernetes v1.34, kubectl v1.37, on the shared Lima VM from the sandbox lab) Companion pages: Kubernetes fundamentals · Kubernetes security · Kubernetes incident response · and for what a RuntimeClass handler actually does, the sandbox-isolation lab
The thread running through all five: in Kubernetes the pod is not a security boundary, so security is layers of "who can do what" (RBAC), "what shape of pod is allowed" (Pod Security), "what can talk to what" (NetworkPolicy), "what's protecting the data" (secrets/encryption), and "how strong is the sandbox" (RuntimeClass). Each experiment is one of those layers, and each one shows a gap the next layer closes.
Level 0: words and setup
The words you need
a cluster is one or more machines (nodes) running your containers, plus a control plane (the API server, scheduler, and etcd, the database that stores everything). kubectl talks to the API server.
the smallest unit you deploy: one or more containers that share a network address. "A pod is a process, not a security boundary" — it shares the node's kernel, like any container.
a folder that groups objects (pods, secrets) and is the usual unit RBAC and policy attach to.
the identity a pod runs as when it talks to the Kubernetes API. Every pod gets one (default if you don't choose).
rules saying which verbs (get, create, delete) a subject may use on which resources. A Role lists permissions; a RoleBinding attaches a Role to a subject.
a check the API server runs on every object before it's stored. Pod Security Admission is a built-in one that rejects unsafe pods.
the plugin that wires pod networking and enforces NetworkPolicy. kind uses kindnet.
a named choice of which low-level runtime runs a pod (default runc, or a stronger sandbox like gVisor's runsc).
Memory hookthe five locksWho can ask (RBAC) → what can be created (Pod Security) → what can connect (NetworkPolicy) → what protects the data (secrets + encryption) → how strong the sandbox is (RuntimeClass). An attacker only needs one unlocked; you need all five.
Build the cluster
This reuses the Lima VM from the sandbox lab (it already has Docker). If you don't have it: brew install lima && limactl start --name sandbox-lab, then install Docker inside. Then, inside the VM, install the two tools and create the cluster:
# on your Mac
limactl copy -r kubernetes/labs/cluster-security sandbox-lab:/tmp/k8slab
limactl shell sandbox-lab
# inside the VM, one-time tool install
curl -sSLo kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-arm64 && sudo install kind /usr/local/bin/kind
KV=$(curl -sSL https://dl.k8s.io/release/stable.txt)
curl -sSLo kubectl "https://dl.k8s.io/release/$KV/bin/linux/arm64/kubectl" && sudo install kubectl /usr/local/bin/kubectl
# build the cluster (takes ~20s)
cd /tmp/k8slab && bash setup.sh
export KUBECONFIG=/tmp/kcsetup.sh creates a single-node cluster with API-server audit logging turned on (so you can later inspect who did what, in /var/log/kubernetes/audit.log on the control-plane container). Check it:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
seclab-control-plane Ready control-plane 26s v1.34.0Run the experiments in order: bash 01-rbac.sh, then 02, 03, 04, 05. Each is short and commented — read it before running.
Level 1: RBAC, and the privesc path hiding in "create pods"
Questionif I give a service account exactly "manage pods in one namespace", what did I actually give it?
bash 01-rbac.sh== Ask the API what deploy-bot can do (kubectl auth can-i --as impersonates it) ==
create pods in team-a? yes
read secrets in team-a? no
create pods in kube-system? nokubectl auth can-i <verb> <resource> --as=<subject>asks the API server "would you allow this?" without doing it.--as=system:serviceaccount:team-a:deploy-botimpersonates the service account (your admin kubeconfig is allowed to impersonate). This is the single most useful RBAC-debugging command.- The answers are exactly the Role we bound: pods in
team-a, yes; secrets, no; anything inkube-system, no. So far RBAC looks tight.
== The privilege-escalation path hiding in 'create pods' ==
pod/node-escape created- We just created, as deploy-bot, a pod that mounts the node's entire root filesystem (
hostPath: { path: / }) into the container. It was allowed. - That's the lesson. "Create pods" sounds narrow, but a pod can mount the host, run as root, add capabilities, or use the node's network. deploy-bot can't read secrets through the API, but it can schedule a pod that reads every other pod's secrets off the node's disk, and the kubelet's own credentials with it. RBAC on
pods/createquietly includes most of the node. Experiment 2 is the control that actually closes this.
🎯Exercise. deploy-bot was denied
get secrets. Does that mean it can't obtain the database password stored in a Secret? Answer: no. It can create a pod that mounts that Secret as a volume or env var (pods reference secrets by name, and creating the pod doesn't requireget secrets), then read it from inside the pod. "Can't read the Secret via the API" and "can't obtain the secret value" are different statements — which is why Pod Security and separate secret stores matter on top of RBAC.
Level 2: Pod Security turns "any pod" back into "a safe pod"
Questionhow do I stop the host-mounting trick from Level 1 without hand-auditing every Role?
bash 02-pod-security.sh== Label team-a to enforce the built-in 'restricted' Pod Security standard ==
Warning: existing pods in namespace "team-a" violate the new PodSecurity enforce level "restricted:latest"
Warning: node-escape: allowPrivilegeEscalation != false, unrestricted capabilities, restricted volume types, ...
namespace/team-a labeled- One label,
pod-security.kubernetes.io/enforce=restricted, switches on Pod Security Admission, which is built into the API server (no add-on to install).restrictedis the strictest of the three built-in levels (privileged,baseline,restricted). - It immediately warns that the
node-escapepod from Level 1 violates the new level — existing pods aren't killed, but you're told what would now be rejected.
== Retry the exact host-mounting pod from experiment 1, now in the restricted namespace ==
Error from server (Forbidden): ... violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false, unrestricted capabilities,
restricted volume types (volume "host" uses restricted volume type "hostPath"),
runAsNonRoot != true, seccompProfile ...- The same pod that succeeded in Level 1 is now Forbidden at admission, before it's ever stored. The error is a checklist of everything unsafe about it — most importantly
hostPath, the node-mount trick. - A pod that follows the rules (drops all capabilities, runs as non-root, no host mounts,
seccompProfile: RuntimeDefault) is still accepted. Admission judges the pod's shape, independently of who created it — so it catches the privesc path even when RBAC was too generous.
🎯Exercise. Your cluster has one namespace that legitimately needs
hostPath(a logging agent). How do you keeprestrictedeverywhere else without exceptions weakening the whole cluster? Answer: set the enforce label per namespace —restrictedon application namespaces,baselineorprivilegedonly on the specific namespace the agent runs in, ideally where only the platform team has RBAC. Pod Security is per-namespace by design, so the exception is scoped to one blast radius instead of a cluster-wide hole.
Level 3: NetworkPolicy — from allow-all to default-deny
Questioncan one compromised pod reach every other pod?
bash 03-networkpolicy.sh== Before any policy: client reaches web (the Kubernetes default is allow-all) ==
<title>Welcome to nginx!</title>- By default every pod can reach every other pod, cluster-wide. The
clientpod fetchedweb's page with no rule permitting it. A flat network: a compromised pod can scan and connect to everything in the namespace (and beyond).
== Apply a default-deny-ingress policy selecting all pods, then retry ==
client → web now:
wget: download timed out- The policy has an empty
podSelector: {}("every pod in this namespace") and listsIngressinpolicyTypeswith no allow rules — which means "deny all inbound". The fetch now times out. - kind's
kindnetCNI enforces this. On a cluster whose CNI doesn't (some do nothing with NetworkPolicy), the rule is silently ignored — so if a policy seems to have no effect, check the CNI first. From a default-deny baseline you add back only the flows you want.
🎯Exercise. You applied default-deny-ingress and
webis now unreachable fromclient— butwebcan still make outbound calls to the internet and to the metadata service. Why, and how do you stop that? Answer: this policy only coveredIngress(inbound). Egress is still allow-all. Add a second policy withpolicyTypes: ["Egress"]and explicit allow rules, which is how you stop a compromised pod from exfiltrating data or reaching cloud metadata — the Kubernetes equivalent of the egress firewall in the sandbox lab's Firecracker experiment.
Level 4: a Secret is base64, not encryption
Questionif I put a password in a Kubernetes Secret, who can read it?
bash 04-secrets.sh== 1. kubectl shows the value base64-encoded ==
stored as: U3VwM3JTZWNyZXQh
== 2. Decoding is one step for anyone who can read the Secret object ==
decoded: Sup3rSecret!
== 3. The plaintext sits in etcd itself, with no app involved ==
password
password
Sup3rSecret!U3VwM3JTZWNyZXQhis base64, an encoding with no key —base64 -dreverses it instantly. A Secret is not encrypted by being a Secret.- Step 3 reads the value straight out of etcd, the control-plane database, via the etcd pod: the literal bytes
Sup3rSecret!are on disk. Anyone with etcd access — or a stolen etcd backup, or node root — reads every secret in the cluster, no API call needed. - Two fixes, together: RBAC so few subjects can
get secrets(Level 1 denied deploy-bot that), and anEncryptionConfigurationon the API server (aescbc or a KMS provider) so the value is stored ask8s:enc:...and is unreadable at rest. For the crown jewels, an external store (Vault, cloud secrets manager) via CSI so the secret never lands in etcd at all.
🎯Exercise. You enable encryption at rest today. Is the existing
db-credsecret now protected in etcd? Answer: not until it's rewritten. Encryption at rest only applies to writes after you enable it, so you must re-write every existing secret (e.g.kubectl get secrets -A -o json | kubectl replace -f -) to encrypt what's already there. A subtle gap interviewers like: "we turned on encryption" doesn't retroactively protect old data.
Level 5: RuntimeClass — sending untrusted pods to a stronger sandbox
QuestionPod Security makes a pod well-behaved, but it still shares the node's kernel. How do I run genuinely untrusted code?
bash 05-runtimeclass.sh== Declare a RuntimeClass named 'gvisor' pointing at the 'runsc' handler ==
runtimeclass.node.k8s.io/gvisor created
== A pod opts in with one line: runtimeClassName: gvisor ==
pod/untrusted created
== Status on this lab cluster (runsc is NOT installed on the kind node) ==
phase: Pending
Warning FailedCreatePodSandBox ... no runtime for "runsc" is configured- A
RuntimeClassjust names a handler. A pod opts in with one line,runtimeClassName: gvisor, and the kubelet routes it to that handler instead of the defaultrunc. - On this lab node gVisor isn't installed, so the pod sits Pending with
no runtime for "runsc" is configured. That error is the proof the routing worked: the API server accepted the pod and the kubelet tried to start it underrunsc— there was simply no gVisor on the node. - In production you install gVisor or Kata on a dedicated node pool, taint it so only untrusted work lands there, and require
runtimeClassNamevia policy. Whatrunscactually does to the pod's syscalls is the whole sandbox-isolation lab — this experiment is the Kubernetes wiring that sends a pod to it.
🎯Exercise. Why is "Pod Security
restricted+ NetworkPolicy" not enough for running a customer's arbitrary container, and what does RuntimeClass add? Answer:restrictedstops the pod from mounting the host or running as root, and NetworkPolicy limits where it talks — but the container still shares the node's Linux kernel, so one kernel exploit (Dirty Pipe, a bad syscall) escapes to the node regardless of how well-behaved the pod's spec is. RuntimeClass with gVisor/Kata puts a second kernel or a VM between the pod and the host, so a kernel bug hits a disposable sandbox, not the node. The layers compose: well-behaved pod and strong sandbox.
Clean up
sudo kind delete cluster --name seclab # removes the cluster and its containers
exit # leave the VM
limactl stop sandbox-lab # free the VM's RAM (keeps it for next time)If something goes wrong
kubectl can't connectexport KUBECONFIG=/tmp/kc; the scripts rely on it. Re-run bash setup.sh if the cluster was deleted.
the CNI isn't enforcing it. kindnet does; on a real cluster confirm Calico/Cilium is installed.
auth can-i always says yesyou're asking as yourself (cluster-admin). Add --as=system:serviceaccount:<ns>:<name> to ask as the service account.
FailedCreatePodSandBox for untrustedexpected in Level 5; gVisor isn't on the node. That's the point of the experiment.
Which experiment backs which interview answer
| If you are asked… | Level | Say what you saw |
|---|---|---|
What can a service account with create pods actually do? | 1 | It mounted the node's root fs; "create pods" ≈ node access without Pod Security |
| How does Pod Security Admission work? | 2 | One namespace label; the host-mount pod went from created to Forbidden at admission |
| Are Kubernetes Secrets encrypted? | 4 | base64 by default; read Sup3rSecret! straight out of etcd; needs EncryptionConfiguration |
| Is pod-to-pod traffic restricted by default? | 3 | No — allow-all until a NetworkPolicy; default-deny then times out the fetch |
| How do you run untrusted workloads in a cluster? | 5 | RuntimeClass → gVisor/Kata node pool; restricted + NetworkPolicy don't stop a kernel escape |
| Why isn't a pod a security boundary? | 1,5 | It shares the node kernel; the real questions are what it can reach and how strong the sandbox is |