click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 11

An Exposed Kubelet Is Instant RCE on the Node

Kubelet security: locking down the node agent - Kubernetes Journey

Here's a fact that made me sit up when I first met it: on a lot of clusters, you can point curl at a worker node, ask for a list of running pods, and get an answer. No login. No token. Nothing. And from that same door, if it's left open, you can run commands inside those pods. That door is the kubelet, and this post is about shutting it properly.

The kubelet is the little agent that runs on every worker node. It's the thing that actually starts your containers when the scheduler says so, and reports back whether they're alive. It talks to the API server constantly. And because it has that much power over a node, an exposed kubelet is one of the cleaner ways to go from "I can reach the network" to "I own this node". Let's fix that.

What the kubelet is, in one minute

The kubelet is the on-node worker. When you schedule a pod, the API server doesn't run it. It just records the intent, and the kubelet on the chosen node notices, pulls the images and tells the container runtime (Docker, containerd, whatever) to start them. Then it watches those containers and streams their health back up.

To do that job it exposes its own small HTTP API, and that API is the security story here. It's not just status either. The kubelet can hand you container logs, forward ports, and run commands inside a running container. Handy for the cluster. Very handy for an attacker.

Which is the whole point: the kubelet controls the workloads on a node, so whoever controls the kubelet controls the node.

Where the kubelet keeps its settings

Before hardening it, you need to find how it's configured. Older setups passed everything as long command-line flags in the systemd service file. Since around v1.10, most of that moved into a config file, usually /var/lib/kubelet/config.yaml, written in KubeletConfiguration YAML. One gotcha worth remembering: flag names use dashes (--read-only-port) while the YAML uses camelCase (readOnlyPort), and if a setting appears in both places, the command-line flag wins.

To see what's actually running, look at the live process and read its config:

# what flags is the kubelet running with?
ps -aux | grep kubelet

# and the config file it points at
cat /var/lib/kubelet/config.yaml

That config dump tells you everything you're about to check: whether anonymous access is on, which CA it trusts, the authorization mode, and whether the read-only port is open. This is also the first thing I read on a node during an assessment.

The two ports: 10250 and 10255

The kubelet listens on two ports by default, and they are not equal. One is the real API. The other is a quiet little information leak that a lot of people forget exists.

Kubelet's two default ports
PortWhat it servesRisk out of the box
10250full read-write API: pods, logs, exec, port-forwardcode execution
10255read-only: metrics and pod data, no auth everinfo leak
10250 is the dangerous one because it can run commands. 10255 leaks data with no login at all.

Out of the box the kubelet treats an unauthenticated caller as an anonymous user (username system:anonymous, group system:unauthenticated) and, worse, often lets that anonymous user through. So a plain request works:

an open kubelet, no creds
$ curl -sk https://node-1:10250/pods | jq '.items[].metadata.name' "web-frontend-7d9f" "payments-api-55c8" $ curl -sk http://node-1:10255/metrics | head # node metrics stream back, still no login... # from here: /logs, and exec into a container. that's RCE.
If this returns data on your cluster, the node is one step from full compromise. Try it on your own nodes.
Why this is a big deal The kubelet's exec endpoint runs commands inside your containers. An anonymous 10250 isn't an info leak, it's remote code execution on every workload on that node. This is a real, well-trodden attack path, not a theoretical one.

Four changes that shut the door

The good news: hardening the kubelet is four settings, and none of them are hard. Here's the whole checklist, then each one in turn.

01
Kill anonymous

anonymous-auth = false

02
Require certs

trust a CA with clientCAFile

03
Real authorization

AlwaysAllow → Webhook

04
Close 10255

readOnlyPort = 0

Do all four. Any one on its own leaves a gap.

1. Turn off anonymous access

This is the big one. Stop the kubelet accepting requests with no identity. You can set it as a flag or in the config file, and you'll see both styles in the wild:

# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
  anonymous:
    enabled: false
# kubelet.service
ExecStart=/usr/local/bin/kubelet \
  --anonymous-auth=false \
  ...

2. Require a client certificate

Turning off anonymous access is only half the job. Now you have to give legitimate callers a way to prove who they are. The clean way is certificates, the same TLS and PKI ideas from Topic 7. You tell the kubelet which certificate authority (CA) to trust, and only clients holding a certificate signed by that CA get in:

# /var/lib/kubelet/config.yaml
authentication:
  x509:
    clientCAFile: /etc/kubernetes/pki/ca.crt

Remember the direction here: from the kubelet's point of view, the API server is the client. So the API server has to present a certificate the kubelet trusts, which is why it's configured with its own kubelet client cert and key:

# kube-apiserver
--kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt
--kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key
The silent fallback If your auth isn't set up so it can positively reject a bad request, the kubelet quietly drops back to treating the caller as anonymous. So "I disabled anonymous" and "I set up certs" aren't two optional choices, you need both, or you can end up back where you started.

3. Swap AlwaysAllow for Webhook

Authentication proves who you are. Authorization decides what you're allowed to do, and this is where the kubelet has an ugly default: AlwaysAllow, which means every authenticated request is permitted. Switch it to Webhook, and the kubelet stops deciding for itself. Instead it asks the API server "is this caller allowed to do this?" and honours the answer. It's the same Webhook authorization idea from the RBAC post, pointed at the kubelet:

# /var/lib/kubelet/config.yaml
authorization:
  mode: Webhook

Now a random authenticated identity can't just call exec on any pod. The API server's RBAC rules get the final say.

4. Close the read-only port

Last one, and it's a one-liner. Port 10255 serves metrics and pod info with no authentication, ever. There's rarely a good reason to leave it open. Set it to zero and it's gone:

# /var/lib/kubelet/config.yaml
readOnlyPort: 0

Anonymous off, certs on, Webhook authorization, read-only port closed. Four lines, and probably the easiest node-level win in Kubernetes.

The attacker's checklist (and so, yours)

When I look at a cluster, the kubelet is an early target because the payoff is so high. The recon is quick, and flipping it round gives you your own audit list:

  • Can I reach 10250 or 10255 on any node from where I'm standing? Network reachability is half the battle, so segment these ports off.
  • Does curl -sk https://node:10250/pods answer without credentials? If yes, anonymous auth is on.
  • Is 10255 responding at all? If so, that's free reconnaissance for anyone on the network.
  • On the node itself, does config.yaml show authorization.mode: AlwaysAllow? That's the jackpot for an attacker and a finding for you.
Defender checklist Set anonymous.enabled: false, authorization.mode: Webhook, a real clientCAFile, and readOnlyPort: 0 on every node. Then firewall 10250 so only the control plane can reach it. The CIS Benchmark checks all of this, so kube-bench will flag it for you.

Why this one is blunter than most

What stuck with me is how blunt this one is. Most Kubernetes security is layered and subtle. The kubelet is not: it's a handful of settings that are either right or catastrophically wrong, and the default leans wrong. I keep coming back to that anonymous exec path, because it turns a network position straight into code execution with nothing clever required. I've not yet built a lab to fire the exec endpoint end to end myself (it's on my list), so if you've walked that exact path I'd be keen to compare notes. Next up I want to look at securing the kubelet's siblings on the control plane, and how certificate rotation keeps all of this honest over time.

If this was useful, come say hi on LinkedIn or the contact page, and tell me what to break down next. More in the Kubernetes Journey.

Further reading

FAQ

Why is an exposed kubelet dangerous?

The kubelet's API on port 10250 can list pods, read logs and even run commands inside containers. If it accepts anonymous requests, anyone who can reach that port gets code execution on the node's workloads, no credentials needed.

What are kubelet ports 10250 and 10255?

Port 10250 is the kubelet's full read-write API. Port 10255 is a read-only port that serves metrics and pod data with no authentication at all. Best practice is to secure 10250 and disable 10255 by setting readOnlyPort to 0.

How do I secure the kubelet?

Turn off anonymous access with anonymous-auth=false, require client certificates via clientCAFile, switch authorization from AlwaysAllow to Webhook so the API server vets each request, and disable the read-only port by setting readOnlyPort to 0.