GKE Anonymous Reconnaissance
Exposing GKE patch versions and cluster configuration to unauthenticated clients when anonymousAuthenticationConfig is ENABLED
Recent GKE versions ship with anonymousAuthenticationConfig.mode: LIMITED, which restricts anonymous requests to the three health endpoints (/healthz, /livez, /readyz) and rejects everything else at the authentication stage. On clusters where the mode is set to ENABLED, the attack surface opens in two stages. The first stage is automatic when ENABLED is set, and anonymous can read /version plus the health endpoints. The second stage requires an additional misconfiguration, namely a ClusterRoleBinding that names system:unauthenticated or system:anonymous as a subject and references a discovery role such as system:discovery. The second stage exposes the full discovery surface.
The two stages exist because GKE preserves only one default binding to system:unauthenticated, namely system:public-info-viewer, and that role grants only /version plus the health endpoints. The role that grants /api, /apis, and /openapi/* is system:discovery, which by default is bound to system:authenticated only. So flipping the mode without adding another binding leaks the patch version but not the API group inventory.
Info
Anonymous authentication is a Kubernetes-level concept, not GKE-specific. The same
system:public-info-viewerbinding exists in vanilla Kubernetes. GKE’s contribution is theLIMITEDmode, which short-circuits the authentication step so the binding is never reached, and theenableInsecureBindingSystemUnauthenticatedflag, which controls whether the default bindings tosystem:unauthenticatedexist at all.
The two modes
anonymousAuthenticationConfig.mode on GKE accepts two values.
| Mode | Anonymous request behavior |
|---|---|
ENABLED | Anonymous requests are authenticated as user system:anonymous in group system:unauthenticated. RBAC then decides what they can do. With only the default system:public-info-viewer binding, anonymous reaches /healthz, /livez, /readyz, /version. Other endpoints return 403. |
LIMITED | Anonymous requests are accepted only for /healthz, /livez, /readyz. Everything else returns 401 before RBAC is consulted. |
There is no DISABLED value in the GKE flag enum. The closest equivalent is to set the mode to LIMITED and additionally set enableInsecureBindingSystemUnauthenticated to false, which removes the RBAC bindings to the unauthenticated group entirely.
Attack precondition check
The attack requires conditions to align on the target cluster.
For Stage 1 (patch version leak):
anonymousAuthenticationConfig.modeisENABLED.- The API endpoint is reachable from the attacker’s network.
For Stage 2 (broader discovery):
- A ClusterRoleBinding exists from
system:unauthenticated(orsystem:anonymous) to a role granting/api,/apis, or/openapi/*. The default role that grants these issystem:discovery.
From an attacker’s perspective, all three conditions are observable without credentials. The probe in Step 2 answers them by inspecting HTTP response codes.
The attack sequence
The attacker probes the API endpoint without credentials, reads the response codes to determine which stage of misconfiguration the cluster is in, then extracts whichever fingerprint information is reachable.
Step 1: Identify the API endpoint
GKE control plane endpoints live in Google’s public IP space. The TLS certificate served on the endpoint reveals the cluster type. From inside a compromised pod, the in-cluster Service exposes the same API server on kubernetes.default.svc:
echo "$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
10.96.0.1:443
From outside the cluster, the public endpoint can be enumerated from project-wide GCE network scans or recovered from a leaked kubeconfig, terraform state, or screenshot. Once an endpoint is in hand, fetch the certificate to confirm it is a GKE control plane:
echo | openssl s_client -connect 203.0.113.10:443 -showcerts 2>/dev/null \
| openssl x509 -noout -subject -issuer
subject=CN = 203.0.113.10
issuer=CN = aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
The issuer CN is a UUID identifying the GKE-internal cluster CA. That format alone is a strong tell that the endpoint is a GKE control plane.
Step 2: Probe anonymous reachability
Send unauthenticated requests against a small set of well-known endpoints and observe response codes. From inside a compromised pod (no Authorization header, no SA token):
API="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"
for p in /version /healthz /livez /readyz /api /apis /openapi/v2 /openapi/v3 /metrics; do
code=$(curl -sk -o /dev/null -w "%{http_code}" "$API$p")
echo "$code $p"
done
Three outcome shapes are possible. The shape itself classifies the cluster.
mode: LIMITED (the recent GKE default). Authentication is rejected outside the health endpoints.
401 /version
200 /healthz
200 /livez
200 /readyz
401 /api
401 /apis
401 /openapi/v2
401 /openapi/v3
401 /metrics
The 401 on /version is the signal that the cluster is in LIMITED. Nothing else is reachable without credentials.
mode: ENABLED, default bindings only (Stage 1). Anonymous succeeds at authentication but only system:public-info-viewer is bound, which covers /version plus health.
200 /version
200 /healthz
200 /livez
200 /readyz
403 /api
403 /apis
403 /openapi/v2
403 /openapi/v3
403 /metrics
The 403 (not 401) responses on /api, /apis, /openapi/* are the signal that authentication succeeded but the unauthenticated group lacks the binding for those endpoints. The patch version (Step 3) is still reachable.
mode: ENABLED, with a binding to a discovery role (Stage 2). A custom CRB names system:unauthenticated against system:discovery, or an equivalent role covers the discovery nonResourceURLs.
200 /version
200 /healthz
200 /livez
200 /readyz
200 /api
200 /apis
200 /openapi/v2
200 /openapi/v3
403 /metrics
Full discovery surface is reachable. /metrics remains 403 because system:discovery does not include /metrics. Continue with Steps 4 and 5.
Tip
A 200 on
/healthzalone tells the attacker nothing about anonymous mode. Health endpoints respond identically underENABLEDandLIMITED. The 200 on/versionis the oracle for Stage 1 and above. The 200 on/apisis the oracle for Stage 2.
Step 3: Extract version and patch level
The /version endpoint returns the exact Kubernetes server version including the GKE-specific suffix. This is reachable in Stage 1 and Stage 2 alike. Used directly for exploit selection.
curl -sk "$API/version"
{
"major": "1",
"minor": "35",
"emulationMajor": "1",
"emulationMinor": "35",
"minCompatibilityMajor": "1",
"minCompatibilityMinor": "34",
"gitVersion": "v1.35.3-gke.1993000",
"gitCommit": "5cbe1541a0f17ef25efb657e79704019407257b3",
"gitTreeState": "clean",
"buildDate": "2026-03-15T02:00:32Z",
"goVersion": "go1.25.7 X:boringcrypto",
"compiler": "gc",
"platform": "linux/amd64"
}
The -gke.1993000 suffix identifies the cluster as GKE and gives the GKE-specific patch number, which is a more precise version than the upstream Kubernetes minor alone. emulationMinor and minCompatibilityMinor further reveal which upstream versions the control plane is compatible with. This is useful for selecting CVEs that affect the actual binary, not just the advertised minor.
Step 4: Enumerate installed API groups
Requires Stage 2. /apis lists every API group registered on the API server. The presence of non-core groups indicates installed addons or operators.
curl -sk "$API/apis" | jq -r '.groups[].name' | sort -u
admissionregistration.k8s.io
apiextensions.k8s.io
apiregistration.k8s.io
apps
authentication.k8s.io
authorization.k8s.io
auto.gke.io
autoscaling
autoscaling.x-k8s.io
batch
certificates.k8s.io
cloud.google.com
coordination.k8s.io
datalayer.gke.io
discovery.k8s.io
events.k8s.io
flowcontrol.apiserver.k8s.io
hub.gke.io
internal.autoscaling.gke.io
metrics.k8s.io
monitoring.googleapis.com
networking.gke.io
networking.k8s.io
node.gke.io
node.k8s.io
nodemanagement.gke.io
policy
rbac.authorization.k8s.io
resource.k8s.io
scheduling.k8s.io
snapshot.storage.k8s.io
storage.k8s.io
warden.gke.io
The auto.gke.io, datalayer.gke.io, hub.gke.io, internal.autoscaling.gke.io, networking.gke.io, node.gke.io, nodemanagement.gke.io, and warden.gke.io groups together confirm the cluster is GKE. monitoring.googleapis.com indicates Google Managed Prometheus is enabled. cloud.google.com belongs to GKE ingress and BackendConfig support. Additional groups would appear here for Argo, Flux, Kyverno, Crossplane, Istio, or Tekton if installed. The absence of those groups is itself useful information, because it narrows the attacker’s hypothesis about what controllers run in the cluster.
Step 5: Read CRD schemas via OpenAPI
Requires Stage 2. /openapi/v2 returns the OpenAPI v2 schema for every resource the API server knows about, including CRDs from installed addons. The response is several megabytes of JSON. Filter for definitions tied to a specific group:
curl -sk "$API/openapi/v2" \
| jq -r '.definitions | keys[]' \
| grep -E '^com\.googleapis\.monitoring' | sort -u
com.googleapis.monitoring.v1.ClusterNodeMonitoring
com.googleapis.monitoring.v1.ClusterPodMonitoring
com.googleapis.monitoring.v1.ClusterRules
com.googleapis.monitoring.v1.GlobalRules
com.googleapis.monitoring.v1.OperatorConfig
com.googleapis.monitoring.v1.PodMonitoring
com.googleapis.monitoring.v1.Rules
com.googleapis.monitoring.v1alpha1.ClusterPodMonitoring
com.googleapis.monitoring.v1alpha1.ClusterRules
com.googleapis.monitoring.v1alpha1.GlobalRules
com.googleapis.monitoring.v1alpha1.OperatorConfig
com.googleapis.monitoring.v1alpha1.PodMonitoring
com.googleapis.monitoring.v1alpha1.Rules
The presence of com.googleapis.monitoring.v1 and the coexisting v1alpha1 resources confirms Google Managed Prometheus is installed and tells the attacker which API version pairs are served. This is useful for picking webhook bypass paths or for targeting v1alpha1 resources that may not have the same admission validation as their stable counterparts.
The full OpenAPI document also includes property-level schemas, field names, types, validation patterns, and description annotations. These often leak operator version hints, enum value sets that constrain attack payloads, and field deprecation markers that indicate the controller is mid-migration. /openapi/v3 returns a paginated, per-group variant of the same information.
Warning
The OpenAPI document is the same one
kubectlfetches from/openapi/v3at startup for client-side validation. A defender cannot tell from this request alone whether the caller intends benign client-side use or recon, which is why distinguishingsystem:anonymouscallers from authenticated ones in the audit log is the actionable signal.
Lab Setup
To reproduce Stage 1 on a cluster currently in LIMITED, change the mode. This requires roles/container.admin or equivalent. It is a lab setup step, not part of the attack chain.
gcloud container clusters update <cluster-name> \
--zone=<zone> \
--anonymous-authentication-config=ENABLED
After the update propagates (typically under a minute), the probe from Step 2 returns the Stage 1 shape, with 200 on /version and health and 403 on the rest.
To reproduce Stage 2, additionally bind system:unauthenticated to a discovery role. This is the misconfiguration the attack chain depends on.
kubectl create clusterrolebinding lab-anon-discovery \
--clusterrole=system:discovery \
--group=system:unauthenticated
The probe from Step 2 then returns the Stage 2 shape, with 200 across all discovery endpoints. Remove the binding and revert the mode when done.
kubectl delete clusterrolebinding lab-anon-discovery
gcloud container clusters update <cluster-name> \
--zone=<zone> \
--anonymous-authentication-config=LIMITED
Validation against a LIMITED cluster
The probes above were validated against a stock GKE 1.35.3 cluster created via the default gcloud container clusters create flow, with mode: LIMITED and enableInsecureBindingSystemUnauthenticated: true. Cycling through LIMITED, ENABLED (Stage 1), and ENABLED plus the custom binding (Stage 2) produced the three probe shapes shown in Step 2.
kubectl get clusterrolebinding system:public-info-viewer -o yaml
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:public-info-viewer
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: system:authenticated
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: system:unauthenticated
The binding includes system:unauthenticated as a subject because enableInsecureBindingSystemUnauthenticated: true preserves it. Under LIMITED, requests authenticated as anonymous are rejected before this binding is consulted. Under ENABLED, this binding alone grants only /version plus health, which is why Stage 1 does not include /api or /apis.
kubectl get clusterrole system:public-info-viewer -o yaml
rules:
- nonResourceURLs:
- /healthz
- /livez
- /readyz
- /version
- /version/
verbs:
- get
The system:discovery ClusterRole grants /api, /apis, /openapi, /openapi/* in addition to the health and version paths, but by default it is bound only to system:authenticated, not to system:unauthenticated. A custom binding from system:unauthenticated to system:discovery is what completes the chain.
Where ENABLED still shows up
Three cluster classes carry this configuration today.
- Clusters created before the
LIMITEDdefault rollout. The field did not exist, and behavior matchedENABLED. GKE does not retroactively change the mode on upgrade, so operators must explicitly update. - Clusters where
mode: ENABLEDwas set deliberately to support an older controller, an external auth proxy, or a custom audit tool that relies on anonymous discovery. - Clusters provisioned by IaC modules that hardcode
ENABLEDbecause the module was written before the default changed. The cluster looks compliant ongcloud container clusters describeuntil someone reads the mode field specifically.
Stage 2 is rarer in the wild than Stage 1, because adding a CRB to system:unauthenticated is an explicit RBAC change that an operator has to write. It appears most often when a team adapts a sample from an older Kubernetes tutorial that assumed system:public-info-viewer already covered the discovery roles, or when an unauthenticated tool (an external dashboard, a legacy CI probe) needs to read /apis without provisioning a service account.
Probing cost is one HTTP request per candidate endpoint. The response code shape distinguishes the three cases in a single probe, so an attacker can sort a list of candidate clusters into LIMITED, Stage 1, and Stage 2 cheaply.
Impact
Reveals the exact Kubernetes patch version to unauthenticated callers on the public API endpoint when anonymousAuthenticationConfig is set to ENABLED. If a binding from system:unauthenticated to a discovery role is also present, the surface extends to installed API groups and addon CRD schemas, supplying the fingerprint an attacker needs to pick targeted exploits without holding any credential
Mitigation
- Set anonymousAuthenticationConfig.mode to
LIMITED(default on recent GKE versions).LIMITEDrejects anonymous requests at the authentication stage for everything except/healthz,/livez, and/readyz, regardless of which RBAC bindings exist forsystem:unauthenticated - Set enableInsecureBindingSystemUnauthenticated to
false. The legacysystem:public-info-viewerClusterRoleBinding tosystem:unauthenticatedis removed at cluster creation, leaving anonymous requests with no permissions even if the mode is later flipped toENABLED - Audit ClusterRoleBindings that name
system:unauthenticatedorsystem:anonymousas a subject. The default GKE setup has onlysystem:public-info-viewer. Any custom binding to a broader role likesystem:discoveryopens the full discovery surface underENABLED - Configure masterAuthorizedNetworks with the smallest set of source CIDRs that need to reach the control plane. The default behavior of leaving the public endpoint reachable from
0.0.0.0/0is the precondition that makes pre-auth recon possible from anywhere - Prefer a private cluster with the public endpoint disabled. Anonymous discovery from the public internet becomes impossible regardless of mode
- Alert on changes to anonymousAuthenticationConfig via Cloud Asset Inventory feeds. A flip from
LIMITEDtoENABLEDis rarely intentional outside of explicit lab work