Contents

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

Advertisement

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

Advertisement

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# /etc/dnsmasq.d/sinkhole.conf

# Upstream resolvers for everything NOT on the blocklist.
# Swap in whichever resolver you trust (a public privacy resolver,
# or better, your own recursive resolver on the LAN as shown here).
server=192.168.1.5       # your chosen upstream resolver
server=192.168.1.6       # a second upstream for redundancy

# Our curated blocklist, hosts format.
addn-hosts=/etc/dnsmasq.d/blocklist.hosts

# Don't forward queries for plain names with no dots, and
# don't read the host's own /etc/hosts as authoritative.
domain-needed
no-hosts

# Log queries while you're testing so you can watch it work.
log-queries

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:

1
2
3
4
# /etc/dnsmasq.d/blocklist.hosts
0.0.0.0 malware-c2.example.bad
0.0.0.0 tracker.shady.example
0.0.0.0 phish-login.example.bad

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/env bash
set -euo pipefail

LIST=/etc/dnsmasq.d/blocklist.hosts
TMP=$(mktemp)
FEED="https://example-threatfeed.invalid/hosts"   # your chosen feed URL

# Fetch, keep only well-formed sinkhole lines, normalise to 0.0.0.0.
curl -fsSL --retry 3 --max-time 30 "$FEED" \
  | grep -E '^(0\.0\.0\.0|127\.0\.0\.1)\s+\S+' \
  | awk '{print "0.0.0.0", $2}' \
  | sort -u > "$TMP"

# Sanity check BEFORE swapping in — never reload an empty or truncated list.
COUNT=$(wc -l < "$TMP")
if [ "$COUNT" -gt 1000 ]; then
  mv "$TMP" "$LIST"
  systemctl reload dnsmasq
  logger -t sinkhole "blocklist updated: ${COUNT} entries"
else
  logger -t sinkhole "refusing suspiciously small list (${COUNT}); keeping old"
  rm -f "$TMP"
fi

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:

1
2
3
4
5
6
7
8
# /etc/systemd/system/sinkhole-update.timer
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

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:

1
2
3
4
5
# Sinkhole lives at 192.168.1.2. Reject any DNS that isn't going to it.
sudo nft add rule inet filter forward \
    ip daddr != 192.168.1.2 udp dport 53 reject
sudo nft add rule inet filter forward \
    ip daddr != 192.168.1.2 tcp dport 53 reject

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:

1
2
3
$ sudo journalctl -u dnsmasq -f | grep -i config
dnsmasq: config tracker.shady.example is 0.0.0.0
dnsmasq: config malware-c2.example.bad is 0.0.0.0

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.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.