Fortifying the Fort: Defensive Strategies in Kubernetes and Containers
Update (15 Oct 2026): ingress-nginx, the controller in the incident below, was retired in March 2026 with no further patches; Kubernetes recommends migrating to Gateway API or another controller. If you run Trivy in CI, read the note in the tool stack on its March 2026 compromise.
On 24 March 2025, the Kubernetes Security Response Committee disclosed CVE-2025-1974 (CVSS 9.8): anything with access to the pod network could inject configuration into ingress-nginx through its admission webhook and, because the controller can read every Secret in the cluster by default, could in many cases take over the cluster without credentials. Two defaults set the blast radius: an unsegmented pod network and a service account that reads every Secret.
Since Kubernetes removed dockershim in v1.24, nodes usually run containerd or CRI-O, but mounting /var/run/docker.sock or marking a pod privileged: true is still a risk. Image signing and verification are covered in hardening the supply chain, and CI/CD credentials in pipeline hijacking.
The attacker’s path
Take a cluster with dev and prod namespaces, no NetworkPolicy and the default service accounts. After an RCE in a dev web application, the attacker:
- Reads the token from
/var/run/secrets/kubernetes.io/serviceaccount/tokenand asks the API what the token is allowed to do. - Lists Secrets if allowed; if the token can create pods, launches a privileged one with a
hostPathof/and jumps to the node. - Requests the node’s credentials from the cloud metadata service (
169.254.169.254). - Connects to services in
prod, because a namespace is not a network boundary.
Execution in the pod: minimal image and securityContext
After the RCE, the attacker looks for tools. Debian-based images such as node:latest or python:latest hand them a shell, apt, curl and git; Alpine also ships a shell (BusyBox) and apk. For a static Go or Rust binary, distroless static has no shell and no package manager:
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /go/bin/app .
FROM gcr.io/distroless/static-debian13@sha256:58133991db06659feaabe0f4e97a35cebf15ef4ea08f8a4c6d2ee5f75e4aa6a0
COPY --from=build /go/bin/app /app
USER 10001:10001
ENTRYPOINT ["/app"]
The manifest uses a dedicated service account with no token, the image pinned by digest and a securityContext:
apiVersion: v1
kind: ServiceAccount
metadata:
name: web
namespace: prod
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: prod
spec:
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
serviceAccountName: web
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: web
image: registry.example.com/app@sha256:<digest>
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
With runAsNonRoot, the kubelet refuses to start the container as UID 0. allowPrivilegeEscalation: false sets no_new_privs (a setuid binary cannot raise privileges), drop: ["ALL"] removes every capability, RuntimeDefault applies the runtime’s seccomp profile and readOnlyRootFilesystem blocks writes outside the mounted volumes (here, /tmp). Deploying by digest, with no hot fixes through kubectl exec, means the image that runs is the one built and scanned.
Pod Security Admission (stable since v1.25) applies the Pod Security Standards per namespace. Start with a server-side dry run against the existing pods, then turn on enforce, pinned to your cluster’s minor version (v1.37 in the example):
kubectl label --dry-run=server --overwrite ns prod \
pod-security.kubernetes.io/enforce=restricted
kubectl label --overwrite ns prod \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=v1.37 \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
Limits: restricted does not require readOnlyRootFilesystem, and enforce acts on pods, not on Deployments, so a non-compliant Deployment is accepted, with a warning because warn is set, and its ReplicaSet fails to create pods. Anyone who can patch the namespace can relax the labels. PSA is also no substitute for patching the runtime: CVE-2024-21626 (January 2024, fixed in runc 1.1.12) allowed a container escape through a leaked file descriptor.
Service account token and RBAC escalation
Since v1.22, the mounted token expires, the kubelet rotates it and it is bound to the pod: once the pod is deleted, the token stops validating. However, with --service-account-extend-token-expiration left at its default, injected tokens can stay valid for up to a year, so a stolen one works for as long as the pod exists. If the application does not call the API, automountServiceAccountToken: false removes the token (the pod-level value wins); one ServiceAccount per workload avoids sharing default.
If the token is there, what matters is what it allows. The RBAC good practices guide flags these escalation paths:
get,listorwatchonsecrets(listandwatchreturn the contents).createon pods or workloads: lets you mount any Secret and use any service account in the namespace.getonnodes/proxy: the kubelet API, bypassing audit logging and admission.createonserviceaccounts/token,escalate,bind,impersonate, andpatchon namespaces.cluster-adminand wildcards (*), which cover even resources that do not exist yet.
kubectl auth can-i --list with --as=system:serviceaccount:prod:web shows its permissions (this needs the impersonate verb). This CI check fails if it finds one of those paths or cannot evaluate one (with --quiet, “no” and an error share an exit code, so it reads the output):
#!/usr/bin/env bash
# Exit 1 on an escalation path, 2 if any check cannot run.
set -euo pipefail
sa="system:serviceaccount:prod:web"
ns="prod"
found=0
check() {
local out
out=$(kubectl auth can-i "$@" -n "$ns" --as="$sa") || true
case "$out" in
yes) echo "RISK: $sa can $*" >&2; found=1 ;;
no*) ;;
*) echo "ERROR: could not evaluate: $*" >&2; exit 2 ;;
esac
}
check get secrets
check list secrets
check watch secrets
check create pods
check create serviceaccounts --subresource=token
check get nodes --subresource=proxy
check patch namespaces
check impersonate users
check bind clusterroles.rbac.authorization.k8s.io
check escalate clusterroles.rbac.authorization.k8s.io
check '*' '*'
exit "$found"
Run it on every RBAC change. It does not cover every path (creating Deployments, for example).
Secrets: encrypted in etcd or kept out of the API
Secret values are only base64-encoded, which provides no confidentiality, and by default they are stored unencrypted in etcd: anyone who can read etcd or one of its backups can read all of them. Encryption at rest with KMS v2 (stable since v1.29) is configured on the API server with --encryption-provider-config:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
apiVersion: v2
name: cloud-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
timeout: 3s
# Keep identity only until every Secret has been rewritten.
- identity: {}
After restarting the API server, kubectl get secrets --all-namespaces -o json | kubectl replace -f - rewrites the existing Secrets so they are stored encrypted; then remove identity. On EKS 1.28 and later, this encryption is on by default, with KMS v2.
Limit: this protects etcd, not the API. Anyone who can read Secrets, or create a pod that mounts them, sees them in plain text. To keep a value out of etcd altogether, the Secrets Store CSI Driver mounts it straight from the external secrets manager (unless you turn on its alpha sync-to-Secret feature), and the Vault Agent Injector writes it to shared memory. External Secrets Operator, by contrast, copies it into a native Secret, stored in etcd.
Escaping to the node: PSA and admission policies
The jump to the node in step 2 is blocked by baseline in labeled namespaces: it forbids privileged, hostPath volumes (including ones for docker.sock or the containerd socket), hostNetwork, hostPID, hostIPC and adding capabilities beyond a short allowed list. PSA does not look at where the image comes from. That is the job of ValidatingAdmissionPolicy (stable since v1.30), which evaluates CEL rules inside the API server itself:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: trusted-registry-digest.example.com
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods", "pods/ephemeralcontainers"]
validations:
- expression: >-
object.spec.containers.all(c,
c.image.startsWith('registry.example.com/') && c.image.contains('@sha256:'))
message: "containers must use registry.example.com images pinned by digest"
- expression: >-
!has(object.spec.initContainers) || object.spec.initContainers.all(c,
c.image.startsWith('registry.example.com/') && c.image.contains('@sha256:'))
message: "initContainers must use registry.example.com images pinned by digest"
- expression: >-
!has(object.spec.ephemeralContainers) || object.spec.ephemeralContainers.all(c,
c.image.startsWith('registry.example.com/') && c.image.contains('@sha256:'))
message: "ephemeralContainers must use registry.example.com images pinned by digest"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: trusted-registry-digest-prod.example.com
spec:
policyName: trusted-registry-digest.example.com
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: prod
pods/ephemeralcontainers covers the containers that kubectl debug adds. Start with validationActions: [Warn, Audit] and switch to [Deny] once the warnings stop. This is the native alternative to the Kyverno restrict-image-registries policy in the supply chain post: use one or the other.
Coming from the right registry proves nothing about who signed the image or how it was built; ImageValidatingPolicy checks that, on Kyverno 1.18 or later. According to the policy types table, ClusterPolicy, along with its verifyImages rules, was deprecated in 1.19 and will be removed in 1.20. The complete policy is in that same post. Kyverno and OPA Gatekeeper run as webhooks: with failurePolicy: Ignore, requests go through unvalidated when the webhook does not respond; with Fail, they are rejected.
Reaching production: NetworkPolicy and real separation
Kubernetes has no default network policy: if no NetworkPolicy selects a pod, all of its traffic is allowed, including across namespaces. NetworkPolicies are also enforced by the network plugin, so without a CNI that supports them, such as Calico or Cilium, they have no effect (flanneld does not enforce them on its own). Test this in a scratch namespace. The starting point is to deny everything and allow DNS to CoreDNS, which still carries the k8s-app: kube-dns label:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: prod
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Each application’s rules go on top, such as ingress to port 8080 on app: web from the ingress controller. NetworkPolicy is namespaced: every new namespace needs its own (Calico and Cilium add cluster-wide policies).
Limit: NetworkPolicy filters at L3 and L4, with no cryptographic identity and no encryption. Istio runs in PERMISSIVE mode by default, which accepts plain-text traffic, until you apply a PeerAuthentication with mtls.mode: STRICT; authorization goes in an AuthorizationPolicy with principals such as cluster.local/ns/prod/sa/web. And a namespace does not isolate tenants: dev and prod share the API server and, if they share nodes, the kernel. The multi-tenancy guide discusses separate clusters; I would put production in its own cluster and cloud account.
Cloud metadata
On AWS, Google Cloud and Azure, the instance metadata service (IMDS) answers at 169.254.169.254, and on EKS a pod that reaches it can inherit the node’s permissions. The default-deny policy already blocks it; the risk returns once you open egress to the internet. In that egress rule, use an ipBlock with cidr: 0.0.0.0/0 and except: ["169.254.169.254/32"].
The EKS guide also recommends requiring IMDSv2 with a hop limit of 1, so the response cannot reach pods that run in their own network namespace, and one role per application through EKS Pod Identity or IRSA. Do not switch IMDS off entirely: the node termination handler, among others, depends on it.
aws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1 \
--http-endpoint enabled
On GKE, Workload Identity Federation for GKE intercepts those requests and serves the pod’s identity instead of the node’s. The exception is pods with hostNetwork: true: GKE does not apply the federation to them and NetworkPolicy does not define how to treat them, another reason to enforce baseline.
Runtime detection and response
The stable rules for Falco cover several steps of the scenario: Terminal shell in container, Contact K8S API Server From Container and Drop and execute new binary in container. For IMDS, the incubating ruleset includes Contact cloud metadata service from container, which is not loaded by default; install it separately (falcoctl or the Helm chart).
Decide the response in advance: isolate the pod with a NetworkPolicy, delete it so its token stops working, and rotate anything it may have read.
The API server records no audit events without --audit-policy-file, and keeps none without a backend (--audit-log-path or a webhook); on EKS, control plane logs are off by default. This policy logs everything at the Metadata level: who, which verb and which resource, without request bodies or Secret values.
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- "RequestReceived"
rules:
- level: Metadata
Those events let you detect bulk Secret reads or unexpected requests to serviceaccounts/token. kube-bench checks nodes and the control plane against the CIS Kubernetes Benchmark (kube-bench run --targets master,node); on EKS or GKE, only the worker nodes. Manifests get checked in CI with trivy config or Kubescape (see the tool stack).
What this does not cover
- A kernel or runtime bug can get around
securityContextand PSA; for untrusted code, the multi-tenancy guide suggests sandboxing (virtual machines or user-space kernels). - A list of findings (from kube-bench, Kubescape or an image scanner) does not tell you which ones chain into a path like this one; to find out, you have to test it, for example with a cluster pentest that starts from a compromised pod.
If you only do three things
- PSA
restrictedon every application namespace (a dry run, orwarnandaudit, beforeenforce). - Default-deny with DNS allowed, tested against your CNI, and production in its own cluster.
- No token for workloads that do not use the API, and the RBAC check in CI.
Automate this in the pipeline and at admission; runtime detection covers what those controls cannot block.
References
- Pod Security Standards.
- Pod Security Admission.
- Role Based Access Control Good Practices.
- Kubernetes Hardening Guide v1.2, NSA and CISA, August 2022.
- OWASP Kubernetes Top 10.