From: xylove21 <[email protected]>
To: [email protected]
Cc: [email protected]
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: <[email protected]>
Pre-flight token: ev_7fa7611407c7
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: ev_7fa7611407c7
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,
  raw_min_capped=7.10, cvss_roundup_applied=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 raw_diffs/.

`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",
    "X_VAULT_TOKEN": "FORWARDED — header was \"s.ATTACKER_LEAKED_VAULT_TOKEN\""
  }

================================================================
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: [email protected]

================================================================
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
* raw_diffs/BUGGY-v1.15.5-snippet.txt — buggy v1.15.5 source
* raw_diffs/BUGGY-v1.16.5-snippet.txt — buggy v1.16.5 source
* raw_diffs/BUGGY-v1.17.4-snippet.txt — buggy v1.17.4 source
* raw_diffs/BUGGY-main-snippet.txt — buggy main source
* raw_diffs/BUGGY-setup.go — buggy `pkg/issuer/vault/setup.go` excerpt
* raw_diffs/diff/vault-v1.15-newConfig.txt
* raw_diffs/diff/vault-v1.16-newConfig.txt
* raw_diffs/diff/vault-v1.17-newConfig.txt
* raw_diffs/diff/vault-main-newConfig.txt

================================================================
Email body ends.

Reply via email to