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

Node Metadata Isn't a Vulnerability, Which Is Why It Slips Through

Securing node metadata in Kubernetes - Kubernetes Journey

The first time I ran kubectl get nodes -o wide on a lab cluster I actually paused. In one line it told me the internal IP of the machine, the exact OS image, and the kubelet version. Handy for me. Also a gift for anyone who shouldn't be looking. None of it is a password, but if I were attacking that cluster, that's the first thing I'd want.

So this one is about the quiet stuff a node tells you. What a Kubernetes node actually records about itself, why that pile of "boring" detail is worth protecting, and how to make sure only the right people can read it. What, how, why, takeaway.

What node metadata actually is

Every worker machine in a cluster has a matching object in Kubernetes called a Node. That object is a description of the machine, kept and updated by the control plane. When people say "node metadata", they mean all the fields inside that description.

The kubelet, the agent running on each node, reports its state up to the API server, which stores it on the Node object. You read it back with kubectl get nodes or, for the full dump, kubectl describe node <name>. Think of it like the spec sheet stuck to the side of a server in a data centre: model, address, what's installed, whether it's healthy.

The scheduler leans on this data to decide where your pods run, so it has to be accurate. But the same sheet also describes the machine in enough detail to attack it. That double use, useful to you and useful to an attacker, is the whole tension in this post.

A NODE OBJECT
Name & UID
Labels
Taints
System info
Addresses
Conditions
Capacity
Cloud IDs
Everything a single node quietly publishes about itself. Some of it is just admin. Some of it is recon.

A quick tour of what's inside

Let me name the parts, because they come up again when we talk about risk. Nothing here is complicated, it's just a machine describing itself:

  • Name and UID - the unique identity of the node.
  • Labels - simple key/value tags used to group nodes, like region=us-east-1 or disk=ssd. The scheduler reads these to place pods.
  • Taints - the opposite of a label: a "keep off unless you're allowed" marker. A taint like NoSchedule stops normal pods landing on that node.
  • System info - the revealing bit: machine ID, system UUID, boot ID, kernel version, OS image, container runtime version, and the kubelet and kube-proxy versions.
  • Addresses - the node's internal and external IP addresses.
  • Conditions and capacity - is it Ready, is it under memory pressure, how much CPU and RAM it has.
  • Cloud provider IDs - on EC2, GCE or Azure, the provider's own instance ID for the VM.

Here's the slice most people never look at twice, straight out of describe:

kubectl describe node node01 (trimmed)
System Info: Kernel Version: 5.15.0-1065-gcp OS Image: Ubuntu 22.04.4 LTS Container Runtime Version: containerd://1.6.26 Kubelet Version: v1.30.0 # exact version = exact exploit shortlist Kube-Proxy Version: v1.30.0 Addresses: InternalIP: 192.168.87.12 # now they can map your private network # none of this is "secret" - that's exactly why it gets ignored
The node isn't leaking a bug. It's leaking a map and a version list, which is enough.
One line to avoid confusion This is the Kubernetes Node object, read through the API. It is not the cloud metadata endpoint at 169.254.169.254 that hands VMs their instance data and credentials. Same phrase, totally different thing, and you protect them in totally different ways. I'll come back to that at the end so it doesn't muddy the water.

Why this "boring" data is worth protecting

The mistake is thinking recon data doesn't count because none of it is a credential. But an attacker's hardest job early on is knowing what they're even looking at. Node metadata does that job for them. Four concrete ways it bites.

1. Version disclosure to targeted exploits

The problem: if someone can list your nodes, they get the kubelet version, the kernel version and the runtime version for free. That turns a blind attack into a precise one. Instead of spraying exploits and hoping, they look up the known bugs for that exact build and fire the one that fits.

The recon is a one-liner. This is the sort of thing I'd run in the first minute if I had read access I shouldn't:

free recon from a single list permission
$ kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.kubeletVersion}' v1.30.0 v1.30.0 v1.28.4 # one node is behind - that's the one I'd hit   $ kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.kernelVersion}' 5.4.0-1041-aws 5.15.0-1065-gcp # kernel = local-privesc shortlist # no exploit fired yet - just building the target list
The mixed versions are the tell. Attackers go for the oldest thing on the board.

2. A ready-made map of your network

List every node's addresses and you've drawn the internal network without touching a scanner. Which subnets exist, how many machines, where the control plane sits. From there it's easier to plan lateral movement, or just to point a flood of traffic at the right internal targets. It's the reconnaissance step done for you.

kubectl get nodes -o jsonpath='{.items[*].status.addresses[?(@.type=="InternalIP")].address}'
# 192.168.87.12 192.168.87.13 192.168.87.14

3. Tampered labels and taints send pods to the wrong node

This is the sneaky one, and it's about writing metadata, not reading it. Labels and taints decide where pods land. If an attacker can change them, they can change scheduling. Picture a node deliberately kept for sensitive workloads, protected by a taint so nothing else lands there. Strip that taint and ordinary pods start scheduling onto it. Now your sensitive stuff is sharing a machine with who-knows-what.

Removing a taint is a single command (that trailing - means "remove"):

kubectl taint nodes node-1 workload=sensitive:NoSchedule-
# node/node-1 untainted

So the labels and taints that feel like plumbing are actually a security boundary. Whoever can edit them can quietly redraw where your workloads run.

4. The compliance angle

Handing out kernel versions, OS builds and internal topology to anyone who asks is also the kind of thing that trips audits. If you're under something like GDPR or HIPAA, "unauthenticated users can enumerate our infrastructure" is not a line you want in a report. I'm no auditor, so take that as a nudge to check rather than gospel, but it lands in a lot of frameworks.

01
Read nodes

one list permission, no exploit yet

02
Pick the weak one

oldest kubelet / kernel on the board

03
Fire the exact bug

version-specific, high hit rate

Metadata doesn't break anything on its own. It just makes the next step land first time.

Who can actually read your nodes?

Good news first: on a sane, modern cluster this isn't wide open by default. Nodes are cluster-scoped (they don't live in a namespace), so reading them needs a ClusterRole binding, not a namespaced one. And anonymous access to the API is off in current setups. So a random pod or an unauthenticated request shouldn't be able to list nodes.

The real leak is almost always RBAC that's too generous. Someone binds the built-in view ClusterRole or, worse, cluster-admin to a service account "just to get it working", and now that account can enumerate every node. So the question isn't "is it locked?", it's "who did we accidentally hand the keys to?" Least privilege is the whole game here.

Hands-on: see the leak, then close it

Let's make this real with a tiny local cluster. You'll create a low-privilege account, watch it read node metadata it shouldn't, then take that away and confirm the door's shut. Everything below is on your own lab only.

Step 1: a throwaway cluster

I use kind (Kubernetes in Docker) for this because it's instant and disposable:

kind create cluster --name meta-lab
kubectl get nodes -o wide     # this is the view we're about to restrict

Step 2: a service account with too much reach

Now the mistake, on purpose. We make a service account and bind it a ClusterRole that can list nodes. This stands in for the "just make it work" binding you find in real clusters:

kubectl create serviceaccount recon -n default

# a ClusterRole that can read nodes cluster-wide
kubectl create clusterrole node-reader --verb=get,list --resource=nodes

# bind it to our service account (this is the over-grant)
kubectl create clusterrolebinding recon-nodes \
  --clusterrole=node-reader \
  --serviceaccount=default:recon

Step 3: watch the metadata walk out

Impersonate that account with --as and pull the same recon an attacker would. The auth can-i check is the honest test of what an identity is allowed to do:

as the over-privileged service account
$ kubectl auth can-i list nodes --as=system:serviceaccount:default:recon yes # uh oh - it can enumerate every node   $ kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.kubeletVersion}' \ --as=system:serviceaccount:default:recon v1.30.0 # version handed over, no admin rights needed
A plain "yes" here is the finding. One loose binding and the whole cluster's node info is readable.

Step 4: take it back and prove it

Now remove the over-grant and re-run the exact same check. The switch from yes to no is your proof the fix worked:

Before vs after removing the binding
CheckStatusResult
recon SA can list nodes (before)FAILopen
recon SA can list nodes (after)PASSdenied
Flip "Failures only" to see just the before state. The fix is the second row.
kubectl delete clusterrolebinding recon-nodes

# verify - should now say "no"
kubectl auth can-i list nodes --as=system:serviceaccount:default:recon
# no

kind delete cluster --name meta-lab   # tidy up

The fix for reading node metadata isn't some clever setting, it's RBAC hygiene. Grant get/list on nodes to the handful of things that truly need it, and nothing else.

What to look for as a defender

Two cheap habits catch most of this. First, audit who can actually read and write nodes rather than trusting your memory of it. Second, watch for node metadata being changed, especially taints and labels, since that's the scheduling-integrity risk, not just information leak.

  • Enumerate every subject that can touch nodes: comb your ClusterRoleBindings for the built-in view, edit and cluster-admin roles bound to service accounts.
  • Test specific identities directly: kubectl auth can-i list nodes --as=... and --as your service accounts. If something says yes that shouldn't, that's your finding.
  • In the API server audit log, flag update/patch on nodes and on nodes/status from anything that isn't the control plane or a known operator. A pod removing a taint is a red flag.
Defender checklist Keep get/list on nodes to the few components that need it. Never bind view or cluster-admin to a service account for convenience. Keep anonymous auth off. Lock down who can patch nodes so no ordinary account can strip a taint or relabel a machine. Audit node reads and writes. And remember the kubelet exposes node data too, so harden that alongside the API.
Don't confuse it with the cloud metadata endpoint Separate risk, worth naming: pods on a cloud VM can often reach 169.254.169.254, the provider's instance metadata service, which can hand out IAM credentials. That's a different beast from the Kubernetes Node object and needs its own controls (block the endpoint from pods, use IMDSv2, scope instance roles). I'll give that its own write-up rather than bolt it on here.

Why this one slips through

The thing that stuck with me writing this: node metadata isn't a vulnerability, and that's exactly why it slips through. There's no scary CVE, no patch to apply, just a machine honestly describing itself to anyone allowed to ask. The fix isn't a feature, it's discipline, deciding who's allowed to ask, and meaning it. Reading nodes is an RBAC problem; editing labels and taints is a scheduling-integrity problem. I've done the read-side lab end to end, but I've not yet properly rigged the taint-tampering scenario with a live workload to watch a sensitive pod drift onto the wrong node, so that's my next evening. If you audit clusters, I'm curious: how often do you still find view or worse bound to a service account "temporarily"? I suspect the honest answer is "more than we'd admit".

If this made node metadata click, come say hi on LinkedIn or the contact page, and tell me what to pull apart next. More in the Kubernetes Journey.

Further reading

FAQ

What is node metadata in Kubernetes?

It is everything the API server records about each worker machine in a Node object: its name and unique ID, labels and taints, the OS and kernel version, container runtime and kubelet version, internal and external IPs, resource capacity and cloud provider IDs. It describes the node and helps the scheduler place pods.

Why is exposing node metadata a security risk?

It hands an attacker a free targeting sheet. Kernel and kubelet versions point straight at version-specific exploits, internal IPs let them map your private network, and labels or taints reveal which nodes run sensitive workloads. None of it is secret, but together it turns blind guessing into precise attacks.

Can a normal user list Kubernetes nodes by default?

No. Nodes are cluster-scoped, so reading them needs a ClusterRole binding, and modern clusters disable anonymous access. The real risk is over-broad bindings: giving a service account or user a role with get and list on nodes, often by accident through a wide view or cluster-admin binding.

How do I stop workloads landing on the wrong node?

Treat the labels and taints that drive scheduling as security settings. Restrict who can patch nodes with RBAC so no ordinary account can remove a taint or relabel a node, and audit changes. If an attacker can strip a NoSchedule taint, sensitive pods can drift onto insecure nodes.

Is Kubernetes node metadata the same as the cloud metadata endpoint?

No, they are different things that share a name. This post is about the Kubernetes Node object read through the API. The cloud metadata endpoint at 169.254.169.254 is the provider service that hands out instance data and credentials to a VM. Both matter, but you protect them in completely different ways.