
Security
Internal Penetration Testing: Proving One Pod Stays Contained
Internal penetration testing on Kubernetes: start from a shell in one pod and prove NetworkPolicy, RBAC and Pod Security stop lateral movement.
Most internal penetration testing guidance describes a corporate network: Active Directory, file shares, a phished laptop. On a cluster the insider is a container, and it walks different paths.
This article scopes that test: four lateral-movement paths out of one pod, the control each should hit, where the Kubernetes documentation says that control stops, and the open-source tools for each boundary. The snippets are non-destructive.
Internal testing starts with the attacker already inside
NIST SP 800-115, the Technical Guide to Information Security Testing and Assessment published in September 2008, separates the two viewpoints. External security testing is conducted from outside the organization's security perimeter. For internal testing, assessors work from the internal network and assume the identity of a trusted insider or an attacker who has penetrated the perimeter defenses.
On a cluster, the attacker past the perimeter is a process in a pod: a backdoored dependency, an exploited handler, a forgotten debug container. The ServiceAccount token is placed into each container's filesystem at /var/run/secrets/kubernetes.io/serviceaccount/token, and the API server answers at kubernetes.default.svc.
That makes this the inside-out complement to external penetration testing of a self-hosted perimeter, which asks what the internet reaches before any foothold exists. Two other axes are separate from internal versus external. Whitebox versus blackbox is about what the tester is told, not where they stand: an internal test can be either. And physical penetration testing — badges, doors, server rooms — is a separate engagement, not covered here.
Four paths out of one pod
Scope the test as four paths, each paired with the control meant to stop it. Then a finding is not "the tester reached X", it is "the control expected to stop X did not".
- The API, via the mounted token. Control: RBAC on the pod's ServiceAccount. MITRE ATT&CK catalogues gathering credentials through container and Kubernetes APIs as T1552.007, Container API.
- A Secret in another namespace. Control: RBAC again, but tested separately, because the indirect routes to a Secret are not the obvious ones (covered below).
- East-west network to other namespaces' pods. Control: NetworkPolicy. Microsoft's Threat Matrix for Kubernetes lists cluster internal networking as lateral movement, because pod-to-pod traffic is allowed by default and one compromised container can reach others.
- The node. Controls: Pod Security Admission, which decides whether a privileged, hostPath or host-namespace pod can be created, and the kubelet's own authentication and authorization on port 10250. MITRE catalogues breaking out of a container to the underlying host as T1611, Escape to Host.
Rules of engagement and the foothold pod
Write the stop conditions first: namespaces and paths in scope, the one person who can halt the test, and an expiry. SP 800-115 ships a Rules of Engagement template as Appendix B. Read-only probes like the ones below are reasonable against a production cluster with written authorization. Exploit modules — container escape, token replay against other workloads — belong in a dedicated test cluster built from the same manifests, or nowhere.
Then decide the knowledge axis: a whitebox test hands over the manifests, RoleBindings and NetworkPolicies; a blackbox one hands over only the shell. The foothold must sit where a real compromise would: in the tenant namespace, under that namespace's policies, holding the token of the workload being simulated.
apiVersion: v1
kind: Namespace
metadata:
name: ipt-tenant-a
labels:
pod-security.kubernetes.io/enforce: baseline
pentest.example.com/test-asset: "true"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ipt-foothold
namespace: ipt-tenant-a
---
apiVersion: v1
kind: Pod
metadata:
name: ipt-foothold
namespace: ipt-tenant-a
labels:
app.kubernetes.io/name: ipt-foothold
pentest.example.com/test-asset: "true"
annotations:
pentest.example.com/ticket: "SEC-0000"
spec:
serviceAccountName: ipt-foothold
activeDeadlineSeconds: 86400
containers:
- name: shell
image: nicolaka/netshoot:v0.14
command: ["sleep", "86400"]
env:
- name: NODE_IP
valueFrom:
fieldRef:
fieldPath: status.hostIP
resources:
limits:
cpu: 200m
memory: 128MiactiveDeadlineSeconds is the expiry written into the asset itself, and the label makes every artefact findable afterwards. Bound ServiceAccount tokens are invalidated when the pod they are mounted into is deleted, so deleting the foothold retires the credential the test was holding; the deadline alone only stops the containers.
Path one and two: what the token can do
Start by asking the API server what the token may do in its own namespace. kubectl auth can-i --list is a SelfSubjectRulesReview, which returns the set of actions a user can perform within a namespace. Treat that list as a to-do list, not as evidence. The API reference says the returned list may be incomplete depending on the server's authorization mode and any errors during evaluation, and that it should not be used to drive authorization decisions. A verb missing from the list is not a deny; the evidence is a single-verb can-i check, or the attempted call.
So record status.incomplete and any evaluation error alongside the list, and do not run --list with --all-namespaces: the review is namespace-scoped, so check each namespace you care about explicitly.
The Secret path needs its own checks because the RBAC good-practices page documents three routes that do not look like "get secrets":
listandwatchreveal contents. Per the documentation, list and watch access also effectively allow users to reveal Secret contents, not just names.- Creating a workload implies every Secret in the namespace. Permission to create Pods, or anything that manages Pods, implicitly grants access to that namespace's Secrets, ConfigMaps and PersistentVolumes, and — because a Pod can run as any ServiceAccount there — the API rights of every ServiceAccount in it. The Secret concept page says the same: anyone authorized to create a Pod in a namespace can read any Secret in it, including through a Deployment.
createonserviceaccounts/tokenmints credentials. Users with that right can issue tokens for existing ServiceAccounts.
#!/usr/bin/env bash
set -uo pipefail
SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN="$(cat "$SA/token")"
CA="$SA/ca.crt"
NS="$(cat "$SA/namespace")"
API="https://kubernetes.default.svc"
TARGET_NS="${TARGET_NS:-payments}"
OUT="${OUT:-/tmp/api-probe.tsv}"
record() { printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$1" "$2" | tee -a "$OUT"; }
api() {
local args=(-sS --max-time 10 --cacert "$CA" -H "Authorization: Bearer $TOKEN" -X "$1")
if [ -n "${3:-}" ]; then args+=(-H 'Content-Type: application/json' --data "$3"); fi
curl "${args[@]}" "$API$2"
}
# 1. The to-do list. Incomplete is recorded, never ignored.
rules="$(api POST /apis/authorization.k8s.io/v1/selfsubjectrulesreviews \
"{\"apiVersion\":\"authorization.k8s.io/v1\",\"kind\":\"SelfSubjectRulesReview\",\"spec\":{\"namespace\":\"$NS\"}}")"
printf '%s\n' "$rules" > /tmp/rules-review.json
printf '%s' "$rules" | grep -q '"incomplete": *true' && record rules-review "INCOMPLETE: absence proves nothing"
printf '%s' "$rules" | grep -q '"evaluationError"' && record rules-review "evaluation error present"
# 2. One verb at a time: this is the evidence.
can() { # verb resource namespace [subresource]
local body resp scope
body="{\"apiVersion\":\"authorization.k8s.io/v1\",\"kind\":\"SelfSubjectAccessReview\",\"spec\":{\"resourceAttributes\":{\"verb\":\"$1\",\"resource\":\"$2\",\"namespace\":\"$3\",\"subresource\":\"${4:-}\"}}}"
resp="$(api POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews "$body")"
if printf '%s' "$resp" | grep -q '"allowed": *true'; then verdict=ALLOWED
elif printf '%s' "$resp" | grep -q '"allowed": *false'; then verdict=denied
else verdict="ERROR: no review returned"; fi
if [ -n "$3" ]; then scope="-n $3"; else scope="(cluster-scoped)"; fi
record "can-i $1 $2${4:+/$4} $scope" "$verdict"
}
for v in get list watch; do can "$v" secrets "$TARGET_NS"; done
can create pods "$NS"
can create pods "$TARGET_NS"
can create serviceaccounts "$TARGET_NS" token # checked here, never exercised
can get nodes "" proxy
# 3. The attempt. Status code only; the body is discarded.
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 --cacert "$CA" \
-H "Authorization: Bearer $TOKEN" "$API/api/v1/namespaces/$TARGET_NS/secrets?limit=1")"
record "list secrets -n $TARGET_NS (attempt)" "HTTP $code"
# 4. Would admission accept a node-reaching pod here? dryRun=All persists nothing.
pod='{"apiVersion":"v1","kind":"Pod","metadata":{"generateName":"ipt-dryrun-"},"spec":{"hostPID":true,"containers":[{"name":"c","image":"busybox:1.36","securityContext":{"privileged":true},"volumeMounts":[{"name":"h","mountPath":"/host"}]}],"volumes":[{"name":"h","hostPath":{"path":"/"}}]}}'
for ns in "$NS" "$TARGET_NS"; do
resp="$(api POST "/api/v1/namespaces/$ns/pods?dryRun=All" "$pod")"
if printf '%s' "$resp" | grep -q '"kind": *"Pod"'; then verdict="ADMITTED (dry-run)"
else verdict="$(printf '%s' "$resp" | grep -o '"message": *"[^"]*"' | head -n 1)"; fi
record "dry-run privileged hostPath pod -n $ns" "${verdict:-no response}"
doneRead section 4 as two questions. A forbidden refusal from RBAC means the identity cannot create pods there; a PodSecurity refusal means it can, and admission caught the pod's shape, a weaker result. The automated penetration testing piece covers the nodes/proxy line and scheduled regressions. The script samples create pods; Deployments, Jobs and other pod controllers need the same review. Access to the nodes/proxy subresource is command execution in every pod on the node, outside audit logging and admission control.
Path three: east-west, and where NetworkPolicy stops
By default a pod is non-isolated in both directions: all inbound and outbound connections are allowed until a NetworkPolicy selects it. The east-west probe checks whether that default has actually been replaced. It runs from the foothold against a matrix of targets with an expected result per row, so the output is a diff, not a port list.
#!/usr/bin/env bash
set -uo pipefail
NODE_IP="${NODE_IP:?set from status.hostIP in the foothold manifest}"
# expect target port
MATRIX='
blocked payments-api.payments.svc.cluster.local 8080
blocked postgres.payments.svc.cluster.local 5432
blocked prometheus.monitoring.svc.cluster.local 9090
allowed kube-dns.kube-system.svc.cluster.local 53
blocked NODE 10250
'
printf '%-8s %-8s %s\n' EXPECT OBSERVED TARGET
printf '%s\n' "$MATRIX" | while read -r expect host port; do
[ -z "${expect:-}" ] && continue
[ "$host" = NODE ] && host="$NODE_IP"
if nc -z -w 3 "$host" "$port" 2>/dev/null; then got=allowed; else got=blocked; fi
flag=""
[ "$got" != "$expect" ] && flag=" <-- FINDING"
printf '%-8s %-8s %s:%s%s\n' "$expect" "$got" "$host" "$port" "$flag"
done
code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "https://$NODE_IP:10250/pods")"
echo "kubelet /pods, no credentials: HTTP $code (000 = unreachable, 401/403 = refused, 200 = open)"The control under test is usually a default-deny egress policy with an explicit DNS allow, like the one below. Note what it does to path one: the API server is no longer reachable unless another rule allows it, so the token test may fail at the network layer before RBAC is ever consulted. Record which control stopped the attempt; "blocked" and "forbidden" are different findings. nc also reports a closed or refused port as blocked, so confirm the target listens before writing a row up as policy.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: ipt-tenant-a
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: 53The documented edges decide what a clean matrix proves. The NetworkPolicy API reference allows traffic to a pod if the source is the pod's local node, unconditionally; no policy can deny ingress from the resident node. Egress is different: from a selected pod, outgoing traffic is allowed only if it matches an egress rule. So a blocked result from the foothold to its node's port 10250 is a real egress result.
Three further limits, each covered in the default-deny network policy audit. The concepts page notes, as an exception on ipBlock rules, that traffic to and from the node where a Pod runs is always allowed. NetworkPolicy behaviour for hostNetwork pods is undefined, and the most common implementation treats their traffic as node traffic. And a NetworkPolicy resource has no effect without a network plugin that implements it — while the API offers no logging of blocked or accepted connections, so, short of CNI-specific flow logs such as Hubble or Calico's, the probe output is the record.
Those limits are statements about the NetworkPolicy API, not about every CNI; some plugins add host-level policy of their own. If yours does, test it as a fifth control. Upstream, sig-network's cluster-scoped ClusterNetworkPolicy is the API under active development: network-policy-api v0.3.0 (2026-11-06) made v1beta1 its storage version, still serving v1alpha2 for backward compatibility.
Path four: from pod to node
Test the node path as two independent questions, because they fail independently.
Through the API. Pod Security Standards baseline disallows privileged containers, host namespaces and hostPath volumes. The dry-run in api-probe.sh asks admission directly, persisting nothing. But Pod Security Admission restrictions are applied at the namespace level when pods are created. A baseline refusal in the foothold namespace says nothing about another namespace that does not enforce it. If the single-verb check shows create pods allowed anywhere, the dry-run against that namespace is the result that matters, and a namespace with no enforce label is the finding.
Directly, to the kubelet. The kubelet binary's flag defaults are --anonymous-auth=true and --authorization-mode=AlwaysAllow; both flags are deprecated in favour of the config file, and an installer or config file may set something else. So the reachability probe ends with one unauthenticated request to /pods. An HTTP 200 there is a node-level finding regardless of how well RBAC was written. The external perimeter piece shows the KubeletConfiguration fields that close it.
Container escape through a kernel or runtime flaw is the remaining node route this article deliberately does not script. Whether a container boundary should be the line at all is a separate design question, argued in containers versus VMs as isolation boundaries.
Peirates, CDK and kubeletctl: which boundary each attacks
Pick tools by path. The three maintained open-source options overlap. Two can port-scan east-west, but none checks observed reachability against intended; only the reachability matrix records that. Versions are as of November 2026.
- [Peirates](https://github.com/inguardians/peirates) (InGuardians, GPL-2.0, v1.1.35 released 2026-11-04). A Kubernetes penetration tool for privilege escalation and pivoting, run from a container on Kubernetes or from a node. It automates stealing and collecting ServiceAccount tokens and Secrets — paths one and two — and its v1.1.35 menu also harvests tokens from open kubelets, plants node persistence through hostPath pods, attempts hostPID breakouts, and scans TCP ports from the pod (
tcpscan). - [CDK](https://github.com/cdk-team/CDK) (Apache-2.0, v1.5.6 released 2026-02-23). A zero-dependency container penetration toolkit built to run in slimmed containers, with an evaluate mode and exploit modules aimed at container escape and cluster takeover. Its evaluation overlaps API discovery on path one;
k8s-secret-dumpattemptsGET /api/v1/secretson path two and prints the body, though itsk8s_secrets.jsonwrite is gated on aselfLinkstring current API responses omit;cdk probeis a TCP port scan on path three. - [kubeletctl](https://github.com/cyberark/kubeletctl) (CyberArk, Apache-2.0, v1.13 released 2025-01-12). A client for the kubelet API, covering documented and undocumented endpoints. Maintained but quiet: the last release is nearly two years old.
- [kube-hunter](https://github.com/aquasecurity/kube-hunter)
--pod(last release v0.6.8, 2022-05-18). Its pod mode was designed to show exposure if an application pod is compromised, but the project is no longer under active development.
Destructiveness sets the rules of engagement. Peirates' attack-pod-hostpath-mount creates a Pod that hostPath-mounts the node's root filesystem at /root, appends a recurring reverse-shell line to the node's /etc/crontab through that mount, then deletes the Pod. The crontab entry outlives the Pod object, but create on pods and Pod Security admission still gate the module. Its exec-via-kubelet first needs get nodes for the current identity, then sends no bearer token: it asks each node's kubelet, unauthenticated on ports 10255 and 10250, to read the mounted ServiceAccount token of running containers. Its nodes-proxy-exec (menu 34) is operator-driven: it lists stored tokens whose nodes/proxy reviews succeeded, the operator picks one, and it sends that as the bearer token to run one command (default id) through the kubelet over a WebSocket GET. kubeletctl also runs kubelet commands. CDK's k8s-secret-dump is an active Secret read. Anything that creates, execs or escapes belongs in the test cluster; keep raw output as evidence.
If runtime detection is deployed, it should alert on foothold activity: a token-file read, a burst of access reviews, a connection to 10250. Falco and Tetragon can be graded on the same run; a containment test that nobody noticed is two findings.
Reporting containment, failure modes, exit ramps and the long game
Write each result as a record that a second tester can re-run: the path, the foothold identity, the control expected to stop it, the attempt, the observed result, and where the raw evidence lives. The evidence-first report covers the document around it.
{
"id": "IPT-2026-11-004",
"path": "node-via-api",
"foothold": { "namespace": "ipt-tenant-a", "serviceAccount": "ipt-foothold" },
"control_expected": "Pod Security Admission enforce=baseline on namespace batch-jobs",
"attempt": "POST /api/v1/namespaces/batch-jobs/pods?dryRun=All (privileged, hostPID, hostPath /)",
"observed": "ADMITTED (dry-run): namespace carries no pod-security enforce label",
"rbac_precondition": "can-i create pods -n batch-jobs: ALLOWED",
"evidence": "evidence/2026-11-13/api-probe.tsv#L14",
"severity": "high",
"retest_after": ["RBAC change", "PSA label change", "CNI change"],
"retest_date": "2026-12-13"
}The example is illustrative. The failure modes that make a clean internal test lie are:
- Testing from a pod with no token. If the foothold sets
automountServiceAccountToken: falsebut the real workload does not, paths one and two were never tested. - Reading
can-i --listas proof of a deny. The list may be incomplete. Only the single-verb review or the attempt is evidence. - Testing pod-to-pod only. Ingress from the pod's own node is outside NetworkPolicy's reach; hostNetwork pods may be too.
- A CNI that does not enforce policy. Every probe passes against policies that do nothing — unless the matrix includes a row expected to be blocked and checks it is.
- Testing one namespace's admission and generalising. Pod Security Admission is per namespace.
Every check uses an upstream Kubernetes API — access reviews, server-side dry-run, plain TCP — and the tools are open source under GPL-2.0 and Apache-2.0. No vendor scanner is involved, so it survives a change of CNI, distribution or provider. Identity-based controls such as SPIFFE/SPIRE workload identity and external secret stores add boundaries; they become further rows in the same matrix, not a replacement for it.
The long game is cadence. Containment is a property the cluster had on the day of the test; every RBAC change, unlabelled namespace or CNI upgrade re-opens it. Version the foothold manifest, both probes and the expected matrix beside the policies they test, and re-run them when those change.
§FAQ/Common questions
Frequently asked
What is internal penetration testing?
Testing in which assessors work from the internal network and assume the identity of a trusted insider or an attacker who has penetrated the perimeter, per NIST SP 800-115. On Kubernetes, that starting point is a shell in one pod holding its mounted ServiceAccount token.
How is an internal penetration test different from a Kubernetes config audit?
A config audit proves that RBAC, NetworkPolicy and Pod Security settings exist and are applied. An internal test starts from a foothold pod and attempts to reach the API, another namespace's Secrets, other namespaces' pods and the node, proving whether those controls hold.
Can NetworkPolicy block traffic from a node to its own pods?
No. The NetworkPolicy API always allows ingress to a pod from its local node. Egress from a selected pod to its node is allowed only if an egress rule matches, so that direction can be blocked. hostNetwork pod behaviour is undefined.
Is kubectl auth can-i --list enough to prove a ServiceAccount cannot read Secrets?
No. The list returned by SelfSubjectRulesReview may be incomplete depending on the authorization mode and evaluation errors. Check each verb with a single-verb can-i or attempt the call, and remember that pod-create rights in a namespace imply its Secrets.
Which tools should an internal Kubernetes pentest use?
Peirates for token, Secret and node paths, CDK for in-container evaluation and escape, kubeletctl for the kubelet API. Peirates and CDK can port-scan east-west; only a probe script with an expected-result matrix records intended versus observed reachability.
Further reading
- External Penetration Testing: Scope the Perimeter You Built
- Default Deny, Actually: Auditing Kubernetes Network Policy
- Automated Penetration Testing: What a Script Can Prove
- Falco vs Tetragon: Why Kernel-Time Detection Needs Both
- Vulnerability Assessment and Penetration Testing: The Report
- Containers vs VMs: Which Boundary Are You Trusting?
- SPIFFE/SPIRE on Kubernetes: Zero-Trust Workload Identity
- Kubernetes Secrets Are Still Broken: ESO Over Vault
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.