Pi-hole DoH / DoT Bypass Defense¶
The Problem¶
A LAN-wide ad/tracker/threat-intel blocklist at the DNS layer is only effective if clients actually use the DNS server doing the blocking. Three classes of client routinely bypass LAN DNS:
- Modern browsers with built-in DNS-over-HTTPS (DoH). Chrome, Firefox, Safari, Edge all ship with DoH either on by default or a one-toggle opt-in. When enabled, the browser sends DNS queries over HTTPS directly to Cloudflare / Google / Quad9 / NextDNS, bypassing the OS resolver and every DNS-layer blocklist on the network.
- IoT / smart devices with hardcoded public DNS. Chromecast, Google Home, Nest, many Samsung TVs, some Amazon devices include hardcoded
8.8.8.8or1.1.1.1. They ignore DHCP-pushed DNS entirely. - Applications using DNS-over-TLS (DoT). Rarer than DoH but used by some privacy-focused apps and occasional malware C2 — hits Cloudflare / Quad9 on port 853 instead of 53.
Without defense, a compromised IoT or a telemetry-hungry app can exfil DNS traffic freely even though Pi-hole is "running."
What This Guide Covers¶
- How Pi-hole's
blocking.mode = NULLstructurally prevents the most common fallback-resolver bypass. - Why the
HaGeZi doh-vpn-proxy-bypassadlist is the single highest-leverage defense against browser DoH. - What still leaks and how to assess whether the router-level firewall is worth the effort for your threat model.
Pi-hole's block mode matters¶
Pi-hole v6's default dns.blocking.mode is NULL. A blocked domain resolves to 0.0.0.0 — a valid DNS answer, not an NXDOMAIN. Verify on your host:
Why this matters: multi-resolver OSes (macOS, iOS, Windows) only consult fallback resolvers on a failure (timeout, SERVFAIL). A valid NULL answer short-circuits that — the client accepts the 0.0.0.0, tries to connect, fails at TCP, and never retries DNS. Even if /etc/resolv.conf has 1.1.1.1 as a secondary, it's never queried.
If you've set blocking mode to NXDOMAIN, clients will fall back — and every telemetry domain on every adlist becomes bypassable through whatever secondary resolver the OS is configured with. Leave it at NULL.
Check:
HaGeZi DoH/VPN/Proxy Bypass — the biggest single win¶
HaGeZi maintains adblock/doh-vpn-proxy-bypass.txt — ~18,000 DoH resolver hostnames, including the bootstrap domains used by every major browser:
| Browser | DoH bootstrap |
|---|---|
| Firefox | mozilla.cloudflare-dns.com |
| Chrome | chrome.cloudflare-dns.com, dns.google |
| Safari (iCloud Private Relay bootstrap) | Apple-specific, not in this list — Apple uses QUIC |
| Edge | dns.google, other public resolvers |
When the bootstrap hostname can't be resolved (Pi-hole answers 0.0.0.0), the browser's DoH setup fails and it falls back to the system resolver — which is Pi-hole. This flips the default behavior from "browsers can bypass" to "browsers respect LAN DNS."
Adding it¶
NOW=$(date +%s)
sudo pihole-FTL sqlite3 /etc/pihole/gravity.db <<SQL
INSERT INTO adlist (address, enabled, comment, date_added, date_modified, type)
VALUES
('https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/adblock/doh-vpn-proxy-bypass.txt',
1, 'HaGeZi DoH/VPN/Proxy bypass', $NOW, $NOW, 0);
SQL
sudo pihole -g
See pihole-v6-adlist-management for general adlist CRUD via SQL.
Verification¶
After pihole -g completes, probe major DoH hostnames:
for h in mozilla.cloudflare-dns.com dns.google chrome.cloudflare-dns.com dns.quad9.net; do
echo -n "$h → "
dig +short $h @<pihole-ip>
done
# All should return 0.0.0.0
Known false positives¶
The list is aggressive. Expect occasional pushback:
claude.ai— gets caught by the broaderpro.txtor TIF list in some combinations; DoH bypass list itself is usually clean. If you use Claude on LAN and see blocks, allowlistclaude.ai— note thatapi.anthropic.comis typically not on any of these lists, so Claude Code / API traffic is unaffected.- Zscaler ZPA / Zscaler Internet Access — this will break work-from-home auth if you don't allowlist it. The DoH/VPN bypass list classifies Zscaler's ZTNA backbone as a "VPN proxy" and blocks it. Symptom: users see a blank / failed page at
https://samlsp.private.zscaler.com/...during SAML sign-in, and the Zscaler Client Connector fails to authenticate.
The critical piece is that Zscaler's SAML SP hostname is a CNAME chain:
samlsp.private.zscaler.com. CNAME samlsp.prod.zpath.net.
samlsp.prod.zpath.net. CNAME zapp2saml.gslb.prod.zpath.net.
zapp2saml.gslb.prod.zpath.net. CNAME snico2br.gslb.prod.zpath.net.
snico2br.gslb.prod.zpath.net. A <IP>
Pi-hole walks the CNAME chain and blocks on the target (status 9 = blocked_gravity_cname), so an exact-hostname allowlist for samlsp.private.zscaler.com will NOT fix it — you have to allowlist the CNAME target domain. The GSLB subdomains rotate, so use a regex allowlist for the whole zpath.net zone:
INSERT OR IGNORE INTO domainlist (type, domain, enabled, comment)
VALUES (2, '(\.|^)zpath\.net$', 1, 'Zscaler ZPA CNAME backbone — do not block');
Don't forget pihole reloaddns after. Expect to also need regex allowlists for zscaler.net, zscalertwo.net, zscalerthree.net, zscalerone.net, zscloud.net if any are gravity-blocked — HaGeZi's lists may cover different combinations over time.
- iCloud Private Relay — if you want iCPR to keep working on your Apple devices, allowlist its mask ingresses. The DoH/VPN bypass list blocks mask.icloud.com, mask-h2.icloud.com, and mask-api.icloud.com (Apple's iCPR entrance points). Without them, iCPR silently falls back to standard DNS — which means Pi-hole is covering the bypass whether you want it to or not. For hosts where iCPR is desired:
INSERT OR IGNORE INTO domainlist (type, domain, enabled, comment)
VALUES (2, '(\.|^)mask[a-z0-9-]*\.icloud\.com$', 1, 'iCloud Private Relay ingress');
Keep this surgical — do not allowlist all of icloud.com. Other subdomains (metrics.icloud.com, init.gc.apple.com family) are Apple telemetry that the adlists correctly block. After allowlist + pihole reloaddns, toggle Wi-Fi or flip iCPR off/on in Settings on each Apple device to force DNS re-resolution — iOS/macOS caches DNS aggressively and won't pick up the change otherwise.
- dot.txt companion adlist — as of April 2026, HaGeZi's separate adblock/dot.txt URL returns 403. DoT resolver hostnames are folded into doh-vpn-proxy-bypass.txt already.
What still leaks¶
The DoH adlist does not defend against:
- IoT devices with hardcoded public DNS. Chromecast et al. send UDP/53 queries directly to
8.8.8.8. Pi-hole never sees them. - Apps that hardcode a DoH or DoT endpoint by IP. If an app has
1.1.1.1baked in rather thancloudflare-dns.com, the hostname block can't help. - Apple iCloud Private Relay. Uses QUIC (UDP/443) to Cloudflare with oblivious DNS. Safari + Apple services route around Pi-hole entirely. Acceptable tradeoff for most users; mostly a privacy win even if it weakens your LAN-side visibility.
- Tailscale MagicDNS — the big one, and the easiest to miss. ⚠️ Added 2026-09-01 after finding four machines bypassing 100%. A node with
--accept-dns=true(the default) installs Tailscale's resolver100.100.100.100ahead of the DHCP-supplied one. On macOS and Windows it becomes the primary resolver outright. Queries then go to whatever the tailnet's global nameservers are — Quad9 in the case that prompted this — and Pi-hole never sees them.
This defeats every other defense on this page. The device is on your LAN, it looks configured correctly, and the adlists are irrelevant to it.
The precise mechanism on Linux (systemd-resolved), found 2026-09-05 debugging a server rather than a laptop: it isn't simply "Tailscale's resolver goes first in a list" — resolvectl status shows each interface can carry a routing domain, and an interface that claims the wildcard domain ~. wins for any query that doesn't match a more specific domain, regardless of which interface is merely marked Default Route: yes. A tailnet-wide Override DNS servers setting makes tailscale0 claim ~. on every accept-dns=true device — beating the LAN interface's plain default route even though nothing about that interface's own priority changed. This is why toggling Override DNS servers in the admin console can silently flip DNS behavior on servers nobody touched that day, not just the laptops the change was aimed at. Confirm with resolvectl status <iface> — look for DNS Domain: ... ~. on the tailnet interface, not just whether it's present in the resolver list.
The same fix applies differently depending on where the traffic actually originates. A workstation running WSL2 with Tailscale installed Windows-side only still inherits this on the WSL2 side — WSL2's own /etc/resolv.conf points at its internal NAT proxy, which in turn reflects whatever Windows' effective resolver is, so accept-dns=false on the Windows Tailscale client changes DNS behavior inside WSL2 too, with no WSL-side config needed. Verify this per-shell class of machine rather than assuming from the OS: dig the same known-blocked domain against the Pi-hole directly, against 100.100.100.100, and against the default resolver, and compare answers.
Why it hides so well: the machine resolves everything, browses normally, and shows no symptom whatsoever. There is no error to notice. It only surfaces when the filtered and unfiltered devices disagree — the case that exposed it was one household member unable to reach a site that another could, from the same network.
How to detect it, on every device you believe is filtered:
# Ask for a domain your Pi-hole is known to block.
dig +short ads.linkedin.com # filtered host → 0.0.0.0
# bypassing host → a real CNAME/IP
scutil --dns / resolvectl alone: seeing 192.168.50.x in the list says nothing about ordering or which resolver actually answered.
Cross-check from the Pi-hole side — a device that never appears in the query log is not a quiet device, it is a bypassing one:
-- absence here is the signal
SELECT client, COUNT(*) FROM queries
WHERE timestamp > strftime('%s','now','-1 day') GROUP BY client;
Fixes, cheapest first:
- tailscale set --accept-dns=false per device. ⚠️ This also disables MagicDNS on that device, so anything reached by a *.ts.net name breaks — including services behind a reverse proxy using Tailscale-issued certs, where falling back to the raw 100.x address fails TLS because the cert only covers the MagicDNS name. Pin the name in /etc/hosts (100.x.y.z host.tailnet.ts.net) and TLS keeps validating; pin any SSH aliases that resolved by bare name.
- Point the tailnet's global nameserver at the Pi-hole itself and enable Override local DNS. Keeps MagicDNS, and filters off-network too. ⚠️ Makes the Pi-hole a single point of failure for DNS on every node that accepts tailnet DNS — servers included, not just laptops — so audit tailscale debug prefs | grep CorpDNS fleet-wide first, and note that internal monitoring cannot alert on its failure if delivery needs the DNS that is down.
- Give the services real subdomains whose public A records point at the tailnet IP, with DNS-01 certificates. Only tailnet devices can route to 100.64.0.0/10, so access stays private while the names resolve everywhere. This removes MagicDNS from the critical path entirely and makes the first fix free.
Estimated residual gap after the DoH adlist: ~3% of tracker/telemetry traffic from hardcoded-DNS IoT — ⚠️ but that figure assumes every device actually uses the Pi-hole. A single Tailscale node with default settings is a 100% gap on that device, and it will not show up in any of the tests above unless you go looking for it specifically. Audit membership before trusting a coverage number.
Router-level enforcement (optional, higher effort)¶
To close the remaining 3%, block outbound udp/53, tcp/53, tcp/853 at the router for everything except the Pi-hole's IP. Two rules:
# Transparently redirect all LAN :53 traffic to Pi-hole, except Pi-hole itself
iptables -t nat -I PREROUTING -i br0 -p udp --dport 53 ! -s <pihole-ip> -j DNAT --to <pihole-ip>:53
iptables -t nat -I PREROUTING -i br0 -p tcp --dport 53 ! -s <pihole-ip> -j DNAT --to <pihole-ip>:53
# Reject DoT so apps fall back to classic DNS (→ Pi-hole via above)
iptables -I FORWARD -i br0 -p tcp --dport 853 ! -s <pihole-ip> -j REJECT --reject-with tcp-reset
iptables -I FORWARD -i br0 -p udp --dport 853 ! -s <pihole-ip> -j REJECT
Design choices:
- REDIRECT (DNAT), not DROP, for port 53 — devices with hardcoded 8.8.8.8 receive transparent answers from Pi-hole instead of silently breaking.
- REJECT, not DROP, for port 853 — DoT clients see a fast error and fall back to classic DNS immediately instead of timing out.
- Exempt the Pi-hole — it needs to reach upstream resolvers (1.1.1.1 etc.) unimpeded.
- -i br0 only — LAN ingress, not WAN.
Persistence depends on router firmware¶
- Asuswrt-Merlin: add rules to
/jffs/scripts/firewall-start— runs on every firewall init. - Stock AsusWRT 388+:
/jffs/scripts/firewall-startis not honored. Rules added live persist until the nextrestart_firewallevent (reboot, WAN flap, GUI config change). Workarounds: flash to Merlin, use the GUI's "LAN ▸ Network Services Filter" (DROP-only, less flexible), or schedule a cron re-apply in/jffs/configs/crontab. - OpenWrt / pfSense / OPNsense: their respective firewall config persistence works out of the box.
Summary — minimum viable DoH defense¶
- Pi-hole block mode =
NULL(default — verify). - Install HaGeZi
doh-vpn-proxy-bypassadlist. - Run
pihole -g. - Verify major DoH bootstraps return
0.0.0.0. - Optional: add router iptables rules to close the IoT/hardcoded-DNS gap.
Steps 1–4 give you ~97% effectiveness with zero client-side changes and no broken devices. Step 5 is polish for threat models where LAN-wide DNS visibility matters.
Related¶
- majorpi — local Pi-hole deployment
- pihole-v6-adlist-management — adlist CRUD via SQL (v5 CLI commands don't work in v6)
- Network Overview — fleet network context