Cloud Security Office Hours Banner

Kubernetes security lab: kind + Kubernetes Goat

Learn Kubernetes attack and defense on a real cluster that runs entirely on your laptop. Build it with kind, break it with Kubernetes Goat, then harden it - all for $0.

Jump to the build Back to Home Lab

· · Vendor-neutral

Time: ~3 hours  ·  Difficulty: Intermediate  ·  Stack: Docker · kind · kubectl · Kubernetes Goat · kube-bench · kubescape

You can learn the overwhelming majority of Kubernetes attack and defense on your laptop, for free. kind ("Kubernetes IN Docker") spins up a real, conformant cluster inside Docker containers in about thirty seconds. Kubernetes Goat is a deliberately vulnerable cluster full of realistic scenarios - leaked secrets, SSRF, container escape, over-permissive RBAC. This walkthrough builds the cluster, walks several Goat scenarios hands-on, then flips to defense: you baseline the cluster with kube-bench and kubescape and lock a namespace down with Pod Security admission and NetworkPolicies.

Laptop only. Kubernetes Goat is intentionally exploitable. Run it on a local kind cluster you can delete - never on a shared, cloud, or production cluster, and never expose its ports beyond localhost.

On this page

  1. What you will build
  2. Prerequisites
  3. Step-by-step
  4. Common mistakes
  5. Where next

What you will build

Prerequisites

Step-by-step

1. Install kind and kubectl

# macOS via Homebrew (see kubernetes.io / kind.sigs.k8s.io for Linux and Windows)
brew install kubectl kind        # kubectl = the cluster client; kind = "Kubernetes IN Docker", the cluster itself

kind --version                   # confirm kind installed
kubectl version --client         # --client = print only the local CLI version, don't try to reach a cluster yet

2. Create a multi-node cluster

kind-config.yaml:

kind: Cluster                       # this file describes a kind cluster
apiVersion: kind.x-k8s.io/v1alpha4  # the kind config schema version (not the Kubernetes version)
nodes:                              # each list entry becomes one Docker container running a full kubelet
  - role: control-plane             # runs the API server, scheduler, etcd - the cluster's brain
  - role: worker                    # runs your workloads (pods)
  - role: worker                    # a second worker, so you can watch pods get scheduled across nodes
kind create cluster --name goat --config kind-config.yaml   # build the 3-node cluster from the file above (~30s)
kubectl get nodes                          # list nodes; expect one control-plane and two workers, all "Ready"
kubectl cluster-info --context kind-goat   # --context picks this cluster; prints the API-server URL to confirm you're wired up

Each "node" is a Docker container running a full kubelet. A multi-node cluster lets you observe pod scheduling and NetworkPolicy behavior that a single node hides.

3. Deploy Kubernetes Goat

git clone https://github.com/madhuakula/kubernetes-goat.git   # download the deliberately-vulnerable scenarios
cd kubernetes-goat
bash setup-kubernetes-goat.sh         # deploy every vulnerable workload into your cluster
kubectl get pods                      # list pods; re-run until they all show STATUS = Running
bash access-kubernetes-goat.sh        # open kubectl port-forwards so you can reach the scenarios from your browser

With the port-forwards running, open http://localhost:1234 for the Goat scenario hub. Each scenario has a short brief and a hint page.

4. Scenario: secrets exposed to a pod

Walk the "Sensitive keys in codebases" and "DIND (Docker-in-Docker) exploitation" scenarios. The core lesson is how much a single compromised pod can see:

# what secrets can the default service account list?
kubectl get secrets                                    # list Secret objects in the current namespace
kubectl get secret <name> -o jsonpath='{.data}' | jq   # -o jsonpath pulls out just the .data map; pipe to jq to read it
                                                        # (values are base64 - `| base64 -d` to decode a field)

# from inside a compromised pod, read the mounted service-account token
kubectl exec -it <pod-name> -- sh   # exec a shell in the pod; -it = interactive TTY, everything after -- runs inside
# ...then, inside the pod, read the token Kubernetes auto-mounts for API calls:
cat /var/run/secrets/kubernetes.io/serviceaccount/token       # the JWT the pod uses to authenticate to the API server
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace   # which namespace this pod (and token) belongs to

Every pod gets a service-account token mounted at that path by default. If the pod is compromised and its service account is over-permissive, the attacker inherits those API rights. That single fact drives most in-cluster privilege escalation.

5. Scenario: map the RBAC blast radius

# what can this service account actually do?
kubectl auth can-i --list \                     # --list = dump every verb/resource this identity is allowed
  --as=system:serviceaccount:default:default    # --as = impersonate; format is system:serviceaccount:<namespace>:<name>

# check one specific dangerous verb
kubectl auth can-i create pods \                # asks a yes/no question: can this identity create pods?
  --as=system:serviceaccount:default:default    # a "yes" here from a pod's own SA is a real privilege-escalation finding

kubectl auth can-i is the single most useful RBAC command. Running it as a service account shows you the exact blast radius of a pod compromise. If a default service account can create pods or get secrets cluster-wide, that is your finding.

6. Scenario: spot a container-escape path

# inspect a pod spec for the classic escape enablers
kubectl get pod <pod-name> -o yaml \                                         # -o yaml = dump the pod's full spec
  | grep -iE 'hostPath|privileged|hostNetwork|hostPID|securityContext' -A2   # -i case-insensitive, -E regex, -A2 show 2 lines of context

A hostPath mount of /, privileged: true, or hostPID: true each turns a container compromise into node compromise. Kubernetes Goat's "container escape to the host system" scenario walks the full exploitation; the grep above is how you find the same red flags in any real pod spec.

7. Baseline the cluster with kube-bench and kubescape

# kube-bench: runs the CIS Kubernetes Benchmark as an in-cluster Job
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml   # -f = apply the manifest straight from the URL
kubectl wait --for=condition=complete job/kube-bench --timeout=120s   # block until the Job finishes (or 120s passes)
kubectl logs job/kube-bench                                          # the benchmark results are written to the Job's logs

# kubescape: posture scan mapped to the NSA/CISA and MITRE ATT&CK frameworks
brew install kubescape
kubescape scan --format html --output kubescape-report.html   # scan the live cluster and write a shareable HTML report

kube-bench grades the cluster against the CIS benchmark control by control; kubescape maps findings to attack frameworks and produces a shareable report. Trivy can also scan the whole cluster in one shot: trivy k8s --report summary cluster. Reading these reports - deciding what is a real risk versus benchmark noise on a kind cluster - is the actual skill.

8. Harden a namespace: Pod Security admission

kubectl create namespace secure-app          # a fresh namespace to apply the policy to
kubectl label namespace secure-app \         # Pod Security admission is switched on via namespace labels
  pod-security.kubernetes.io/enforce=restricted \   # enforce = actually block pods that violate the "restricted" profile
  pod-security.kubernetes.io/warn=restricted        # warn = also print a warning to whoever applies a bad pod

# now try to run a privileged pod there - admission rejects it before it schedules
kubectl -n secure-app run bad --image=nginx \        # -n = target the labeled namespace
  --overrides='{"spec":{"containers":[{"name":"bad","image":"nginx","securityContext":{"privileged":true}}]}}'   # patch in privileged:true - the exact thing "restricted" forbids

Pod Security admission is built into modern Kubernetes. The restricted profile blocks privileged containers, host namespaces, and hostPath mounts - the exact things you exploited in step 6. Watch the API server reject the bad pod with a clear policy violation.

9. Harden the network: default-deny

default-deny.yaml:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: secure-app          # this policy only governs pods in the secure-app namespace
spec:
  podSelector: {}                # {} = an empty selector matches EVERY pod in the namespace
  policyTypes: [Ingress, Egress] # govern both inbound and outbound traffic...
                                 # ...and because no allow rules follow, both are denied by default

Important caveat most tutorials skip: kind's default CNI (kindnet) does not enforce NetworkPolicy, so the policy above will appear to do nothing. To actually see traffic get denied, recreate the cluster with the default CNI disabled and install Calico or Cilium:

# add `networking: {disableDefaultCNI: true}` to kind-config.yaml, then:
kind create cluster --name goat --config kind-config.yaml   # rebuild the cluster with NO default network plugin
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml   # install Calico, a CNI that DOES enforce NetworkPolicy
kubectl -n secure-app apply -f default-deny.yaml   # re-apply the policy; now traffic actually gets blocked

10. Tear it down

kind delete cluster --name goat   # destroy the cluster and its Docker containers - complete, instant, and free

Instant, complete, and free. Recreate the whole lab in thirty seconds whenever you want a clean slate.

Common mistakes

Where next