DNS Sinkholing: Blocking Malware Domains at the Network Level
Stopping callbacks and bad domains before a single packet leaves

Contents
A few years ago I watched a brand-new “smart” plug on my network try to reach roughly forty different domains in its first hour of life — telemetry endpoints, a couple of ad networks, and one domain that a threat feed flagged as a known command-and-control host. I hadn’t installed anything on it; I couldn’t. It’s a sealed appliance with no SSH, no agent, no way in. The only thing standing between that plug and whatever it was trying to phone was the resolver it asked for an address. That resolver said “no”, and the connection died in the device’s own network stack before a single packet left my house.
That is DNS sinkholing, and on a home or small-business network it is the single highest-leverage security control I run. Almost every hostile thing a compromised or chatty device does begins with a DNS lookup: malware phones home, a phishing link resolves a lookalike domain, a tracker beacons to an analytics endpoint, a botnet implant fetches its next instruction. All of it starts with “what’s the IP for this?” Which makes name resolution the best chokepoint on the whole network. Block the name and the connection never happens — no per-IP firewall rule to maintain, no deep packet inspection, no certificate gymnastics.
What “sinkholing” actually means
A sinkhole is just a DNS resolver that, for a curated list of bad domains,
answers with a dead end instead of the real address. Ask it for
malware-c2.example.bad and instead of the attacker’s server you get back
0.0.0.0 (the unroutable “this host” address) or NXDOMAIN (“no such name”),
so the connection fails immediately in the client. The malware can’t reach its
command server; the tracker can’t beacon; the phishing page never loads.
The mechanism is identical to what an ad-blocking resolver does — it is the same
trick, just pointed at threat-intelligence feeds rather than ad networks. If
you’ve already set up Pi-hole with Unbound for network-wide ad
blocking, you are
ninety percent of the way to a malware sinkhole; you just add security-focused
lists alongside the ad lists. The two classic tools are Pi-hole and the more
configurable Unbound, but you can build a perfectly good sinkhole with
dnsmasq and a cron job, and that minimal version is the clearest way to see
what is actually going on under the hood. Let me build it from the bones up.
Why DNS and not the firewall
The obvious question is: why not just block the bad servers at the firewall? Because attacker infrastructure moves. A piece of malware’s command server might live on a different IP every day, rotating through cheap cloud instances and compromised hosts, but the domain it looks up is comparatively sticky — it is baked into the implant and expensive for the attacker to change everywhere at once. Blocking by IP is whack-a-mole against an opponent who can spin up a new address in seconds. Blocking by name catches the thing that doesn’t move.
DNS blocking is also device-agnostic in a way nothing else is. A firewall rule protects traffic that crosses the firewall; an endpoint agent protects the one machine it’s installed on. A sinkhole protects everything that asks it for a name, including the sealed IoT junk you can’t touch, the guest’s phone, and the smart TV that ships its own ad SDK. That breadth is the whole point.
A minimal sinkhole with dnsmasq
dnsmasq will read any hosts-format file and answer authoritatively for the
names in it. Point a blocklist into it and you have a sinkhole. Here is a
config that forwards everything legitimate upstream and blocks the listed names
locally:
| |
The two server= lines are placeholders pointing at a LAN resolver —
swap in whatever upstream you actually trust (a public privacy resolver, or
better, your own recursive resolver so nothing leaks to a third party at all;
see running your own DoH resolver at
home for that
route). The blocklist itself is plain hosts format — point every bad domain at
the unroutable 0.0.0.0:
| |
You do not hand-curate this. You pull a real, maintained threat feed and reload it on a schedule.
Automating the blocklist
Curated feeds exist for exactly this — aggregated malware, phishing and command-and-control domain lists, published in hosts format and updated continuously. The job of your sinkhole host is to fetch one (or several), sanity-check it, and reload. Here’s the script I run from a systemd timer:
| |
That COUNT -gt 1000 guard is not optional, and it is the part people skip. A
truncated download — feed outage, captive-portal redirect, a 200 response that’s
actually an error page — reloads as a near-empty list and silently disables your
protection while everything still appears to work. You won’t notice until
something gets through. Guard against it, log the count every time, and alert if
the count ever drops sharply.
Schedule it with a systemd timer rather than cron so you get logging and dependency ordering for free:
| |
The randomised delay stops every sinkhole on the internet hammering the feed at exactly 04:30, which the feed maintainers appreciate.
Make every device actually use it
A sinkhole only protects what queries it, and the default behaviour of most devices is to use whatever resolver DHCP hands them — until they decide not to. Two steps lock it down.
First, hand the sinkhole out as the only resolver via DHCP, so every device gets it automatically. Second — and this is the step people forget — block outbound DNS at the firewall so nothing can route around you by talking to a public resolver directly. Plenty of devices ship a hardcoded resolver and will quietly ignore the DHCP-assigned one. Force the issue:
| |
Now a device that tries to reach a hardcoded resolver on port 53 gets rejected, notices its preferred DNS is unreachable, and (usually) falls back to the one it was given — yours.
The encrypted-DNS problem
DoH (DNS-over-HTTPS) is the awkward part, and any honest write-up has to admit it. A browser or device that does its own DoH lookups sends them as ordinary HTTPS to port 443, indistinguishable from normal web traffic, routing around your port-53 block and your sinkhole entirely. You cannot simply block port 443.
The pragmatic answers, in rough order of effort:
- Block the known DoH bootstrap endpoints. The major DoH providers resolve their own endpoints through a handful of domains; sinkhole those and clients fail their encrypted lookups and fall back to system DNS. Maintained “DoH provider” blocklists exist for this purpose — add one.
- Use the Firefox canary domain. Firefox checks a specific canary name; if
your resolver answers it with
NXDOMAIN, Firefox disables its own DoH and respects the system resolver. It’s a deliberate co-operation hook — use it. - Set browser policy on managed devices. On machines you control, disable application-level DoH via enterprise policy so the browser uses the system resolver, which is yours.
It is a cat-and-mouse corner and you will not win it completely. Treat DoH suppression as raising the cost of bypass, not as an impenetrable wall.
Watching it work
The reason to enable log-queries during setup is to see the sinkhole earn
its place. Tail the log and watch for blocked lookups:
| |
Each config ... is 0.0.0.0 line is a request that got stopped at the door.
Leave it running for a week and you’ll build a genuine picture of what your
network quietly reaches out to — it is usually more, and more surprising, than
you’d guess. Once you trust it, turn per-query logging back off: it is noisy,
it grows your journal, and a permanent record of every DNS query every device
makes is a mild privacy liability of its own.
Troubleshooting
A few failure modes account for nearly every “my sinkhole isn’t working” message I get.
Everything resolves, nothing is blocked. Your list reloaded empty or the
device isn’t using the sinkhole. Check the entry count
(wc -l /etc/dnsmasq.d/blocklist.hosts) — if it’s near zero, your update guard
saved you or your feed broke. Then confirm the client is actually querying you:
dig @192.168.1.2 malware-c2.example.bad should return 0.0.0.0, and
dig malware-c2.example.bad (using the client’s default resolver) should also
return 0.0.0.0. If the second one resolves normally, the device is bypassing
your resolver — tighten the firewall rule above.
A legitimate site broke. Overly aggressive feeds occasionally flag a domain
that a service you use depends on (a CDN, an auth provider). Find it in the log,
add an allowlist entry. In dnsmasq an explicit server=/good.example/<upstream>
line forces that one domain to resolve normally regardless of the blocklist.
Keep your allowlist small and version-controlled so you remember why each entry
exists.
Reload doesn’t take effect. systemctl reload dnsmasq re-reads config but
some versions need a full restart to drop cached answers. If a freshly blocked
domain still resolves, flush with a restart and re-test; the underlying caching
behaviour is the same reason clearing your DNS
cache is the first thing to try when any
resolver gives you a stale answer.
Intermittent slow lookups. If one of your two upstreams is flaky, dnsmasq
may wait on it before falling back. Add all-servers to query both in parallel
and take the first answer, at the cost of slightly more upstream traffic.
The verdict
Worth it? For a home or small-office network, emphatically yes. A sinkhole is cheap — a Raspberry Pi handles it without breaking a sweat — it protects every device including the ones you can’t install software on, and it stops whole classes of attack at the name-resolution stage where they’re cheapest to kill.
The honest limits, stated plainly: it is a blocklist, so it only catches known-bad domains; a fresh attacker domain registered an hour ago will sail through until the feed catches up. Encrypted DNS gives sophisticated malware and nosy browsers a real path around it that you can raise the cost of but not fully close. And it is one layer — it does nothing about an attacker who already has a foothold and uses an IP directly. Treat it as one strong, broad, low-maintenance layer in a stack, not as the whole wall.
This is for anyone who runs their own network and wants meaningful, device-wide protection for the price of a Pi and an afternoon. Just don’t reload an empty blocklist. Ask me how I know.




