Most Kubernetes nodes do not have one DNS. They have a path: pod stub / libc, often systemd-resolved on the host, sometimes NodeLocal DNSCache, then CoreDNS, then upstream. The security question that matters to operators is not which recursive binary won a paper bake-off. It is:
Does hop N on my path accept something hop N+1 would reject?
That is path-consistency. Existing differential fuzzers mostly answer a different question (peer-consistency). This post introduces StackDiff: a lab harness for the path question, with an honest scope about what plain containers can and cannot measure.
This is a methodology post. No findings tables, no CVE counts, no Klein/SAD reproduction, and not a claim that a full Kubernetes path is already instrumented.
Path vs peer
Effective cluster posture is the weakest agreeing hop, not the strongest row in a comparison table.
Threat model
Every profile declares three fields before it counts:
| Field | Meaning |
|---|---|
| Adversary position |
on-path / off-path / malicious-but-trusted-upstream / none (smoke) |
| Adversary capability | What they can manipulate: response content, timing, races, ICMP, and so on |
| Win condition | What success means here, for example one hop caches what another rejects |
Every profile also pins DNSSEC posture (matched vs deliberately_mismatched):
"dnssec_posture": {
"mode": "matched",
"unbound": "off-lab-zone",
"dnsmasq": "off",
"notes": "Matched non-validating so Unbound defaults cannot manufacture a divergence"
}
Unbound validates by default; dnsmasq does not unless configured. An unpinned mismatch manufactures fake Class A “findings” that are really config accidents.
Path-consistency win: disagreement along the path so the cluster’s effective posture becomes the weakest agreeing hop. Smoke profile P-SMOKE-AGREE uses position none and matched non-validating DNSSEC. Divergence on the smoke axes is a harness failure, not a win.
Scope / isolation
Containers share a kernel. That matters for some DNS attacks and not others.
Klein targets the Linux kernel’s shared prandom generator (UDP source ports, IPv6 flow labels, IPv4 ID). The paper (arXiv:2012.07432) shows the attack can run locally across users and across containers on the same host. Shared-kernel Docker does not isolate it. Klein-class profiles are deferred to Firecracker / Kata / full VMs.
SAD DNS (CVE-2020-25705) uses ICMP rate limiting as a side channel. Two kernel changes matter, and they are not the same thing:
- Primary mitigation: Linux randomized the ICMP global rate limiter (
b38e7819cae946e2edf869e604af1e65a5d241c5), mainline 5.10+ with wide LTS backports. - Isolation improvement: msgs-per-sec token state moved from host-wide to per-netns between v6.11 and v6.12 (
b056b4cd9178f7a1d5d57f7b48b073c29729ddaa). That changes whether two containers share the bucket, not whether the side channel was already noised.
| Layer | Plain Docker? | This release |
|---|---|---|
| Application-layer outputs (RCODE, RRset, flags, hang/crash) | Valid | In scope |
Klein-class shared prandom
|
Not isolated | Deferred |
| SAD DNS-class ICMP side channel | Randomization ≠ netns isolation | Deferred until kernel pin |
Ubuntu 24.04 and many enterprise kernels are often still v6.11-shaped for the msgs-per-sec bucket. Always record uname -r and the distro package. Prefer full commit URLs.
Related work
| Tool | Venue | Question |
|---|---|---|
| ResolFuzz (Bushart & Rossow) | ESORICS 2023 | Do recursive resolvers diverge on mutated inputs? |
| ResolverFuzz (Zhang et al.) | USENIX Security 2024 | Query–response fuzzing and differential clustering; real cache-poisoning-class bugs with CVE follow-through |
StackDiff does not replace either tool. It asks a path-consistency question under pinned application-layer profiles.
Architecture (pair mode)
The public pin tests resolver pairs under identical conditions so the oracle can be trusted before anyone treats divergences as security-relevant. A full Kubernetes path (stub → node-local → CoreDNS → upstream) is a different measurement unit. Pair mode asks whether two path-role stand-ins agree; it does not claim the whole chain is instrumented.
Unbound and dnsmasq are those stand-ins: dnsmasq approximates local forwarder/cache behavior (NodeLocal DNSCache / systemd-resolved); Unbound approximates a recursive hop. Smoke keeps Unbound forward-only against the same lab authority as dnsmasq, with matched non-validating DNSSEC.
| Class | Meaning | Rule |
|---|---|---|
| A | Documented / RFC-allowed disagreement | Report; do not hype |
| B | Emergent, security-relevant | Root-cause and disclosure first |
| C | Timing, nondeterminism, harness bug | Fix harness; never publish as finding |
Smoke gate
Both resolvers must return RCODE=NOERROR, the same answer RRset containing 203.0.113.10 for agree.lab.stackdiff. A, and neither side may hang or hard-error. Any mismatch on those axes is Class C / stop. AA/RA are informational only.
| Profile | Resolvers | RCODE | RRset | Hang/crash | Class |
|---|---|---|---|---|---|
| P-SMOKE-AGREE | Unbound ↔ dnsmasq | agree | agree | none | pass |
On this forward-only path, dnsmasq often sets aa=true while Unbound sets aa=false for the same answers. That is expected noise, not a smoke failure.
Lab pin
| Field | Value |
|---|---|
| Repo | dns-stack-diff-harness |
| Tag | blog-dns01-2026-07 |
| Host | Ubuntu 24.04.4 LTS (stackdiff-lab), QEMU/KVM |
uname -r |
6.8.0-134-generic |
| Kernel package |
linux-image-6.8.0-134-generic 6.8.0-134.134
|
| Docker / Compose |
29.1.3 / 2.40.3
|
| Run | smoke-20260718T125325Z |
pass / divergences |
true / 0
|
| Manifest | artifacts/smoke-20260718T125325Z/manifest.json |
| Manifest SHA-256 | 6804627105cd22b51b35e9df1c713f2fe26c5c4d67abb81bfdd2064be99e0560 |
This host is v6.11-shaped for the ICMP msgs-per-sec bucket. Do not claim per-netns isolation here. Cite the pin (uname -r and the committed manifest SHA-256), not main plus whatever Docker Desktop shipped. The published uname_a uses the public alias stackdiff-lab.
Reproduce
git clone https://github.com/kazuru-chidumbwe/dns-stack-diff-harness
cd dns-stack-diff-harness
git checkout blog-dns01-2026-07
# Verify the frozen pin (exact hash of the committed file):
sha256sum artifacts/smoke-20260718T125325Z/manifest.json
# expect: 6804627105cd22b51b35e9df1c713f2fe26c5c4d67abb81bfdd2064be99e0560
docker compose -f deploy/compose.yaml up -d --build
make smoke
# Your new artifacts/smoke-* run will have a different SHA (timestamps differ).
# What should match the pin: pass=true and oracle.divergence_count=0
Needs Docker Compose and Python 3.12+. Lab DNS only.
What this post is not claiming
- New CVEs or “N exploitable divergences”
- Klein under Docker, or SAD DNS without a pinned kernel story
- A replacement for ResolFuzz or ResolverFuzz
- That pair mode already is a full stub → node-local → CoreDNS path
It claims a path-consistency instrument (pair-validated first), an explicit isolation boundary, and a smoke gate that is green on the pin above. The harness is public on that pin; this post is methodology only. If you want harness updates, watch dns-stack-diff-harness on GitHub.

Top comments (0)