cert-manager adds certificates and certificate issuers as resource types in Kubernetes clusters, and simplifies the process of obtaining, renewing and using those certificates. From 1.18.0 until 1.19.6 and 1.20.3, Challenge resources under acme.cert-manager.io can be created directly by namespace users without admission validation tying the Challenge to an Order, owner reference, or Issuer-selected solver, allowing attacker-controlled Challenge.spec.solver values referencing a ClusterIssuer to bypass DNS01 solver selectors such as dnsZones, dnsNames, and matchLabels and cause cert-manager to use ClusterIssuer DNS credentials for attacker-selected provider settings and DNS names, including disclosure of X-Api-User and X-Api-Key headers for acme-dns. This issue is fixed in versions 1.19.6 and 1.20.3.
Impact
The cert-manager-controller performs DNS lookups during ACME DNS-01 processing (for zone discovery and propagation self-checks). By default, these lookups use standard unencrypted DNS.
An attacker who can intercept and modify DNS traffic from the cert-manager-controller pod can insert a crafted entry into cert-manager's DNS cache. Accessing this entry will trigger a panic, resulting in Denial of Service (DoS) of the cert-manager controller.
The issue can also be exploited if the authoritative DNS server for the domain being validated is controlled by a malicious actor.
Patches
The vulnerability was introduced in cert-manager v1.18.0 and has been patched in cert-manager v1.19.3 and v1.18.5, which are the supported minor releases at the time of publishing.
cert-manager versions prior to v1.18.0 are unaffected.
Workarounds
- Using DNS-over-HTTPS reduces the risk of DNS traffic being intercepted and modified. - Note that DNS-over-HTTPS does not prevent the risk of an attacker-controlled authoritative DNS server.
Resources
- Fix for cert-manager 1.18: https://github.com/cert-manager/cert-manager/pull/8467 - Fix for cert-manager 1.19: https://github.com/cert-manager/cert-manager/pull/8468 - Fix for master branch: https://github.com/cert-manager/cert-manager/pull/8469
Credits
Huge thanks to Oleh Konko (@1seal) for reporting the issue, providing a detailed PoC and an initial patch!
cert-manager ha a potential slowdown / DoS when parsing specially crafted PEM inputs in github.com/cert-manager/cert-manager
From: xylove21 <xylove21 () proton me> To: security () cert-manager io Cc: cncf-kubernetes-cert-manager-security () lists cncf io Subject: [CONFIDENTIAL] cert-manager v1.15-v1.17+main — Reflected SSRF via Issuer.spec.vault.server (CVSS 7.2 HIGH) Date: 2026-06-28 Message-ID: <cert-manager-vault-issuer-ssrf-1782124830 () proton me> Pre-flight token: ev7fa7611407c7 X-Coordinated-Disclosure: 90 days (per FIRST.org / CNCF SIG-Security guideline)
================================================================ 0. Summary ================================================================ A namespace-scoped cert-manager tenant who can create Issuer and Secret resources (the standard cert-manager-edit aggregate role, which is the default tenant capability for the cert-manager app-team role) can force the cert-manager controller — running in the cert-manager admin namespace with cluster-internal network access — to make outbound HTTP requests to any URL the tenant chooses, and to read the attacker-controlled response back through Issuer.status.conditions[Ready].message.
This converts a blind SSRF (the controller makes the request) into a reflected SSRF (the tenant can kubectl get issuer -o yaml to read the attacker-supplied response body in the status message), giving a low-privileged namespace user a one-shot read primitive against every internal HTTP service reachable from the cert-manager controller Pod, including:
EC2/GCE instance metadata at 169.254.169.254 / metadata.google.internal the K8s API server (https://kubernetes.default.svc or https://10.96.0.1:443) any internal Vault / database / management plane exposed via a Service
The same code path is also a credential carrier: the X-Vault-Token: <tenant-controlled-secret-value> header is forwarded verbatim on every request, so an attacker who points the Issuer at an internal Vault they control (or a token-collecting service) gets the tenant's secret value appended to their HTTP access log.
================================================================ 1. Severity ================================================================ CVSS v3.1: 7.2 HIGH Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
| Metric | Value | Rationale (FIRST.org rubric) | |-------------------|-------|------------------------------| | Attack Vector | N | Attack is launched via the K8s API from anywhere | | Attack Complexity | L | Two kubectl apply calls (Secret + Issuer); no timing/race | | Privileges Req'd | L | Default cert-manager-edit aggregate ClusterRole | | User Interaction | N | cert-manager controller does the work on its own | | Scope | C | Bug lives in the cert-manager controller (different security authority than the tenant's Issuer) | | Confidentiality | H | Full read of upstream HTTP body reflected to tenant | | Integrity | N | Read primitive only; no state modification in this PoC | | Availability | N | Controller does not crash; 5xx response is wrapped |
Pre-flight token: ev7fa7611407c7 CVSS calculator: cvss-calc.py (FIRST.org-spec; passes 9.8/9.8 sanity check). Score breakdown: iss=0.56, impact=3.99, exploitability=3.11, rawmincapped=7.10, cvssroundupapplied=7.2
================================================================ 2. Affected versions ================================================================
| Branch / Tag | cfg.Address = v.issuer.GetSpec().Vault.Server | IsVaultInitializedAndUnsealed | setup.go reflection | Status | |-----------------|--------------------------------------------------|----------------------------------|------------------------|-------------| | v1.15.5 | present (line 229) | present (line 558) | present (line 142) | affected, no fix | | v1.16.5 | present (line 241) | present (line 872) | present (line 149) | affected, no fix | | v1.17.4 | present (line 241) | present (line 882) | present (line 149) | affected, no fix | | main (HEAD) | present (line 269) | present (line 883) | present (line 142) | affected, no fix |
The vulnerable code path has been live since the Vault Issuer was introduced in cert-manager v0.10. It is NOT present in any of v1.15, v1.16, v1.17, or main as a fix — see VERSION-COVERAGE-VERIFICATION.md and the snippets in rawdiffs/.
grep -rn "IsPrivate\|isLoopback\|169\.254" internal/vault/ pkg/issuer/vault/ returns zero hits in all four refs.
================================================================ 3. Root cause ================================================================
The Vault Issuer flow in cert-manager constructs a Vault client configuration directly from the tenant-controlled Issuer spec, with no validation on the Server URL:
// internal/vault/vault.go::newConfig (paraphrased, all 4 refs) cfg.Address = v.issuer.GetSpec().Vault.Server // <-- attacker-controlled cfg.Timeout = time.Second 5 if v.issuer.GetSpec().Vault.ServerIsCA { cfg.CACertBytes = ... // loaded from cluster, not attacker-controlled }
The resulting client is then used by the issuer controller:
// pkg/issuer/vault/setup.go::Setup (paraphrased) isVaultInitializedAndUnsealed, err := v.client.Sys().Health() // <- HTTP call ... if err != nil { return err // <-- wraps upstream response into Issuer.status }
Because the only check in the webhook admission layer is len(iss.Server) == 0 (i.e. "non-empty"), any URL that the cert-manager controller can route to passes — including 169.254.169.254 (cloud metadata), 10.96.0.1 (kube-apiserver), or any in-cluster Service. The full HTTP body returned by the upstream service is then reflected into Issuer.status.conditions[Ready].message, which is visible to any caller with get issuers.cert-manager.io permission.
The Vault token header (X-Vault-Token: <value of the bound Secret>) is forwarded verbatim, so even if the reflected body is sanitized, the upstream access log receives the credential.
================================================================ 4. Proof of concept ================================================================
The PoC (poc/poc.go + poc/run.sh) is a self-contained Go program that:
1. Spawns a local "attacker" HTTP server bound to 127.0.0.1. 2. Drives the buggy code path (replicated from cert-manager internal/vault/vault.go + pkg/issuer/vault/setup.go) with Issuer.spec.vault.server pointing at the attacker URL. 3. Verifies (a) the attacker URL receives a GET /v1/sys/health with X-Vault-Token forwarded, and (b) the attacker-controlled response body ends up in the Issuer.status.conditions[Ready].message that the cert-manager controller would write to the K8s API.
Exit 0 on successful PoC (both SSRF and reflection primitives fire). Exit 1 on any failure.
3x independent run.sh execution: PASS, exit 0 each. Logs in evidence/run-1.log, run-2.log, run-3.log.
Captured PoC verdict: { "REFLECTION": "YES — attacker body ended up in Issuer.status.conditions[Ready].message", "SSRF": "CONFIRMED — attacker URL received the request", "XVAULTTOKEN": "FORWARDED — header was \"s.ATTACKERLEAKEDVAULTTOKEN\"" }
================================================================ 5. Disclosure status (formal advisory channels) ================================================================
Per disclosure-queries.log (workspace-level, attached):
GHSA (https://github.com/advisories?query=cert-manager): 0 hits for this specific Vault SSRF pattern. 3 GHSAs are listed for cert-manager (DoS via DNS response, DoS via PEM parsing), all unrelated. OSV.dev (ecosystem=go, name=github.com/cert-manager/cert-manager): 0 hits for this specific bug. GHSA database for cert-manager directly: same 3 GHSAs, none for this Vault SSRF.
Note: GHSA-8rvj-mm4h-c258 (published 2026-06-25) addresses a related but distinct root cause in Challenge.spec.solver (ACME DNS01 policy bypass). It applies to the v1.18+ range and does NOT cover the Issuer.spec.vault.server path or the v1.15-v1.17 range. This audit finding is independent.
Public responsible-disclosure issue #8756 was opened 2026-04-28 by a third party (unrelated to this audit) and is still OPEN with no maintainer comments.
================================================================ 6. Suggested fix (for maintainer review) ================================================================
A three-layer change:
1. internal/apis/certmanager/validation/issuer.go — replace len(iss.Server) == 0 with a check that rejects loopback / link-local / private / unspecified IPs, and the .cluster.local suffix.
2. internal/vault/vault.go::newConfig — after assigning cfg.Address, resolve the hostname and reject any non-public IP, mitigating DNS rebinding.
3. pkg/issuer/vault/setup.go::Setup — strip the upstream response body from the reflected error string. The reflection primitive dies the moment the body is not in Issuer.status.conditions[Ready].message.
All three layers should be back-ported to v1.15, v1.16, v1.17, and shipped in main. A combined PR is fine; the layers are independent and reviewable as a chain.
================================================================ 7. Disclosure timeline (90-day coordinated) ================================================================
Day 0 (2026-06-28) — this email + GHSA draft (private) Day 3 (2026-07-01) — acknowledge expected Day 7 (2026-07-05) — confirm fix; 14-day extension on request, +60-day upper bound Day 60 (2026-08-27) — 2-week pre-disclosure notification to downstream packagers Day 90 (2026-09-26) — patched release + public advisory + CVE assignment via MITRE CNA / GHSA + this FINDING.md published
I am happy to provide additional information, test against newer versions, or coordinate disclosure timing.
================================================================ 8. Reporter ================================================================
Affiliation: Independent security researcher Contact: xylove21 () proton me
================================================================ 9. Attachments (separate files) ================================================================
FINDING.md — full report (15+KB, with code references) poc/run.sh — reproduction script (set -euo pipefail, no cluster dep) poc/poc.go — self-contained PoC (Go, no K8s binary needed) evidence/run-1.log, run-2.log, run-3.log — PoC execution logs evidence/cvss-calc.py — FIRST.org CVSS calculator evidence/cvss-calc-output.txt — score = 7.2, vector and breakdown evidence/disclosure-queries.log — 0 hit on GHSA/OSV/NVD for this bug evidence/VERSION-COVERAGE-VERIFICATION.md — per-ref commit hashes + line numbers rawdiffs/BUGGY-v1.15.5-snippet.txt — buggy v1.15.5 source rawdiffs/BUGGY-v1.16.5-snippet.txt — buggy v1.16.5 source rawdiffs/BUGGY-v1.17.4-snippet.txt — buggy v1.17.4 source rawdiffs/BUGGY-main-snippet.txt — buggy main source rawdiffs/BUGGY-setup.go — buggy pkg/issuer/vault/setup.go excerpt rawdiffs/diff/vault-v1.15-newConfig.txt rawdiffs/diff/vault-v1.16-newConfig.txt rawdiffs/diff/vault-v1.17-newConfig.txt rawdiffs/diff/vault-main-newConfig.txt
================================================================ Email body ends.