writing
Your Pod Is Compromised. How Far Does the Attacker Get?
Security··31 min read

Your Pod Is Compromised. How Far Does the Attacker Get?

Six Kubernetes attack paths from a single container to your cloud account, each with the built-in controls that stop it and the Kyverno policy that makes it stick.

Share

Key Takeaways

  • Assume the worst realistic starting point: an attacker already has remote code execution inside one container. A security model that collapses the moment one pod is owned is not a security model.
  • Six default-driven attack paths chain from a single container to your cloud account: root and a toolbox, a hostPath mount to the node, a mounted service account token, RBAC escalation, a flat network to the metadata endpoint, and a supply chain that runs your code for you.
  • The built-in track (non-root and read-only security contexts, PSA restricted, scoped RBAC, NetworkPolicy default-deny) is the CKA syllabus and works on day one with nothing installed; it is the floor that holds even when your platform tooling is down.
  • Kyverno adds only what the built-ins genuinely cannot: enforcing readOnlyRootFilesystem, mutating in safe defaults, per-workload hostPath exceptions, generating self-healing default-deny NetworkPolicies, and image signature verification (the one place with no built-in equivalent).
  • The highest-leverage single change is automountServiceAccountToken: false everywhere the API is not needed; the highest-value single hour is listing everything bound to cluster-admin and justifying each one in writing.
  • You will not always prevent the initial compromise, so make it end at the pod: a non-root, read-only, shell-less container with no token and default-deny egress defeats attacks 1 through 5 and stops attack 6 from phoning home.

Six attack paths, and the controls that end each one.

Most Kubernetes security articles hand you a checklist. Enable this admission controller, drop those capabilities, scan your images. All correct, all forgettable, because a checklist never tells you what it is actually costing you when you skip line four.

So let's do it the other way round. I'm going to walk through six attack paths that work on a depressingly large number of real clusters, starting inside a single container and ending with your cloud account. For each one: what the attacker does, then the specific controls that break the chain, then where you would see it happening if you were watching.

I ended up writing this while preparing for the CKA. Somewhere in the RBAC and NetworkPolicy sections I stopped thinking of them as exam objectives and started reading them as attack surface, because that is what they are. A Role isn't trivia to memorise. It is the exact boundary between an attacker who owns one pod and an attacker who owns your cloud account. Once I started reading the curriculum that way it stuck far better than it had when I was drilling kubectl flags.

None of this is novel research. Every technique here is documented, in the CKS curriculum, and in the CIS Kubernetes Benchmark. That is exactly the point. Clusters don't fall over because attackers are inventing new physics. They fall over because the defaults are permissive and nobody went back to tighten them.

Assume the starting position throughout: an attacker has remote code execution inside one container. Not hypothetical. That is an RCE in your app, a compromised dependency, or a CI job running untrusted code. If your security model collapses the moment one container is owned, it isn't a security model.

How each fix is presented. Every attack gets its prevention twice. First built-in only, using nothing but what ships with Kubernetes. Then with Kyverno, where a policy engine buys you something the built-ins genuinely cannot do. That split isn't decoration. If you're sitting the CKA, the built-in track is your exam: no policy engine appears on it, and every command there is something you should be able to type without opening the docs. If you're securing a real cluster, start with the built-in track anyway, because it works on day one with nothing installed, then add Kyverno where the gaps are real.

Scope, so you study the right things: the built-in track covers RBAC, NetworkPolicy, service accounts and security contexts, which are CKA territory. Pod Security Admission, image signing and runtime detection belong to KCSA and CKS.


Attack 1: The container that hands over a root shell and a full toolbox

We start here because this is where the attacker actually lands, and because the fix costs you almost nothing.

The misconfiguration

FROM node:20
WORKDIR /app
COPY . .
RUN npm ci --only=production
CMD ["node", "server.js"]

Nothing looks wrong. Nothing is flagged. And this container runs as root, because root is the Docker default and always has been. If a Dockerfile has no USER instruction, UID 0 is what you get. Combine it with a full distribution base image and a writable filesystem, and you have handed an attacker a workshop.

What the attacker does

Their RCE lands them as root inside a container that ships with curl, wget, bash, apt, python3, a compiler toolchain, and a writable filesystem. Every one of those is a gift.

They pull in tooling. The first move after landing is to fetch what they actually want to run. curl and wget make that one command. A container with no download utility forces the attacker to improvise through whatever your application itself can do, which is dramatically harder.

They write to disk and stay. A writable root filesystem means a crypto miner, a reverse shell, a backdoor in the app directory, or a modified binary that survives as long as the container does. On a writable filesystem, persistence is cat > /app/hook.js.

They install whatever is missing. Root plus a package manager means the container becomes whatever the attacker needs it to be. Scanner, proxy, tunnel endpoint.

Root inside becomes root outside. The container and the host share a kernel. UID 0 in the container is UID 0 to the kernel, and it is UID 0 on any host directory that gets mounted in. That is what makes attack 2 catastrophic instead of merely bad, and it is why kernel escape vulnerabilities matter so much more when your workloads run as root.

Now contrast: the same RCE in a container built FROM scratch, running as UID 10001, with a read-only filesystem and no capabilities. The attacker has code execution inside a process that cannot write anywhere, cannot install anything, has no shell to spawn, no network utility to fetch with, and no privileged operation available. They have your application's own permissions and nothing else. Same vulnerability, radically different outcome.

Prevention, built-in only

This attack is entirely defeated with built-in Kubernetes and a better Dockerfile. No policy engine required.

Fix the Dockerfile first. Cluster settings that contradict the image tend to break at runtime, so make the image non-root at source and the cluster settings become confirmation rather than correction:

FROM node:20-slim
RUN groupadd -r app -g 10001 && \
    useradd -r -u 10001 -g app app
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --only=production
COPY --chown=app:app . .
USER 10001
EXPOSE 8080
CMD ["node", "server.js"]

Three details worth noticing. USER 10001 uses a numeric UID rather than a name, which matters because runAsNonRoot in Kubernetes validates the numeric UID and cannot resolve a username. EXPOSE 8080 avoids ports below 1024, which need NET_BIND_SERVICE. And --chown at copy time means you never need a chown -R layer.

Shrink the base image. Every tool in the image is a tool the attacker inherits. The ladder, roughly in order of how much you remove:

Base Size What an attacker gets
node:20, ubuntu 300MB to 1GB Shell, package manager, curl, compilers, everything
node:20-slim, debian:slim 50 to 200MB Shell, curl, reduced package set
alpine 5 to 50MB Busybox shell, apk, minimal utilities
gcr.io/distroless/* 20 to 100MB Runtime only, no shell, no package manager
scratch Your binary Nothing at all

For Go and Rust, scratch is genuinely achievable and worth the effort, because a static binary needs no operating system underneath it:

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
 
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
USER 10001
ENTRYPOINT ["/app"]

The resulting image contains your binary and a CA bundle. There is no shell to spawn, no curl to download with, no package manager, no /bin at all. An attacker with code execution has your application's logic and nothing else to build on. The trade-off is real and worth stating: no shell also means no kubectl exec to debug with, so lean on ephemeral debug containers (kubectl debug) instead. Distroless is the middle ground when scratch is impractical, which it usually is for interpreted languages.

Then set the security context. These fields are plain pod spec and need nothing installed:

apiVersion: v1
kind: Pod
metadata:
  name: my-app
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: registry.company.io/my-app@sha256:abc123
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: config
          mountPath: /etc/app
          readOnly: true
        - name: tmp
          mountPath: /tmp
      resources:
        limits:
          memory: "512Mi"
          cpu: "500m"
  volumes:
    - name: config
      configMap:
        name: app-config
    - name: tmp
      emptyDir: {}

Line by line, because each one removes a specific capability from the attacker:

  • runAsNonRoot: true rejects the pod at startup if the image would run as UID 0. This is the backstop that catches a rebuilt image that lost its USER line, which is exactly the kind of regression nobody notices.
  • runAsUser: 10001 forces a specific unprivileged UID even if the image says otherwise.
  • allowPrivilegeEscalation: false sets no_new_privs on the process, so no setuid binary can raise privileges. Even if a setuid binary exists in the image, it stops being useful.
  • readOnlyRootFilesystem: true is the one that hurts attackers most and the one people skip. No miner, no backdoor, no dropped payload. They can execute in memory but they cannot leave anything behind.
  • capabilities: drop: ["ALL"] strips the roughly fourteen Linux capabilities a container gets by default. Almost no application needs any of them. If yours truly needs to bind port 80, add back only NET_BIND_SERVICE, or better, listen on 8080 and let the Service do the mapping.
  • seccompProfile: RuntimeDefault applies the container runtime's syscall filter, which blocks a large set of syscalls no normal application makes.
  • resources.limits are a security control, not just a capacity one. An unlimited container is a free crypto miner, and it is also how one compromised pod starves its neighbours.

On the read-only filesystem, since this is where teams push back. The objection is always "but the app writes things." Look at what it actually writes. In the overwhelming majority of cases an application reads its configuration and writes only scratch data: temp files, caches, a PID file, sometimes logs. Configuration should be mounted readOnly: true from a ConfigMap or Secret, because the application has no business modifying its own config at runtime, and if it can, so can an attacker. Everything the app genuinely writes gets a specific emptyDir mount at that specific path. The result: /tmp is writable, /app and /etc/app are not, and an attacker cannot drop a file anywhere that matters. If you cannot enumerate the paths your application writes to, that is worth knowing on its own.

Log to stdout rather than to files. That is standard practice anyway and it removes one more writable mount.

Namespace-wide enforcement. Pod Security Admission makes several of these mandatory rather than optional:

kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted --overwrite

The restricted profile requires runAsNonRoot, allowPrivilegeEscalation: false, dropping ALL capabilities, and a RuntimeDefault seccomp profile.

The built-in limitation: PSA restricted does not require readOnlyRootFilesystem, and it has no opinion at all about how large your base image is or whether it contains a shell. Two of the strongest controls in this section are things nothing built into Kubernetes will enforce for you.

Prevention, with Kyverno

Close exactly that gap. Require the read-only filesystem, and mutate in the safe defaults so a team that forgets still gets them:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: container-hardening
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: require-readonly-rootfs
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        message: "Containers must set readOnlyRootFilesystem: true. Mount an emptyDir at any path the app genuinely writes to."
        pattern:
          spec:
            containers:
              - securityContext:
                  readOnlyRootFilesystem: true
    - name: default-secure-context
      match:
        any:
          - resources:
              kinds: ["Pod"]
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"
                securityContext:
                  +(allowPrivilegeEscalation): false
                  +(capabilities):
                    drop: ["ALL"]

The +() anchor adds the field only when it is absent, so a team that deliberately set something keeps their value and a team that forgot gets the safe default. That distinction is what makes mutation policies survivable in a large cluster.

Where you would see it

Runtime tooling such as Falco or Tetragon firing on a shell spawning inside a container that has no business spawning one, on writes to unexpected paths, and on outbound connections from a workload that never dials out. Image scanning in CI flagging root users and oversized bases. PSA warnings in the API server audit log.


Attack 2: The hostPath mount that owns the node

The misconfiguration

volumes:
  - name: host-root
    hostPath:
      path: /          # the entire node filesystem
volumeMounts:
  - name: host-root
    mountPath: /host   # writable by default

Someone needed to read node logs. Or a monitoring agent's chart shipped this way. Or it was a debugging shortcut in 2023 that nobody removed.

What the attacker does

The pod boundary is now decorative. /host is the node's root filesystem, and writing to it is writing to the node. Note how much worse this is combined with attack 1: root in the container means root on those mounted host directories.

On disk is the interesting part. The kubelet's kubeconfig and client certificate. Every other pod's mounted service account token, because every pod scheduled on this node has its token sitting in the kubelet's directory. Container runtime sockets. Cloud credentials, if this is a managed node. Persistence is trivial when you can write to the host: anything the node executes on a schedule or at boot is available.

The escalation that matters is the kubelet's own identity, which can read secrets referenced by pods on its node and, depending on setup, reach further. The blast radius is not "one pod". It is every workload sharing that node.

This attack is one line of YAML and it ends at node compromise. There is no clever part.

Prevention, built-in only

Block the volume type at admission with PSA. The baseline profile does not restrict hostPath volumes and does not require allowPrivilegeEscalation: false; those are restricted-profile requirements only, so blocking hostPath means using restricted or enforcing it with a separate policy engine like Kyverno or OPA. This catches a lot of teams who enforce baseline and believe they are covered.

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

The restricted profile allows only ConfigMap, CSI, DownwardAPI, EmptyDir, Ephemeral, PersistentVolumeClaim, Projected and Secret volumes, so hostPath and everything outside that list is forbidden.

Where a workload genuinely needs host access (node exporters, CNI, CSI drivers), isolate it: its own namespace, its own exemption, documented and reviewed. Keeping an explicit exemptions registry with namespace, workload, profile required and written justification is the difference between a deliberate exception and a hole nobody remembers.

Migration path, since this is the one people stall on. Start in audit and warn mode cluster-wide. Read the violations. Every team that does this finds problems they didn't know about: containers running as root that never needed to be, host networking on services that only talk cluster-internally, capabilities granted and never used. Fix those, enforce baseline as a floor, then graduate namespaces to restricted as they comply.

The built-in limitation: PSA is all or nothing per namespace. The moment one workload legitimately needs hostPath, the whole namespace drops to baseline, and baseline permits hostPath for everything in it. There is no built-in way to say "this one pod may, nothing else may."

Prevention, with Kyverno

The exception becomes a label on a specific workload instead of a downgrade for a whole namespace:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-hostpath
spec:
  validationFailureAction: Enforce
  background: true
  rules:
    - name: block-hostpath-except-approved
      match:
        any:
          - resources:
              kinds: ["Pod"]
      exclude:
        any:
          - resources:
              namespaces: ["kube-system", "monitoring"]
              selector:
                matchLabels:
                  security.company.io/hostpath-approved: "true"
      validate:
        message: "hostPath volumes are not permitted. Request an exemption if this workload genuinely needs host access."
        pattern:
          spec:
            =(volumes):
              - X(hostPath): "null"

Now the exemption is reviewable in a pull request and the rest of the namespace keeps the higher bar.

Where you would see it

Audit log entries creating pods with hostPath volumes. The pod_security_evaluations_total and pod_security_exemptions_total metrics from the API server tell you whether PSA is doing anything at all. Runtime tooling firing on writes to sensitive host paths from a container.


Attack 3: The service account token you forgot was mounted

The misconfiguration

Nothing. This is the default. Every pod gets a service account token mounted at /var/run/secrets/kubernetes.io/serviceaccount/ unless you opt out, and automountServiceAccountToken defaults to true.

What the attacker does

The first move from any compromised pod is always the same: find out what that token can do. The API server is reachable from every pod by default, the token is right there, and one request answers the question.

On a well-configured cluster the answer is "almost nothing" and they move on. On a typical cluster the answer is: list secrets in this namespace, read configmaps, list pods cluster-wide because someone attached a ClusterRole while debugging and never detached it. Secrets in a namespace usually means database credentials, cloud keys, and third-party API tokens, at which point the attack leaves your cluster entirely and continues in your cloud account or your vendors.

The uncomfortable version is when the token can create pods. See attack 4.

Prevention, built-in only

Turn it off where it isn't needed, which is most places:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-app
automountServiceAccountToken: false

Or on the pod spec, which wins over the service account setting:

spec:
  automountServiceAccountToken: false

If your workload never calls the Kubernetes API, and most application workloads never do, it has no business holding a credential for it.

The command that matters, on the exam and on a real cluster, is the one that tells you what an identity can actually do:

kubectl auth can-i --list \
  --as=system:serviceaccount:production:my-app -n production
 
kubectl auth can-i get secrets \
  --as=system:serviceaccount:production:my-app -n production

Run that against your own workloads before an attacker does. It is the fastest audit in Kubernetes.

For anything talking to a cloud provider, stop putting long-lived cloud keys in secrets at all. Use workload identity federation so the pod exchanges its projected service account token for short-lived cloud credentials scoped to that workload.

The built-in limitation: those manifests only protect service accounts someone remembered to write them on. Every new namespace ships a default service account with automounting on, and Kubernetes gives you no way to change that default.

Prevention, with Kyverno

Mutation flips the default for the whole cluster and makes API access opt-in:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: default-deny-sa-token-automount
spec:
  rules:
    - name: mutate-pod-automount
      match:
        any:
          - resources:
              kinds: ["Pod"]
      preconditions:
        all:
          - key: "{{ request.object.metadata.labels.\"security.company.io/needs-api-access\" || 'false' }}"
            operator: NotEquals
            value: "true"
      mutate:
        patchStrategicMerge:
          spec:
            automountServiceAccountToken: false

This is the single highest-leverage Kyverno policy I know of. It removes the attacker's most reliable second step across the entire cluster in one object, and it fails safe: a workload that genuinely needs the API gets a label and a reviewer, rather than silently keeping a credential because nobody thought about it.

Where you would see it

Audit logs are the whole game here. Alert on authorization.k8s.io/decision: forbid spikes from a service account, which is enumeration. Alert on any service account reading secrets it has never read before. Alert on SelfSubjectRulesReview calls, which is what auth can-i --list actually does and which almost no legitimate workload issues.


Attack 4: RBAC escalation, or the verbs that are secretly admin

The misconfiguration

A Role that looks modest and isn't. Four patterns worth internalising:

create pods in a namespace containing a privileged service account. The attacker creates a pod, sets serviceAccountName to the powerful one, and runs code as that identity. If the namespace holds a service account with cluster-admin, and kube-system style namespaces often do, creating a pod is privilege escalation.

escalate or bind on roles. RBAC normally stops you granting permissions you don't hold. These verbs remove that stop.

Wildcards. resources: ["*"], verbs: ["*"], or apiGroups: ["*"]. Usually enters the cluster as "we'll tighten it later."

pods/exec and pods/attach. Not write access in most people's mental model, but exec into a pod running as a more privileged service account gives you that pod's token.

What the attacker does

They don't guess, they read. The permissions model is queryable by anyone holding a token, so they enumerate roles and bindings, build the graph, and find the shortest path to something that reads secrets or creates pods. This is fully automatable and the tooling is public.

Prevention, built-in only

Audit what you already have. The highest-value hour you can spend on cluster security is listing every subject bound to cluster-admin and asking who each one is. There will be more than you expect, and at least one will be a service account from a Helm chart installed two years ago.

# who is cluster-admin?
kubectl get clusterrolebindings -o json | \
  jq -r '.items[] | select(.roleRef.name=="cluster-admin") |
         "\(.metadata.name): \(.subjects // [] | map(.kind+"/"+.name) | join(", "))"'
 
# which roles contain wildcards?
kubectl get clusterroles -o json | \
  jq -r '.items[] | select(.rules[]?.verbs[]? == "*") | .metadata.name'
 
# what can this identity actually do?
kubectl auth can-i --list --as=system:serviceaccount:default:some-sa

Then write replacements imperatively, which is faster and less error-prone than hand-writing YAML:

kubectl create role pod-reader \
  --verb=get,list,watch --resource=pods -n production
 
kubectl create rolebinding my-app-pod-reader \
  --role=pod-reader \
  --serviceaccount=production:my-app -n production

The rules to hold yourself to:

  • Never grant escalate or bind. There is no legitimate application need.
  • No wildcards in any Role or ClusterRole you wrote yourself.
  • Prefer namespaced Roles to ClusterRoles by default. A ClusterRole should be a deliberate decision.
  • Treat create pods, create deployments, pods/exec and secret access as tiers of the same privilege, because functionally they are.

The built-in limitation: all of the above is detection and discipline. Kubernetes will happily accept a ClusterRole with verbs: ["*"] at three in the morning. RBAC has exactly one built-in guardrail, which is that you cannot grant permissions you don't hold unless you have escalate, and that is it.

Prevention, with Kyverno

PSA has nothing to say about RBAC, because it only looks at pods. Kyverno validates any resource:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: block-dangerous-rbac
spec:
  validationFailureAction: Enforce
  rules:
    - name: no-wildcard-verbs-or-resources
      match:
        any:
          - resources:
              kinds: ["ClusterRole", "Role"]
      validate:
        message: "Wildcards in verbs, resources or apiGroups are not permitted. Enumerate what this role actually needs."
        foreach:
          - list: "request.object.rules"
            deny:
              conditions:
                any:
                  - key: "*"
                    operator: AnyIn
                    value: "{{ element.verbs || `[]` }}"
                  - key: "*"
                    operator: AnyIn
                    value: "{{ element.resources || `[]` }}"
    - name: no-escalate-or-bind
      match:
        any:
          - resources:
              kinds: ["ClusterRole", "Role"]
      validate:
        message: "The escalate and bind verbs defeat RBAC's privilege ceiling and are not permitted."
        foreach:
          - list: "request.object.rules"
            deny:
              conditions:
                any:
                  - key: "{{ element.verbs || `[]` }}"
                    operator: AnyIn
                    value: ["escalate", "bind"]

Run this in Audit first. A chart you installed years ago will almost certainly trip it, and you want that in a report rather than in a failed deploy at 5pm.

Where you would see it

Alert on creation or modification of ClusterRoleBindings, which in a healthy cluster is rare and deliberate. Alert on any binding to cluster-admin. Alert on pods/exec in production namespaces and route it to humans who should know.


Attack 5: Flat networking and the metadata endpoint

The misconfiguration

No NetworkPolicies. Again the default: every pod can reach every other pod and every external endpoint unless something says otherwise.

What the attacker does

Lateral movement. The entire cluster is a reachable network. Internal services that skip authentication because "it's only reachable from inside the cluster" are directly callable. Databases, caches, admin interfaces, metrics endpoints that leak more than anyone intends.

The cloud metadata endpoint. This is the one that hurts. On a managed cluster the node's metadata service is typically reachable from inside pods unless explicitly blocked. Reach it and you get the node's cloud identity and whatever it is permitted to do, which on many clusters is broad enough to read storage buckets, pull from registries, or describe infrastructure. Your Kubernetes compromise just became a cloud account compromise, over plain HTTP, with no exploit involved.

Unrestricted egress, for exfiltration and for pulling in tooling. Note how attack 1 compounds here: if the container has curl and a writable filesystem, open egress is immediately useful. Without them it is much less so.

Prevention, built-in only

NetworkPolicy is core Kubernetes, needs nothing installed beyond a CNI that implements it, and is on the CKA. Default-deny per namespace, then allowlist:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]

Then add back what each workload needs, starting with DNS, which everyone forgets and which is why default-deny appears to "break everything":

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
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

Four things worth committing to memory, because they are where people lose marks and lose afternoons:

  1. An empty podSelector: {} means every pod in the namespace. That is what makes default-deny work.
  2. Policies are additive and there is no deny rule. You cannot write an exception to an allow. If any policy permits a flow, it is permitted.
  3. namespaceSelector and podSelector in the same list item is an AND. As separate items it is an OR. One - changes the meaning entirely.
  4. Egress needs DNS or nothing resolves. Every default-deny incident starts here.

Verify from inside a pod rather than trusting the YAML:

kubectl run netcheck --rm -it --image=nicolaka/netshoot -n production -- \
  curl -s --max-time 3 http://169.254.169.254/latest/meta-data/ || echo "blocked"

Block the metadata endpoint explicitly at the CNI or egress layer, and use your cloud's workload identity mechanism so pods never need the node role in the first place.

One caveat worth stating plainly: NetworkPolicy is enforced by your CNI. If your CNI doesn't implement it, your policies are YAML that does nothing. Check.

The built-in limitation: nothing creates that default-deny policy for you. A new namespace starts wide open and stays that way until a human remembers. On a cluster where teams self-serve namespaces, that is a losing race.

Prevention, with Kyverno

The generate rule closes exactly that gap:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: default-deny-networkpolicy
spec:
  rules:
    - name: generate-default-deny
      match:
        any:
          - resources:
              kinds: ["Namespace"]
      exclude:
        any:
          - resources:
              namespaces: ["kube-system", "kube-public", "kube-node-lease"]
      generate:
        apiVersion: networking.k8s.io/v1
        kind: NetworkPolicy
        name: default-deny-all
        namespace: "{{ request.object.metadata.name }}"
        synchronize: true
        data:
          spec:
            podSelector: {}
            policyTypes: ["Ingress", "Egress"]

synchronize: true is the important flag. If someone deletes the generated policy, Kyverno puts it back, so a namespace cannot quietly return to being wide open.

Note the division of labour, because it is the general pattern: NetworkPolicy still does the enforcing. Kyverno only makes sure the policy exists everywhere it should.

Where you would see it

Egress metering per namespace. Alerts on connections to the metadata IP. Connection-rate anomalies from workloads that shouldn't dial out at all. A web app that suddenly opens thousands of connections is either a bug or an attacker, and both deserve a page.


Attack 6: The supply chain gets there before you do

Last, because it is the one that skips everything above. The attacker doesn't need to break in if your cluster will voluntarily run their code.

The misconfiguration

image: myapp:latest, no digest, no signature verification, no admission control on registries.

What the attacker does

They influence what your cluster pulls. Mutable tags mean the image you reviewed and the image running are related only by convention. A compromised CI credential, a registry account with weak auth, or a typosquatted base image in your Dockerfile all end with your cluster running attacker code at whatever privilege that workload holds. Which brings attack 1 back around: if that workload runs as root with a writable filesystem and a full toolbox, the attacker inherits all of it.

This is not theoretical. In the Anthropic evaluation incidents disclosed in July, a model published a package to a public registry under a name it found referenced in documentation. It was installed and executed on real systems within the hour, including by a security vendor's scanner that installs packages in order to scan them. Installing is executing. Your dependency scanners, license checkers and SBOM tools all run untrusted code by design.

Prevention, built-in only

This is where the built-in track is genuinely thin, and it is worth saying so rather than pretending otherwise.

  • Pin by digest, not tag. image: myapp@sha256:abc123... instead of myapp:v1.2.3. Plain pod spec, and the single most effective built-in control here, because a digest is immutable by definition.
  • Set imagePullPolicy: Always so a cached malicious layer isn't reused indefinitely.
  • Use private registries and imagePullSecrets so the set of pullable images is at least bounded by credentials.
  • Minimal base images, from attack 1. Fewer dependencies means less supply chain to compromise, and scratch has none at all.
  • Scan in the pipeline, before anything reaches the cluster.
  • Run anything that installs-to-inspect in a disposable sandbox with no network and no credentials.

The built-in limitation: Kubernetes has no concept of image trust. It will not check a signature, will not restrict which registries are allowed, and will not stop a tag being used. Digest pinning depends entirely on every author remembering, and nothing rejects the manifest that doesn't. An admission controller is the only answer.

Prevention, with Kyverno

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: supply-chain-controls
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: allowed-registries-only
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        message: "Images must come from an approved registry."
        pattern:
          spec:
            containers:
              - image: "registry.company.io/* | ghcr.io/your-org/*"
    - name: verify-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences:
            - "registry.company.io/*"
          failureAction: Enforce
          mutateDigest: true
          verifyDigest: true
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/your-org/*"
                    issuer: "https://token.actions.githubusercontent.com"

Two things there do quiet, useful work. mutateDigest: true rewrites the tag to the digest verified at admission time, which solves the digest-pinning discipline problem automatically: what you verified is provably what runs, whether or not the author remembered. And the keyless attestor ties the signature to a specific GitHub Actions workflow identity, so "signed" means "built by our pipeline" rather than "signed by someone holding a key."

Where you would see it

Admission denials on unsigned or unpinned images. Alerts on any image pulled from a registry outside your allowlist. Runtime alerts on process execution that doesn't match the image's expected profile.


Built-in or Kyverno? Both, and here's the split

If Kyverno can express all of this, why bother with the built-ins?

Because they fail differently. PSA, RBAC, NetworkPolicy and the security context are part of Kubernetes itself. No webhook to time out, no controller to crash, no certificate to expire. If your cluster is up, they are enforcing. That makes them the right tool for the floor, the rules that must hold even when your platform tooling is having a bad day.

Kyverno is a webhook, which is the source of both its power and its risk. It sees every resource kind, it can mutate and generate as well as validate, it can verify signatures, and it can express conditions the built-ins cannot. It can also, configured carelessly, block every deployment in the cluster when it is unhealthy.

The whole article in one table:

Attack Built-in control What Kyverno adds
1. Root container, full toolbox Non-root Dockerfile, minimal base, securityContext, PSA restricted Enforces readOnlyRootFilesystem, mutates in safe defaults
2. hostPath to node PSA restricted Per-workload exceptions instead of per-namespace downgrades
3. SA token automountServiceAccountToken: false, scoped RBAC Mutation makes it the cluster-wide default
4. RBAC escalation Careful Roles, auth can-i audits Rejects wildcards and escalate/bind at admission
5. Flat network NetworkPolicy default-deny Generates that policy in every new namespace, self-healing
6. Supply chain Digest pinning, private registries Signature verification and registry allowlists, no built-in equivalent

Read the right-hand column and a pattern appears. For attacks 1 through 5 the built-ins do the enforcing and Kyverno makes the control consistent and hard to forget. For attack 6 there is no built-in control at all, and that is the one place a policy engine is a requirement rather than a convenience.

If you're sitting the CKA: the middle column is your syllabus, and the RBAC and NetworkPolicy rows are where the marks are. kubectl create role, kubectl create rolebinding, kubectl auth can-i --list --as=..., and writing a default-deny NetworkPolicy with a DNS exception from memory are all fair game and all fast once drilled. The right-hand column isn't examined until CKS, and even there the emphasis is on concepts rather than Kyverno specifically.

Three operational notes, since this is where teams get hurt:

Run everything in Audit before Enforce. Every policy, no exceptions. Something important is going to violate it.

Think carefully about failure policy. Fail on a security-critical policy is correct in principle and means an unhealthy Kyverno blocks deploys. Ignore keeps you shipping and means an unhealthy Kyverno silently stops enforcing. Neither is universally right, so pick per policy, and alert on Kyverno's own health, because a policy engine nobody monitors is a control you only think you have.

Exclude the namespaces Kyverno itself needs, and be deliberate about kube-system. Locking yourself out of your own control plane is an unglamorous outage to explain.

The chain, and why it matters that it's a chain

Read the six attacks together and the structure is obvious. Attacker code runs in a container, gets root and a toolbox, escapes to the node or picks up a token, escalates through RBAC, moves laterally across a flat network, and exits into your cloud account. Every link is a default or a shortcut, and every link has a control that breaks it.

architecture-beta
    group node(logos:kubernetes)[Kubernetes node]

    service pod(server)[1 Compromised pod RCE] in node
    service host(disk)[2 Node filesystem and kubelet] in node
    service token(server)[3 Service account token] in node
    service rbac(server)[4 RBAC and API server] in node
    service peers(server)[5 Other pods flat network] in node
    service cloud(cloud)[6 Cloud account]

    pod:R --> L:host
    pod:B --> T:token
    pod:L --> R:peers
    token:B --> T:rbac
    host:R --> L:cloud
    rbac:R --> L:cloud
    peers:B --> T:cloud

That is the actual argument. You do not need to prevent the initial compromise, because you will not always manage it. You need the compromise to end at the pod. A non-root read-only container with no shell means attack 1 gives them almost nothing. PSA restricted means attack 2 fails. No mounted token means attack 3 fails. Clean RBAC means attack 4 fails. Default-deny egress means attack 5 fails and attack 6 cannot phone home.

If you do five things

Not fifteen. Five, in this order:

  1. Fix your Dockerfiles and security contexts. Non-root user, minimal base image, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, drop ALL capabilities. Cheapest and highest-leverage work in this article, and it needs no cluster changes at all.
  2. PSA in audit mode cluster-wide today, enforce baseline within a month, restricted in production namespaces after that.
  3. Turn off automountServiceAccountToken everywhere it isn't needed. Without Kyverno that is a one-line edit per service account. With it, one mutation policy covers the cluster and every namespace created after it.
  4. List everything bound to cluster-admin and justify each one in writing. You will delete some the same day.
  5. Default-deny egress in your most sensitive namespace, including the metadata endpoint, and verify it from inside a pod. Then expand outward.

Everything else matters and none of it compensates for missing these five.

What's changed heading into 2027

Two shifts worth naming. First, agentic workloads are now inside clusters at scale: CI agents with merge rights, operators driven by models, MCP servers exposing tools to anything that can reach them. These decide at runtime what to do, which means identity is no longer a proxy for intent. Every control in this article is a blast-radius control, which is precisely why they matter more now, not less. An agent in a distroless container with no shell, no write access and no egress is a very different proposition from an agent with root and curl.

Second, the work has moved left and down at the same time: into the image and into admission, out of the review meeting. A control that is a policy in the cluster and a check in CI is a control that holds. A control that is a paragraph in a wiki has already failed.


Everything here is defensive material drawn from public Kubernetes documentation, the CIS Benchmark and the CKS curriculum. Run it against clusters you own. If you find one of these on a cluster you don't, disclose it responsibly.

If you're on the certification path too, and I'm working through it myself right now, build a throwaway cluster and run each of these six misconfigurations, then fix it. An hour of that beats a week of flashcards, and unlike flashcards it is still useful the next time you're on call.

Chamod Shehanka
Author

Chamod Shehanka

Software Engineer II at Circles building cloud-native systems with Go and Kubernetes. CNCF & CD Foundation Ambassador, Jenkins GSoC mentor, and lead of Kubernetes Sri Lanka & GDG Sri Lanka.

Keep reading