CoursesKubernetes attack & defenseThe cluster attack surface

The cluster attack surface

Exposed API/kubelet/etcd, IMDS, and blast radius.

Advanced30 min · lesson 1 of 15

Most people who break into a Kubernetes cluster don't start with an exploit. They start with a map. A burglar casing a building does the same thing: before touching a single lock, they write down every way in. The front door, the loading dock, the roof hatch, the fire escape, the side door by the smoking area that someone always props open. A Kubernetes cluster (the system that runs and coordinates your containers across a fleet of machines) has that exact set of entrances. And several of them will happily answer questions about themselves before you've proven who you are. That's what an attacker's scanner is counting on. This lesson walks the perimeter the way an attacker walks it. We'll name every door and the port it listens on, then pair each one with how to catch someone rattling it and how to lock it.

The doors, and what each one is

Six things are worth knowing by name. Each one listens on a port, a numbered channel that anything on the network can knock on, and each is something an attacker's scanner will look for. The API server (port 6443) is the reception desk. Every action in the cluster goes through it. A human running kubectl, a pod asking for a password, all of it walks up to that desk and gets checked three ways: who are you (authentication), are you allowed to do this (authorization), and does cluster policy accept it (admission control). etcd (port 2379) is the records room behind the desk. It's a key-value database, basically one giant filing cabinet, and it holds every piece of cluster state. Secrets live there too, stored base64-encoded. Base64 is encoding, not encryption. It scrambles nothing and anyone can reverse it. So a copy of etcd, or an old backup sitting forgotten in some bucket, is a copy of every password and token you have. The kubelet (port 10250) is the building super on each node. A node is just one worker machine in the fleet. The kubelet starts containers, stops them, and can open a shell inside them. So a kubelet that accepts anonymous callers hands all of that power to anyone who can reach it over the network. Its read-only sibling (port 10255) is dangerous in a quieter way: it gives up the full list of pods with no authentication at all, and teams forget it's even switched on. The container runtime (containerd, reached over a CRI socket, where CRI stands for Container Runtime Interface) is effectively root on the node for anyone who can touch it. And from inside any pod there's one last door: the cloud metadata endpoint, known as IMDS, the Instance Metadata Service, sitting at the address 169.254.169.254. It's the concierge that will read the node's cloud credentials out loud to any caller who asks from inside the building.

The cluster's doors, by exposure
control plane
API server :6443
every request; authn then authz then admission
etcd :2379
all cluster state; Secrets are base64, not encrypted
each node
kubelet :10250
exec, logs, run pods; danger if anonymous auth is on
read-only kubelet :10255
pod list with no auth; often forgotten
container runtime (CRI socket)
root on the node if reachable
from inside a pod
service-account token
an identity the API server honors, limited by RBAC (role-based access control)
IMDS 169.254.169.254
reads out the node's cloud credentials
kube-proxy / cluster net
reach every Service unless NetworkPolicy blocks it
Anything that answers before authentication is your real perimeter. Close those doors first, then shrink what each authenticated identity can reach.

Casing the cluster with kubectl

Start where a real operator starts: discovery. kubectl cluster-info prints the control-plane URLs, the web addresses of the cluster's front desk. kubectl get --raw / asks the API server for its own index, the list of every path it serves, and that's the fastest read you'll get on the shape of the attack surface. Neither command is an attack, and that's the whole point. These two are step one for the person defending the cluster and step one for the person casing it. Same keystrokes, different intent.

control-plane discovery
kubectl cluster-info
kubectl get --raw /
response
Kubernetes control plane is running at https://10.0.7.1:6443
CoreDNS is running at https://10.0.7.1:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
{"paths":["/api","/api/v1","/apis","/apis/apps/v1","/healthz","/livez","/metrics","/openapi/v3","/readyz","/version"]}

Now run it with no identity at all. Point plain curl at port 6443 with no credentials and no token. On almost every cluster the /version path still answers, which quietly leaks your exact Kubernetes version to anyone who can reach the port. Then ask for something real, like the Secrets in a namespace. A healthy cluster hands back a clean 403 that names system:anonymous, the identity the API server gives to any caller it doesn't recognize. Get actual data back instead, and you've found a front door left unlocked. That's how more than one real breach has started.

probe as nobody (no kubeconfig, no token)
curl -sk https://10.0.7.1:6443/version
curl -sk https://10.0.7.1:6443/api/v1/namespaces/default/secrets
response
{"major":"1","minor":"31","gitVersion":"v1.31.4","platform":"linux/amd64"}
{
"kind": "Status",
"status": "Failure",
"message": "secrets is forbidden: User \"system:anonymous\" cannot list resource \"secrets\" in API group \"\" in the namespace \"default\"",
"reason": "Forbidden",
"code": 403
}

The same walk from inside a pod

Say a vulnerable web app hands an attacker a shell inside one pod. Now the perimeter walk starts over from inside the building, because the pod ships with its own set of doors. It carries a mounted service-account token, an identity the API server will honor. So kubectl auth can-i --list reads back exactly what that identity is allowed to do: its RBAC permissions, the rules that decide which identity can touch which resource. The metadata endpoint is one HTTP call away and will recite the node's cloud role and its temporary keys. And if the local kubelet still takes anonymous callers, a single curl to port 10250 lists every pod on the node, each one carrying its own token to steal.

enumerate from a compromised pod
kubectl auth can-i --list
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/eks-node-instance-role
curl -sk https://10.0.7.23:10250/pods | jq -r '.items[].metadata.name'
response
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews [] [] [create]
pods [] [] [get list]
secrets [] [] [get list]
eks-node-instance-role
{
"Code": "Success",
"AccessKeyId": "ASIA24EXAMPLE7QANHUP",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/EXAMPLEKEY",
"Token": "IQoJb3JpZ2luX2VjEND...truncated...",
"Expiration": "2026-07-16T15:44:02Z"
}
nginx-frontend-6b9c4
payments-worker-77f5d

See it, then shut it

Every door you just walked leaves a trace somewhere. Two logs matter most here: the API server's audit log, and a runtime sensor like Falco, a tool that watches what processes actually do inside your containers. Anonymous reconnaissance shows up in the audit log as a user named system:anonymous. A scanner rattling one resource after another shows up as a burst of 403 responses from a single source IP address. The IMDS pivot and the token theft are different animals. They happen entirely inside the pod and never reach the API server, so the audit log never sees them. That's the runtime sensor's job: it watches for a process opening a connection to 169.254.169.254, or reading the service-account token file straight off disk.

kube-apiserver audit log (anonymous recon)
{"kind":"Event","apiVersion":"audit.k8s.io/v1","level":"Metadata","verb":"list",
"user":{"username":"system:anonymous","groups":["system:unauthenticated"]},
"sourceIPs":["10.0.7.23"],
"objectRef":{"resource":"secrets","namespace":"default"},
"responseStatus":{"metadata":{},"code":403},
"requestReceivedTimestamp":"2026-07-16T09:14:02.113451Z"}
Falco runtime alert (IMDS pivot)
09:14:07.442110001: Notice Outbound connection to EC2 instance metadata service
(command=curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/
connection=10.0.7.23:51234->169.254.169.254:80
container=nginx-frontend pod=nginx-frontend-6b9c4 namespace=shop image=shop/web:1.4)
Rule: Contact EC2 Instance Metadata Service From Container

Closing a door is usually one config flag or one policy, and the order you work in matters. Shut anything that answers before authentication first. Turn off the kubelet's anonymous auth and its read-only port. Let a public /version be the most an unauthenticated caller ever gets back. Put a real auth proxy, or a private-only endpoint, in front of etcd and any Dashboard. Then, and only then, start shrinking what each authenticated identity is allowed to reach, which is the whole back half of this course. The map you just drew feeds straight into the next lesson, where each of these doors becomes a named technique in the ATT&CK matrix, the public catalog of how real attackers operate. That lets you measure two things at once: whether a door is shut, and whether you'd even notice someone trying it.

kubelet-config.yaml (close anonymous auth on every node)
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
anonymous:
enabled: false # 10250 now returns 401 to unauthenticated callers
webhook:
enabled: true # authenticate bearer tokens against the API server
authorization:
mode: Webhook # authorize each call via SubjectAccessReview
readOnlyPort: 0 # kill the unauthenticated 10255 pod-list port
NetworkPolicy usually does not block the metadata endpoint
The obvious fix for the IMDS pivot is to deny egress to 169.254.169.254 with a NetworkPolicy. It often does nothing at all. That address is link-local, and a lot of CNI plugins (CNI, the Container Network Interface, is the component that wires up pod networking) never run their policy engine on link-local traffic, so the packet reaches the metadata service anyway. Close it where it actually lives. Require IMDSv2 and set the hop limit to 1: that makes metadata access token-based and single-hop, so a pod's extra network hop can't get to it. Or block the address outright with a host firewall rule on each node. Either way, test from inside a real pod, not from the node itself, or you'll walk away sure you fixed something you didn't.
Quick check
01A quick scan shows port 10250 on a worker node returning pod JSON to an unauthenticated curl. What does that tell you, and what is the right first move?
Correct — 10250 is the full kubelet API; an unauthenticated answer means it trusts anonymous callers, and disabling that is the fix.
Incorrect — Wrong twice: 10250 is the authenticated API, not 10255, and even 10255 leaks the pod list and should be turned off.
Incorrect — Wrong component. This is the kubelet, not the API server, and rotating the CA does not change kubelet auth settings.
Incorrect — It just answered your scanner, so it is reachable from where you are. That assumption is the vulnerability.
02Anonymous reconnaissance against the API server appears in its audit log as the user system:anonymous. Which attacker action from inside a compromised pod would NOT appear in that audit log at all?
Incorrect — That request still reaches the API server, so it is recorded (as system:anonymous) like any other unauthenticated call.
Incorrect — The 403 is generated by the API server, so the denied request lands in the audit log as a system:anonymous forbidden event.
Correct — The metadata-service (IMDS, the Instance Metadata Service) pivot is a connection straight from the pod to that endpoint and never touches the API server, so only a runtime sensor like Falco can catch it.
Incorrect — can-i is a SelfSubjectRulesReview handled by the API server, so it is authenticated and audited.
03To stop the metadata-service pivot, a team adds a NetworkPolicy denying egress to 169.254.169.254, tests it by curling that address from the node, sees it blocked, and closes the finding. What is wrong?
Incorrect — NetworkPolicy usually does nothing here, because many CNI (Container Network Interface) plugins never run their policy engine on link-local traffic.
Correct — The metadata address is link-local and most CNI plugins skip policy on it, so the packet still reaches IMDS; close it at the source and test where it actually runs.
Incorrect — 169.254.169.254 is the correct IMDS address, so the flaw is the enforcement layer, not a typo.
Incorrect — NetworkPolicy fully supports egress rules; the real gap is that CNI plugins commonly do not enforce policy on link-local traffic.

Try this

Work through “See it, then shut it” 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: networkPolicy usually does not block the metadata endpoint. 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