CoursesKubernetes attack & defenseThreat-modeling a cluster

Threat-modeling a cluster

Trace pivots, control each hop, rank by blast radius.

Advanced30 min · lesson 3 of 15

Most cluster security reviews end in a flat list. Forty findings, every one tagged 'medium', and no signal about which one actually ends your quarter. Threat modeling replaces that list with the question an attacker would ask: if I land right here, what can I reach, and how far does it spread? The attack-surface lesson enumerated the doors into a cluster. This one ranks them, so you fix the single break-in that leads to everything before you touch the ten that lead nowhere.

A home security survey is the right mental picture. A good surveyor won't just hand you a list of every weak point (the shed latch, the side gate, the garage keypad). They tell you which broken lock matters. A flimsy latch on an empty shed is a shrug. That same latch on the door between the garage and the house, where the car keys and the alarm panel sit, is the first thing to fix. That difference is blast radius: not how easy the lock is to pick, but how much sits behind it. In a cluster the rooms are your assets (Secrets, workloads, the control plane, the nodes, the cloud account) and the doors are your entry points (an internet-facing pod, a CI (Continuous Integration) pipeline that deploys, a developer running kubectl). You build a grid of assets against entry points, then score every cell by what it reaches.

Build the grid from what's actually running

Don't fill the grid from imagination. Fill it from the API. The API server is the cluster's source of truth; it already knows every service, pod, and permission, so ask it instead of guessing. Start with the entry points, because those are your columns. An entry point is anything an attacker can plausibly land on, and the first ones to map are the workloads exposed to the internet and the identity each of them carries. That identity is the Service Account (SA), the account a pod uses to authenticate to the API server. Once you know what's exposed and which SA it runs as, you know where a foothold starts and whose permissions it inherits.

find the internet-facing entry points
kubectl get svc -A -o json | jq -r '.items[]
| select(.spec.type=="LoadBalancer")
| "\(.metadata.namespace)/\(.metadata.name)\t\(.status.loadBalancer.ingress[0].ip)"'
output
prod/frontend 34.120.55.10
prod/api-gateway 34.120.55.11

Two public services, both in the prod namespace. Now map every pod in that namespace to the SA it runs as and the node it sits on. Hold onto the node column. It changes the ranking later.

map each pod to its identity and node
kubectl get pods -n prod -o custom-columns=POD:.metadata.name,SA:.spec.serviceAccountName,NODE:.spec.nodeName
output
POD SA NODE
frontend-7c9f8b6d4-2xk9l frontend gke-prod-a1
api-gateway-5b7d9c8f-q4m2z api-gw gke-prod-a1
worker-0 ci-runner gke-prod-a1

Three pods, one node, two of them facing the internet. Each carries a different SA, and those SAs are the real subject of the ranking, because a foothold is only as dangerous as the token it can steal. So score them. The command kubectl auth can-i --list asks the API server, as any identity you name, exactly what that identity is allowed to do.

score what each entry-point identity can reach
for sa in frontend api-gw ci-runner; do
echo "== $sa =="
kubectl auth can-i --list -n prod --as=system:serviceaccount:prod:$sa \
| grep -Ei 'secrets|pods|\*\.\*'
done
output
== frontend ==
== api-gw ==
secrets [] [] [get list]
pods [] [] [create]
== ci-runner ==
*.* [] [] [*]

Now the cells write themselves. frontend can't reach Secrets, pods, or anything cluster-wide, so its whole column is low blast radius even though it faces the internet. api-gw can read every Secret in its namespace (get and list hand you those credentials, and a Kubernetes Secret is only base64-encoded, which is not encryption, so it's readable in seconds) and it can create pods, a well-known first step toward owning the node it runs on. That's a high cell. And ci-runner returns *.* with verb [*], which is cluster-admin: every resource, every verb. One line, and the whole ranking tips over.

Rank the cells: find the fire

The top of every blast-radius ranking is the same rung: any entry point whose identity reaches cluster-admin. From there an attacker owns Secrets, workloads, nodes, and usually the cloud account behind them. So hunt for it directly. This query lists every Service Account bound to cluster-admin, and it doubles as your detection. Run it on a schedule and diff the output, and a freshly planted admin binding surfaces the moment it's created.

detect: which identities already reach cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
.items[]
| select(.roleRef.name=="cluster-admin")
| .subjects[]? | select(.kind=="ServiceAccount")
| "\(.namespace)/\(.name)"'
output
kube-system/kubernetes-dashboard
prod/ci-runner

There it is. ci-runner is bound to cluster-admin, and ci-runner is the SA on worker-0, a pod sitting on the same node as your two internet-facing services. The CI pipeline is itself an entry point (it deploys, so a poisoned build runs as ci-runner), and that entry point owns the cluster. Walk the path an attacker walks: land on a public pod, break out to the node, lift ci-runner's token, and you're cluster-admin without ever tripping a firewall rule. Everything else on the report can wait. You close it by granting CI only what it actually does, then deleting the admin grant.

fix: replace cluster-admin with a scoped role
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: prod
name: deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: prod
name: ci-runner-deployer
subjects:
- kind: ServiceAccount
name: ci-runner
namespace: prod
roleRef:
kind: Role
name: deployer
apiGroup: rbac.authorization.k8s.io
apply the fix, then delete the dangerous grant
$ kubectl apply -f ci-runner-rbac.yaml
role.rbac.authorization.k8s.io/deployer created
rolebinding.rbac.authorization.k8s.io/ci-runner-deployer created
$ kubectl delete clusterrolebinding ci-runner-admin
clusterrolebinding.rbac.authorization.k8s.io "ci-runner-admin" deleted
Ranking a pod by its own token undercounts it
Blast radius isn't only about the token a pod carries. Every pod on a node can reach that node's kubelet (the agent on each machine that starts and stops pods) and its filesystem, so whoever owns the node inherits the Service Account tokens of every pod scheduled beside it. Those tokens sit on the node's disk. Here the low-privilege frontend shares gke-prod-a1 with cluster-admin ci-runner, so a container escape (breaking out of the pod onto the host) from frontend lands you on ci-runner's token. When you score a cell, include the node's other tenants, not just the pod in front of you. Run kubectl get pods -A -o wide and read the NODE column: co-tenancy is part of the score.
Rank a foothold by blast radius
A foothold identity. How bad is it?
score the cell before you fix it
P0 · fire
Reaches cluster-admin
bound to cluster-admin, or holds bind / escalate / impersonate
P1 · node path
create pods, privileged, or hostPath
one step from owning the node and every neighbor's token
P1 · creds
get / list on Secrets
every credential in the namespace, base64 only, readable in seconds
P3 · note it
reads one ConfigMap, no token
contained; log it and move on
Fix the top rungs first. A single path to cluster-admin outranks a dozen contained findings.
Quick check
01frontend (an internet-facing pod with no useful permissions) is scheduled on the same node as worker-0, whose Service Account is bound to cluster-admin. How should you rank frontend's blast radius?
Incorrect — This is the exact mistake the ranking is meant to catch. Scoring the pod by its own token alone ignores the node it sits on.
Correct — Co-tenancy is part of blast radius. Owning the node hands you every neighbor's Service Account token, including the admin one.
Incorrect — True in general, but it misses the real reason this pod is dangerous: the cluster-admin token one escape away on the same node.
Incorrect — Disabling its own token doesn't help. The prize is the neighbor's token on the shared node, which frontend's own mount setting can't protect.
02In this lesson, what does 'blast radius' actually measure?
Incorrect — Blast radius is deliberately not about how easy the lock is to pick; a trivial lock on an empty shed is a shrug.
Correct — the lesson defines blast radius as how much is behind the door, not how hard the door is to open.
Incorrect — Replica count doesn't set reach; a single pod holding a cluster-admin token outranks a large deployment holding none.
Incorrect — Internet exposure raises likelihood, but frontend is internet-facing and still low blast radius because its identity reaches almost nothing.
03kubectl auth can-i --list for the internet-facing api-gw service account returns get and list on secrets plus create on pods, all namespaced to prod. How should you rank it, and why?
Incorrect — Namespaced doesn't mean harmless; reading every Secret in the namespace and creating pods are both high-impact.
Incorrect — base64 is an encoding, not encryption, so get/list on Secrets hands back readable credentials in seconds.
Correct — either grant is dangerous on its own, and create on pods is a well-known first step toward owning the node the pod runs on.
Incorrect — Exposure raises likelihood, yet it is the Secret-read and pod-create permissions that give this cell its large blast radius.

Notice how many of the high cells traced back to one thing: what a Service Account is allowed to do. The *.* grant, the secrets get and list, the create on pods. Ranking told you these are the fires. The next lesson, on RBAC (Role-Based Access Control) and the powerful grants, is where you learn to read those permissions precisely and catch the ones that look narrow in a binding but quietly hand over the whole cluster.

Try this

Work through “Rank the cells: find the fire” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: ranking a pod by its own token undercounts it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related