CKS — Node Metadata Protection

9 minute read

Exam Context

Property Value
Domain Domain 1 — Cluster Setup
Weight 10%
Objective Protect node metadata and endpoints

Cloud provider metadata services expose sensitive information — IAM credentials, instance identity tokens, network configuration, and user data scripts — through a well-known IP address. If a Pod can reach this endpoint, a compromised container can escalate to cloud-level privileges.

The Threat: Cloud Metadata Exploitation

Every major cloud provider runs a metadata service at 169.254.169.254. This link-local address is accessible from any process running on the instance — including Kubernetes Pods.

Real-World Attacks

Incident What Happened
Capital One breach (2019) SSRF vulnerability allowed an attacker to query the AWS metadata endpoint, steal IAM temporary credentials, and exfiltrate 100M+ customer records
Shopify bug bounty Researcher accessed GCP metadata from a Pod, obtained a service account token with overly broad permissions
Tesla cryptomining Unsecured Kubernetes Dashboard exposed credentials; attackers accessed instance metadata for persistent access

The pattern is consistent: an attacker gets code execution inside a Pod, queries 169.254.169.254, obtains cloud credentials, and escalates to cloud infrastructure access.

Cloud Metadata Endpoints Comparison

Cloud Metadata URL IMDSv1 IMDSv2/Token-Based Credentials Endpoint
AWS http://169.254.169.254/latest/meta-data/ Yes (default) Yes (requires PUT) /latest/meta-data/iam/security-credentials/<role>
GCP http://metadata.google.internal/computeMetadata/v1/ Yes (legacy) Yes (Metadata-Flavor: Google header required) /computeMetadata/v1/instance/service-accounts/default/token
Azure http://169.254.169.254/metadata/instance Yes Yes (Metadata: true header required) /metadata/identity/oauth2/token

What Metadata Exposes

# AWS — Get IAM role credentials (the most dangerous endpoint)
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Returns the role name, e.g., "eks-node-role"

curl http://169.254.169.254/latest/meta-data/iam/security-credentials/eks-node-role
# Returns AccessKeyId, SecretAccessKey, Token — full temporary credentials!
# GCP — Get access token
curl -H "Metadata-Flavor: Google" \
  http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
# Returns {"access_token":"ya29.c...","expires_in":3600,"token_type":"Bearer"}
# Azure — Get access token
curl -H "Metadata: true" \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"
# Returns {"access_token":"eyJ0...","token_type":"Bearer",...}

NetworkPolicy: Block Metadata Access

The primary defense within Kubernetes is a NetworkPolicy that blocks egress to 169.254.169.254/32.

Manifest: block-metadata.yaml

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-cloud-metadata
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    # Allow DNS (required for service discovery)
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Allow all egress EXCEPT metadata endpoint
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32

How this works:

  • ipBlock.cidr: 0.0.0.0/0 allows all IP destinations
  • except: [169.254.169.254/32] removes the metadata IP from the allowed set
  • DNS is explicitly allowed on port 53

Apply and Verify

# Create the namespace
kubectl create namespace production --dry-run=client -o yaml | kubectl apply -f -

# Apply the policy
kubectl apply -f block-metadata.yaml

# Verify the policy
kubectl get networkpolicy block-cloud-metadata -n production
kubectl describe networkpolicy block-cloud-metadata -n production

Expected describe output:

Name:         block-cloud-metadata
Namespace:    production
Spec:
  PodSelector:     <none> (Coverage: All Pods)
  Allowing egress traffic:
    To Port: 53/UDP
    To Port: 53/TCP
    To:
      NamespaceSelector: <none>
    ----------
    To:
      IPBlock:
        CIDR: 0.0.0.0/0
        Except: 169.254.169.254/32
  Policy Types: Egress

Testing Metadata Block

# Deploy a test Pod in the production namespace
kubectl run metadata-test --image=busybox --namespace=production \
  --rm -it --restart=Never -- sh

Inside the Pod:

# This should timeout — metadata is blocked
wget --timeout=3 -q -O- http://169.254.169.254/latest/meta-data/
# Expected: wget: download timed out

# This should work — regular egress is allowed
wget --timeout=3 -q -O- http://example.com
# Expected: HTML content from example.com

# DNS should work
nslookup kubernetes.default
# Expected: resolved to cluster IP

Default Deny + Allow Specific + Block Metadata

The most secure pattern combines default deny with selective allows:

Manifest: comprehensive-egress.yaml

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-egress
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-and-safe-egress
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    # DNS
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    # Allow cluster-internal traffic only
    - to:
        - namespaceSelector: {}
    # Allow specific external IPs if needed, but block metadata
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32

AWS-Specific: IMDSv2 as Defense in Depth

AWS offers Instance Metadata Service v2 (IMDSv2), which requires a PUT request to obtain a session token before accessing metadata. This mitigates SSRF attacks but does not stop a compromised Pod that can make arbitrary HTTP requests.

Enforcing IMDSv2 on EKS Nodes

# Require IMDSv2 (token-based) with a hop limit of 1
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

Why hop limit of 1?

  • The metadata request from a Pod traverses the container network namespace → the node network namespace → the metadata endpoint
  • A hop limit of 1 means the token request fails from Pods (too many hops)
  • Only processes running directly on the node can obtain tokens

Important: IMDSv2 with hop-limit=1 is an excellent defense, but NetworkPolicy is still the primary control. Use both for defense in depth.

EKS Pod Identity and IRSA

For EKS, use IAM Roles for Service Accounts (IRSA) or EKS Pod Identity instead of node-level IAM roles:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-app
  namespace: production
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/my-app-role

IRSA provides pod-level credentials through a projected token volume — the Pod never needs to access the metadata endpoint.

GCP-Specific: Workload Identity

GCP equivalent of IRSA. Pods get credentials through Workload Identity Federation instead of the metadata endpoint:

# Enable Workload Identity on GKE
gcloud container clusters update my-cluster \
  --workload-pool=my-project.svc.id.goog

# Annotate the Kubernetes ServiceAccount
kubectl annotate serviceaccount my-app \
  --namespace production \
  iam.gke.io/gcp-service-account=my-app@my-project.iam.gserviceaccount.com

Azure-Specific: Azure AD Workload Identity

Azure uses Workload Identity Federation for AKS pods:

# Create a federated credential
az identity federated-credential create \
  --name my-federated-id \
  --identity-name my-identity \
  --resource-group my-rg \
  --issuer "https://oidc.prod-aks.azure.com/tenant-id/" \
  --subject "system:serviceaccount:production:my-app"

Defense in Depth Summary

Layer Control Protects Against
NetworkPolicy Block egress to 169.254.169.254 All metadata access from Pods
IMDSv2 hop limit --http-put-response-hop-limit 1 SSRF and container access (AWS)
Pod-level identity IRSA, Workload Identity, Azure AD Eliminates need for metadata access
Minimal IAM roles Least-privilege node IAM role Limits blast radius if metadata is accessed
Network segmentation VPC/subnet isolation Reduces attack surface

CKS exam focus: The exam primarily tests NetworkPolicy-based metadata blocking. Know the ipBlock with except pattern cold.

Common Mistakes

Mistake Why It’s Wrong Fix
Blocking only IPv4 metadata Some environments use IPv6 link-local for metadata Add fd00:ec2::254/128 for AWS IPv6
Forgetting DNS in the policy Pods can’t resolve hostnames Always include UDP/53 and TCP/53
Applying to wrong namespace Policy only affects its own namespace Apply to every namespace with workloads
Relying solely on IMDSv2 A shell in a Pod can still call the PUT endpoint NetworkPolicy + IMDSv2 together
Not testing the policy Silent failures if CNI doesn’t support NetworkPolicy Always verify with a test Pod

Exam Tips

Tip Details
Know the IP 169.254.169.254 — memorize this
Know the YAML pattern ipBlock.cidr: 0.0.0.0/0 + except: [169.254.169.254/32]
Include DNS Every egress policy needs port 53 UDP & TCP
Test with wget wget --timeout=3 http://169.254.169.254/ from a Pod in the namespace
Namespace-scoped You must apply the policy in every namespace you want to protect

Practice Questions


Q1: Write a NetworkPolicy that blocks all Pods in the app namespace from accessing the cloud metadata endpoint while allowing all other egress including DNS.

Show Answer
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-metadata
  namespace: app
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32

Explanation:

  • First egress rule allows DNS to any namespace
  • Second egress rule allows all IPs except 169.254.169.254/32
  • Combined effect: all egress works except the metadata endpoint

Q2: Why is 169.254.169.254 reachable from Kubernetes Pods by default?

Show Answer

169.254.169.254 is a link-local address in the 169.254.0.0/16 range. Cloud providers configure their hypervisors to intercept traffic to this IP and respond with instance metadata.

Pods can reach it because:

  1. Kubernetes uses a flat network model — Pods get routable IPs
  2. The link-local route is present on the node’s routing table
  3. Pod traffic destined for 169.254.169.254 is forwarded to the node’s network stack
  4. The node’s network stack routes it to the cloud provider’s metadata service
  5. No NetworkPolicy exists by default to block this traffic

The metadata service responds regardless of which process on the instance makes the request — it uses the source IP of the node, not the Pod.


Q3: What is the difference between AWS IMDSv1 and IMDSv2, and why doesn’t IMDSv2 alone protect Kubernetes Pods?

Show Answer
Feature IMDSv1 IMDSv2
Access method Simple GET request PUT to get token, then GET with token header
SSRF protection None — any GET request works PUT request mitigates simple SSRF (many SSRF only support GET)
Hop limit None Configurable (default: 1 for EC2, 2 for EKS)

Why IMDSv2 alone doesn’t protect Pods:

  1. A compromised Pod with shell access can execute arbitrary commands, including the PUT request to obtain a token:
    TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
      -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
    curl -H "X-aws-ec2-metadata-token: $TOKEN" \
      http://169.254.169.254/latest/meta-data/iam/security-credentials/
    
  2. The hop limit defaults to 2 in EKS (to allow Pod access for IRSA), so the request succeeds
  3. IMDSv2 blocks network-level SSRF but not code-execution-level attacks

Defense in depth: Use NetworkPolicy to block 169.254.169.254 AND enforce IMDSv2 with hop limit 1 AND use IRSA/Pod Identity for credentials.


Q4: A security audit finds that Pods in the monitoring namespace need to access the cloud metadata endpoint for node discovery. How would you allow metadata access only for specific Pods?

Show Answer

Use a targeted NetworkPolicy with a podSelector to create an exception:

# Default: block metadata for all Pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-metadata-default
  namespace: monitoring
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32
---
# Exception: allow metadata for node-exporter Pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-metadata-node-exporter
  namespace: monitoring
spec:
  podSelector:
    matchLabels:
      app: node-exporter
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 169.254.169.254/32
      ports:
        - protocol: TCP
          port: 80

How this works:

  • NetworkPolicies are additive — the second policy adds metadata access for Pods with app: node-exporter
  • Other Pods in the namespace are still blocked
  • The metadata exception only allows TCP/80 (the metadata HTTP service)

Q5: You applied a metadata-blocking NetworkPolicy, but wget http://169.254.169.254 still returns data from inside a Pod. What went wrong?

Show Answer

Most likely causes:

  1. Wrong namespace — The NetworkPolicy is applied to a different namespace. NetworkPolicies are namespace-scoped. Verify:
    kubectl get networkpolicy -n <pod-namespace>
    
  2. CNI doesn’t support NetworkPolicy — If using Flannel or a CNI without policy support, the policy exists but has no effect:
    kubectl get pods -n kube-system | grep -E "calico|cilium|weave"
    
  3. Policy syntax error — The ipBlock.except section may be malformed. Common mistakes:
    • Using 169.254.169.254 without /32 prefix
    • Nesting except under the wrong YAML key
    • Missing the policyTypes: [Egress] field
  4. Conflicting policy — Another NetworkPolicy in the same namespace allows all egress (e.g., egress: [{}]), which overrides the restriction since policies are additive:
    kubectl get networkpolicy -n <namespace> -o yaml | grep -A5 "egress"
    
  5. Pod was created before the policy — NetworkPolicies apply to existing Pods immediately, but verify the policy was created successfully:
    kubectl describe networkpolicy block-metadata -n <namespace>
    

Debug workflow: Check namespace → check CNI → check YAML → check for conflicting policies.


← Ingress TLS GUI Access Control →

Updated: