ATT&CK for Kubernetes

The adversary playbook and coverage mapping.

Advanced30 min · lesson 2 of 15

Break-in crews don't improvise much. They work from a catalogue: pop the back window, cut the alarm, grab the jewelry, out through the garage. Kubernetes attackers run the same way. MITRE ATT&CK for Containers is that catalogue written down for clusters. MITRE is a nonprofit that documents how real intruders behave, and ATT&CK stands for Adversarial Tactics, Techniques, and Common Knowledge. It sorts every move an attacker makes into ordered columns called tactics, from getting through the door to cashing out. The payoff for a defender is plain. Once each move has a name, you can go down the list and ask two questions about it. Would we stop this? Would we even see it?

The catalogue, tactic by tactic

The columns sit in a rough order: Initial Access, Execution, Persistence, Privilege Escalation, Defense Evasion, Credential Access, Discovery, Lateral Movement, and Impact. Initial Access is the way in: an exposed API server (the cluster's control panel, every command flows through it), a kubelet listening on the network (the agent on each machine that actually starts your containers), a vulnerable app. Execution is running code where you shouldn't, usually a shell inside a pod. Credential Access is stealing keys, the tokens and Secrets (the Kubernetes objects that hold passwords and API keys) that unlock the next hop. Discovery is looking around to learn what your stolen identity can touch. Impact is the payday: cryptomining, data theft, ransomware. Real break-ins don't march straight across the columns, they loop back and skip around. So you never defend a whole column. You defend one named technique at a time, and that's where a running cluster teaches more than a diagram does. Here are three of those tactics as real commands, all from the seat of an attacker who already has a shell in one pod.

First thing you do after landing is figure out what you're holding. Every pod runs as a service account (SA for short, the robot login Kubernetes uses when the pod talks to the API). A smart attacker doesn't guess what that account can do. They ask the API server outright.

Discovery: what can this identity do?
kubectl auth can-i --list
output
Resources Non-Resource URLs Verbs
selfsubjectrulesreviews.authorization.k8s.io [] [create]
pods [] [get list]
pods/exec [] [create]
secrets [] [get list]

That output is a confession waiting to happen. This checkout account can read every Secret in its namespace and open a shell in the pods it can see. Far too much for something that just takes payments. Each can-i check is a SelfSubjectRulesReview, and the API server logs it, so a pod's identity enumerating its own powers is itself a signal, carried on the same audit stream you'll query later. The real fix is upstream, in RBAC (Role-Based Access Control, the rules that decide which identity may touch which resource). Scope the account down until the honest answer to 'what can I do here' is 'almost nothing.'

Discovery said the token can create pods/exec. Execution is spending it. In plain terms, exec opens a terminal inside a container that's already running, the way you'd SSH into a server. Attackers reach for it because it adds no new image and no new pod, just a shell in something that already looks routine.

Execution: a shell in a running pod
kubectl exec -it checkout-7c9f8-w4d2p -n shop -- /bin/sh
output
/ # id
uid=0(root) gid=0(root) groups=0(root)
/ # cat /etc/os-release | head -1
PRETTY_NAME="Alpine Linux v3.20"

An exec is a create on the pods/exec subresource, and the audit log keeps the pod name, the user, and the command they ran. It's one of the loudest events in the whole cluster. People rarely shell into production, and robots never should. Prevention lives in admission control, the checkpoint every write passes through before it's saved: a policy that denies pods/exec in production, or Pod Security Admission (PSA, the built-in gate that blocks unsafe pod settings) stopping the container from running as root in the first place.

Now the move that makes the rest pay off. Credential Access is grabbing the key to the next door. By default Kubernetes drops a service-account token into every pod as a plain file. It's like a hotel keycard left on the nightstand of every room. The attacker just reads it. The token is a JWT (JSON Web Token, a signed blob with a readable middle section), so they decode that section to see exactly whose identity they've picked up.

Credential Access: decode the mounted token
cat /var/run/secrets/kubernetes.io/serviceaccount/token | cut -d. -f2 | base64 -d 2>/dev/null | jq .
output
{
"aud": ["https://kubernetes.default.svc"],
"exp": 1784198482,
"iss": "https://kubernetes.default.svc",
"kubernetes.io": {
"namespace": "shop",
"pod": { "name": "checkout-7c9f8-w4d2p" },
"serviceaccount": { "name": "checkout" }
},
"sub": "system:serviceaccount:shop:checkout"
}

Notice the token names its own pod, and there's an exp field about an hour out. Modern tokens are bound to the pod and carry a short expiry, so a stolen one stops working once the pod is deleted or the clock runs out. Good design. It still works right now though, and right now is all an attacker needs. Detection and prevention have to meet in the middle.

From catalogue to coverage

The catalogue earns its keep when you reduce each technique to two yes/no questions: can we stop it, can we see it. Take 'see it' for the exec you just watched. If you ship the API server's audit log (and you should), every action is one JSON line, and jq pulls the exec events straight out.

Detect: every exec, from the audit log
jq 'select(.objectRef.resource=="pods" and .objectRef.subresource=="exec") | {who:.user.username, pod:.objectRef.name, when:.requestReceivedTimestamp}' /var/log/kubernetes/audit.log
output
{
"who": "system:serviceaccount:shop:checkout",
"pod": "checkout-7c9f8-w4d2p",
"when": "2026-07-16T09:41:22Z"
}

The tell is the who. A service account, not a person, opened a shell. Route that line to an alert and you've covered the Execution technique. Teams without shipped audit logs get the same signal from Falco, a runtime sensor that watches the kernel and fires when a shell spawns inside a container. Detection is the after. Prevention is the before, and two small changes close most of this chain at once.

Stop: least-privilege SA and role, no mounted token
apiVersion: v1
kind: ServiceAccount
metadata:
name: checkout
namespace: shop
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: checkout-min
namespace: shop
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: checkout-min
namespace: shop
subjects:
- kind: ServiceAccount
name: checkout
namespace: shop
roleRef:
kind: Role
name: checkout-min
apiGroup: rbac.authorization.k8s.io
output
$ kubectl delete rolebinding checkout-wide -n shop
rolebinding.rbac.authorization.k8s.io "checkout-wide" deleted
$ kubectl apply -f checkout-hardening.yaml
serviceaccount/checkout configured
role.rbac.authorization.k8s.io/checkout-min created
rolebinding.rbac.authorization.k8s.io/checkout-min created

Roll the deployment so a fresh pod starts, then land in it the way the attacker did. The token file is gone, so the cat returns 'No such file or directory,' and with no token there's no identity to decode or reuse. Check the account's reach from the outside with kubectl auth can-i --list --as=system:serviceaccount:shop:checkout -n shop, and it comes back nearly empty. The keycard is off the nightstand, and the identity can barely open its own door. That's three catalogue techniques closed with a couple of small objects.

Score every technique on two questions
A technique appears in your cluster
a pod exec, a token read, a new binding
prevented?
RBAC, admission, PSA
the write never lands
detected?
audit log, Falco
you get an alert after the fact
neither?
a coverage gap
goes on the prioritized backlog
Run every relevant technique through these two questions. The blanks are your security backlog, already in priority order.
On managed clusters the audit log is not where you think
The jq examples read /var/log/kubernetes/audit.log, which exists on self-managed control planes. On EKS, GKE, and AKS you never touch that file. You have to turn control-plane audit logging on (it ships off or partial by default), and the events land in CloudWatch, Cloud Logging, or Azure Monitor instead. If you assumed audit was on and it wasn't, your exec detection has been quietly returning nothing this whole time.
Quick check
01You want a reliable signal that someone opened a shell inside a pod. Which source actually catches it?
Correct — exec is a subresource create, so this captures the user, pod, and command every single time.
Incorrect — Misses it. exec doesn't create a pod; it acts on the pods/exec subresource, so a rule scoped to pods alone stays silent.
Incorrect — Wrong layer. Scanning finds vulnerable images before deploy and sees nothing about a live shell.
Incorrect — NetworkPolicy filters pod-to-pod traffic. An exec travels through the API server, not the pod network.
02The lesson scores each ATT&CK (Adversarial Tactics, Techniques, and Common Knowledge) technique with two yes/no questions: can we stop it, and can we see it. What is a technique that answers 'no' to both?
Incorrect — A technique with neither prevention nor detection is the opposite of ignorable — an open door you would never notice being used.
Incorrect — 'Neither' means you hold neither control, so nothing about it is mitigated.
Incorrect — ATT&CK catalogs how real attackers behave regardless of your controls; the exercise is to surface your blanks.
Correct — A technique you can neither prevent nor detect is uncovered risk, and the lesson says those blanks are your prioritized backlog.
03On your EKS cluster you run the jq exec-detection over /var/log/kubernetes/audit.log, it returns nothing, and you conclude nobody has been exec-ing into production pods. Why is that conclusion unsafe?
Correct — Managed control planes don't expose the audit file locally and audit logging must be explicitly turned on, so an empty result can just mean you queried the wrong (or an empty) source.
Incorrect — exec is a create on the pods/exec subresource and is captured by the audit log whenever audit logging is actually enabled.
Incorrect — exec is logged as the pods resource with an exec subresource, so the filter is correct; the missing piece is the log source on a managed cluster.
Incorrect — The issue isn't retention; on a managed cluster that on-disk file isn't present and audit logging may never have been switched on.

Try this

Work through “From catalogue to coverage” 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: on managed clusters the audit log is not where you think. 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