cert-manager-controller DoS via Specially Crafted DNS Response
Description
cert-manager adds certificates and certificate issuers as resource types in Kubernetes clusters, and simplifies the process of obtaining, renewing and using those certificates. In versions from 1.18.0 to before 1.18.5 and from 1.19.0 to before 1.19.3, 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. This issue has been patched in versions 1.18.5 and 1.19.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
cert-manager controller's unencrypted DNS lookups during ACME DNS-01 processing allow attackers to inject crafted responses, causing a panic and denial of service; patched in versions 1.18.5 and 1.19.3.
Vulnerability
Overview
CVE-2026-25518 is a denial-of-service vulnerability in the cert-manager controller, affecting versions 1.18.0 through 1.18.4 and 1.19.0 through 1.19.2. The root cause is that the controller performs DNS lookups without encryption during ACME DNS-01 challenge processing, specifically for zone discovery and propagation self-checks. An attacker who can intercept and modify DNS traffic from the cert-manager-controller pod can inject a crafted entry into the controller's DNS cache, which upon access triggers a panic and crashes the controller [1][2].
Exploitation
Prerequisites
Exploitation requires the attacker to be in a position to intercept or manipulate DNS responses reaching the cert-manager-controller pod. This could be achieved by compromising the network path (e.g., via a malicious DNS resolver or man-in-the-middle attack) or by controlling the authoritative DNS server for the domain being validated. No authentication is needed beyond the ability to influence DNS traffic [2].
Impact
A successful attack causes the cert-manager controller to panic, resulting in a denial of service. This disrupts the controller's ability to issue, renew, or manage TLS certificates, potentially leading to certificate expiry and service outages in the Kubernetes cluster [2].
Mitigation
The issue has been patched in cert-manager versions 1.18.5 and 1.19.3. Users running affected versions should upgrade immediately to the latest patched release. No workarounds are documented; the fix addresses the unencrypted DNS lookups to prevent the injection of malicious responses [1][3].
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/cert-manager/cert-managerGo | >= 1.18.0, < 1.18.5 | 1.18.5 |
github.com/cert-manager/cert-managerGo | >= 1.19.0, < 1.19.3 | 1.19.3 |
Affected products
2- Range: >=1.18.0,<1.18.5 || >=1.19.0,<1.19.3
- cert-manager/cert-managerv5Range: >= 1.18.0, < 1.18.5
Patches
3409fc24e5397Merge pull request #8469 from SgtCoDFish/fqdn-patch
2 files changed · +119 −1
pkg/issuer/acme/dns/util/fqdn_test.go+111 −0 added@@ -0,0 +1,111 @@ +/* +Copyright 2025 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "net" + "testing" + + "github.com/miekg/dns" +) + +// This file contains code adapted from a contribution sent by Oleh Konko as part of GHSA-gx3x-vq4p-mhhv + +func Test_FindZoneByFqdn_NoPanic(t *testing.T) { + zone := "example.com." + fqdn := fmt.Sprintf("findzonebyfqdn.%s", zone) + + // start the dummy DNS server which we'll query + ns, stop := startDNS(t, zone) + defer stop() + + // First call to FindZoneByFqdn to populate the cache + _, err := FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("first call too FindZoneByFqdn failed: %v", err) + } + + // We want to test that the second call does not panic; catch a panic here for prettier log output + + defer func() { + r := recover() + if r != nil { + t.Fatalf("got a panic but none expected: %v", r) + } + }() + + // Second call to FindZoneByFqdn should find the SOA record in the cached response without panic + + _, err = FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("second call too FindZoneByFqdn failed: %v", err) + } +} + +// startDNS starts a local DNS server that responds with a fixed SOA record for any query +func startDNS(t *testing.T, zone string) (addr string, stop func()) { + t.Helper() + + lc := &net.ListenConfig{} + + pc, err := lc.ListenPacket(t.Context(), "udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen udp: %v", err) + } + + h := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + qname := zone + if len(r.Question) > 0 { + qname = r.Question[0].Name + } + + // this is specially crafted: the SOA record exists but is not at Answer[0] + m.Answer = []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + }, + &dns.SOA{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + Mbox: "hostmaster.example.com.", + Serial: 1, + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 60, + }, + } + + _ = w.WriteMsg(m) + }) + + srv := &dns.Server{PacketConn: pc, Handler: h} + go func() { + _ = srv.ActivateAndServe() + }() + + return pc.LocalAddr().String(), func() { + _ = srv.ShutdownContext(t.Context()) + _ = pc.Close() + } +}
pkg/issuer/acme/dns/util/wait.go+8 −1 modified@@ -312,7 +312,14 @@ func FindZoneByFqdn(ctx context.Context, fqdn string, nameservers []string) (str // ensure cachedEntry is not expired if time.Now().Before(cachedEntryItem.ExpiryTime) { logf.FromContext(ctx).V(logf.DebugLevel).Info("Returning cached DNS response", "fqdn", fqdn) - return cachedEntryItem.Response.Answer[0].(*dns.SOA).Hdr.Name, nil + + for _, ans := range cachedEntryItem.Response.Answer { + if soa, ok := ans.(*dns.SOA); ok { + return soa.Hdr.Name, nil + } + } + + return "", fmt.Errorf("cached response has no SOA record") } // Remove expired entry
d4faed26ae12Merge pull request #8468 from SgtCoDFish/release-1.19-fqdn-patch
2 files changed · +119 −1
pkg/issuer/acme/dns/util/fqdn_test.go+111 −0 added@@ -0,0 +1,111 @@ +/* +Copyright 2025 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "net" + "testing" + + "github.com/miekg/dns" +) + +// This file contains code adapted from a contribution sent by Oleh Konko as part of GHSA-gx3x-vq4p-mhhv + +func Test_FindZoneByFqdn_NoPanic(t *testing.T) { + zone := "example.com." + fqdn := fmt.Sprintf("findzonebyfqdn.%s", zone) + + // start the dummy DNS server which we'll query + ns, stop := startDNS(t, zone) + defer stop() + + // First call to FindZoneByFqdn to populate the cache + _, err := FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("first call too FindZoneByFqdn failed: %v", err) + } + + // We want to test that the second call does not panic; catch a panic here for prettier log output + + defer func() { + r := recover() + if r != nil { + t.Fatalf("got a panic but none expected: %v", r) + } + }() + + // Second call to FindZoneByFqdn should find the SOA record in the cached response without panic + + _, err = FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("second call too FindZoneByFqdn failed: %v", err) + } +} + +// startDNS starts a local DNS server that responds with a fixed SOA record for any query +func startDNS(t *testing.T, zone string) (addr string, stop func()) { + t.Helper() + + lc := &net.ListenConfig{} + + pc, err := lc.ListenPacket(t.Context(), "udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen udp: %v", err) + } + + h := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + qname := zone + if len(r.Question) > 0 { + qname = r.Question[0].Name + } + + // this is specially crafted: the SOA record exists but is not at Answer[0] + m.Answer = []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + }, + &dns.SOA{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + Mbox: "hostmaster.example.com.", + Serial: 1, + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 60, + }, + } + + _ = w.WriteMsg(m) + }) + + srv := &dns.Server{PacketConn: pc, Handler: h} + go func() { + _ = srv.ActivateAndServe() + }() + + return pc.LocalAddr().String(), func() { + _ = srv.ShutdownContext(t.Context()) + _ = pc.Close() + } +}
pkg/issuer/acme/dns/util/wait.go+8 −1 modified@@ -312,7 +312,14 @@ func FindZoneByFqdn(ctx context.Context, fqdn string, nameservers []string) (str // ensure cachedEntry is not expired if time.Now().Before(cachedEntryItem.ExpiryTime) { logf.FromContext(ctx).V(logf.DebugLevel).Info("Returning cached DNS response", "fqdn", fqdn) - return cachedEntryItem.Response.Answer[0].(*dns.SOA).Hdr.Name, nil + + for _, ans := range cachedEntryItem.Response.Answer { + if soa, ok := ans.(*dns.SOA); ok { + return soa.Hdr.Name, nil + } + } + + return "", fmt.Errorf("cached response has no SOA record") } // Remove expired entry
9a73a0b38530Merge pull request #8467 from SgtCoDFish/release-1.18-fqdn-patch
2 files changed · +119 −1
pkg/issuer/acme/dns/util/fqdn_test.go+111 −0 added@@ -0,0 +1,111 @@ +/* +Copyright 2025 The cert-manager Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + "net" + "testing" + + "github.com/miekg/dns" +) + +// This file contains code adapted from a contribution sent by Oleh Konko as part of GHSA-gx3x-vq4p-mhhv + +func Test_FindZoneByFqdn_NoPanic(t *testing.T) { + zone := "example.com." + fqdn := fmt.Sprintf("findzonebyfqdn.%s", zone) + + // start the dummy DNS server which we'll query + ns, stop := startDNS(t, zone) + defer stop() + + // First call to FindZoneByFqdn to populate the cache + _, err := FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("first call too FindZoneByFqdn failed: %v", err) + } + + // We want to test that the second call does not panic; catch a panic here for prettier log output + + defer func() { + r := recover() + if r != nil { + t.Fatalf("got a panic but none expected: %v", r) + } + }() + + // Second call to FindZoneByFqdn should find the SOA record in the cached response without panic + + _, err = FindZoneByFqdn(t.Context(), fqdn, []string{ns}) + if err != nil { + t.Fatalf("second call too FindZoneByFqdn failed: %v", err) + } +} + +// startDNS starts a local DNS server that responds with a fixed SOA record for any query +func startDNS(t *testing.T, zone string) (addr string, stop func()) { + t.Helper() + + lc := &net.ListenConfig{} + + pc, err := lc.ListenPacket(t.Context(), "udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen udp: %v", err) + } + + h := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + qname := zone + if len(r.Question) > 0 { + qname = r.Question[0].Name + } + + // this is specially crafted: the SOA record exists but is not at Answer[0] + m.Answer = []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{Name: qname, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + }, + &dns.SOA{ + Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 600}, + Ns: "ns1.example.com.", + Mbox: "hostmaster.example.com.", + Serial: 1, + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 60, + }, + } + + _ = w.WriteMsg(m) + }) + + srv := &dns.Server{PacketConn: pc, Handler: h} + go func() { + _ = srv.ActivateAndServe() + }() + + return pc.LocalAddr().String(), func() { + _ = srv.ShutdownContext(t.Context()) + _ = pc.Close() + } +}
pkg/issuer/acme/dns/util/wait.go+8 −1 modified@@ -312,7 +312,14 @@ func FindZoneByFqdn(ctx context.Context, fqdn string, nameservers []string) (str // ensure cachedEntry is not expired if time.Now().Before(cachedEntryItem.ExpiryTime) { logf.FromContext(ctx).V(logf.DebugLevel).Info("Returning cached DNS response", "fqdn", fqdn) - return cachedEntryItem.Response.Answer[0].(*dns.SOA).Hdr.Name, nil + + for _, ans := range cachedEntryItem.Response.Answer { + if soa, ok := ans.(*dns.SOA); ok { + return soa.Hdr.Name, nil + } + } + + return "", fmt.Errorf("cached response has no SOA record") } // Remove expired entry
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-gx3x-vq4p-mhhvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-25518ghsaADVISORY
- github.com/cert-manager/cert-manager/commit/409fc24e539711a07aae45ed45abbe03dfdad2ccghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/commit/9a73a0b3853035827edd37ac463e4803ba10327dghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/commit/d4faed26ae12115cceb807cdc12507ebc28980e2ghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/pull/8467ghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/pull/8468ghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/pull/8469ghsax_refsource_MISCWEB
- github.com/cert-manager/cert-manager/security/advisories/GHSA-gx3x-vq4p-mhhvghsax_refsource_CONFIRMWEB
- pkg.go.dev/vuln/GO-2026-4399ghsaWEB
News mentions
0No linked articles in our index yet.