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:

  1. Reads the token from /var/run/secrets/kubernetes.io/serviceaccount/token and asks the API what the token is allowed to do.
  2. Lists Secrets if allowed; if the token can create pods, launches a privileged one with a hostPath of / and jumps to the node.
  3. Requests the node’s credentials from the cloud metadata service (169.254.169.254).
  4. 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, list or watch on secrets (list and watch return the contents).
  • create on pods or workloads: lets you mount any Secret and use any service account in the namespace.
  • get on nodes/proxy: the kubelet API, bypassing audit logging and admission.
  • create on serviceaccounts/token, escalate, bind, impersonate, and patch on namespaces.
  • cluster-admin and 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 securityContext and 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

  1. PSA restricted on every application namespace (a dry run, or warn and audit, before enforce).
  2. Default-deny with DNS allowed, tested against your CNI, and production in its own cluster.
  3. 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

Blog

Let's talk security

Talks, press or partnerships: get in touch.