2026-10-05 / zSender
SpamAssassin DNSBLs go quiet on shared DNS
We turned on spam scoring on our mail server on 2026-07-13. Scores came back and they looked plausible. Only a per-rule breakdown of a real message showed what was wrong: lines like URIBL_BLOCKED and URIBL_DBL_BLOCKED_OPENDNS, each printed as 0.0. The blocklists had refused SpamAssassin's lookups, and the total gave no sign of it. The SpamAssassin behaviour below is checked against its 4.0.0 source.
What the blocklists do with shared resolvers#
A DNS blocklist, or DNSBL, is queried by looking up a name under the list's zone. An answer means the domain or address is listed, or, as below, that the list returned an error code. No answer means it is not listed. The large lists are free to query at low volume only if they can tell who is asking, and a query through a big shared resolver carries the resolver's address, not yours.
Spamhaus answers such a query with an error code instead of data. Its return code page lists 127.255.255.254 as "Query via public/open resolver/generic unattributable rDNS" and 127.255.255.255 as "Excessive Number of Queries", and warns that "none of these return codes relate to the reputation of the query". Its FAQ adds that a public resolver will "in most cases" get a "not listed" (NXDOMAIN) reply, and says "We recommend using your own DNS servers when doing DNSBL queries to Spamhaus."
URIBL is just as direct: "If you spam check a large amount of email, or you use a shared DNS platform for resolution, you may receive a response saying the query was refused." It answers refused queries with 127.0.0.1, "as bit 0x1 is not used for domain classification", and suggests a caching nameserver on the loopback of the machine doing the spam checking. SURBL documents the same answer: a result of 127.0.0.1 from its public nameservers "means your access is blocked".
What SpamAssassin does about it#
SpamAssassin 4.0.0's rule files define one rule for each of these refusal answers. A few of them:
| Rule | List | Answer it matches |
|---|---|---|
URIBL_BLOCKED | multi.uribl.com | bit 1 set |
SURBL_BLOCKED | multi.surbl.org | bit 1 set |
URIBL_DBL_BLOCKED_OPENDNS | dbl.spamhaus.org | 127.255.255.254 |
URIBL_DBL_BLOCKED | dbl.spamhaus.org | 127.255.255.255 |
RCVD_IN_ZEN_BLOCKED_OPENDNS | zen.spamhaus.org | 127.255.255.254 |
Each one is described as an "ADMINISTRATOR NOTICE" that the query was blocked. Each one also scores almost nothing. Most of them carry this exact line in the score file:
score URIBL_BLOCKED 0 0.001 0 0.001A score line with four values is one score per score set, and SpamAssassin's configuration documentation defines them in order: set 0 is Bayes and network tests both off, set 1 is network tests on without Bayes, set 2 is Bayes on without network tests, and set 3 is both on. So these rules are worth 0 when network tests are off and 0.001 when they run. SURBL_BLOCKED has 0.001 in all four sets. The report formats each score with one decimal place, so a hit prints as 0.0.
The refusal is an answer, not a timeout or an error, so the lookup succeeds and the rule hits. The message gains 0.001 points. The rules that would have fired on a listed link, such as URIBL_DBL_ABUSE_PHISH at 2.5 points with network tests on, cannot fire, because the list never said anything about the link. Every score comes out lower than it should, and nothing in the total says so.
SpamAssassin does say something, once in a while, outside the score. The URIBL, SURBL and Zen rules above are each paired with a dns_block_rule setting; the two DBL rules are not. The first time a paired rule hits, SpamAssassin writes a Perl warning that starts "check: dns_block_rule URIBL_BLOCKED hit" and adds "This means DNSBL blocked you due to too many queries." It then creates a state file named after the list, and for dns_block_time seconds, 300 by default, stops querying that list at all. Under spamd the warning goes to its log at the warning level. The skipped queries are logged only at debug level, as "async: blocked by dns_block_rule".
So during each five-minute block, messages show no *_BLOCKED rule for that list, because no query was sent. The DBL rules have no such pause, so they keep appearing on every message with a link. When the state file expires, the next query is refused again, the rule hits once more and the warning repeats. And when a resolver turns the refusal into NXDOMAIN, as Spamhaus says public resolvers mostly do, no rule fires at all.
The fix: a resolver of your own#
Run a recursive resolver such as unbound on the same machine as the scanner, listening only on loopback. Port 5353 here is an example, chosen to leave the host's own resolver alone:
server:
interface: 127.0.0.1@5353
do-ip6: noWith no forwarders configured, unbound resolves from the root on its own, so the lists see the scanning machine's own address. By default unbound answers only localhost, so nothing else on the network can use it. With the host's own resolver on port 53 left exactly as it was, if this resolver breaks, only spam scoring breaks with it.
Then SpamAssassin is pointed at it in local.cf. Without a dns_server line it takes its name servers from Net::DNS, which typically reads /etc/resolv.conf:
dns_server 127.0.0.1:5353Restart spamd so it reads the new setting.
Two install traps. Per unbound 1.19.2's documentation and source, with no interface line unbound listens on localhost over both IPv4 and IPv6, binding ::1 as well as 127.0.0.1 while do-ip6 is on, which is the default. A kernel without IPv6 at all is tolerated: unbound logs "IPv6 protocol not available" and carries on. But when IPv6 is switched off with the disable_ipv6 sysctl, the socket opens and ::1 does not exist, so the bind fails and the daemon does not start. The explicit interface line above avoids it, and do-ip6: no also stops unbound sending its own queries over IPv6. And systemd rate-limits starts: its v255 documentation says that by default a unit started more than 5 times within 10 seconds is not permitted to start any more, and this applies to manual starts too. After a few failed attempts, wait for the interval to pass or run systemctl reset-failed unbound before trying again.
Check yours#
Run a message that contains a link through SpamAssassin in test mode, as the same user and with the same configuration as your scanner, and look at the report:
spamassassin -t < message.emlAny rule ending in _BLOCKED means that list is not working for you. Treat every score you have as understated until it is gone.
A clean report does not prove the opposite, because of the five-minute block and the NXDOMAIN case above. Ask the resolver SpamAssassin uses directly, with the test query from the Spamhaus FAQ:
dig +short @127.0.0.1 -p 5353 2.0.0.127.zen.spamhaus.orgA working resolver returns codes such as 127.0.0.2. An answer of 127.255.255.254 or 127.255.255.255, or no answer at all, means your queries are being refused. To see the block state from SpamAssassin's side, turn on its debug output and filter for it:
spamassassin -D async -t < message.eml 2>&1 | grep -i blockedSources#
- SpamAssassin 4.0.0 rules/25_uribl.cf, the URI blocklist rules and their
dns_block_rulelines - SpamAssassin 4.0.0 rules/20_dnsbl_tests.cf, the
RCVD_IN_ZEN_BLOCKEDrules - SpamAssassin 4.0.0 rules/50_scores.cf, the 0.001 scores
- SpamAssassin 4.0.0 rulesrc/scores/72_scores.cf, the score for
SURBL_BLOCKED - SpamAssassin 4.0.0 Conf.pm, the
score,dns_server,dns_block_ruleanddns_block_timeoptions - SpamAssassin 4.0.0 Plugin/Check.pm, the warning and the state file
- SpamAssassin 4.0.0 AsyncLoop.pm, skipped queries during a block
- SpamAssassin 4.0.0 PerMsgStatus.pm, how the report formats scores
- SpamAssassin 4.0.0 spamd, warnings logged at the warning level
- SpamAssassin 4.0.0 spamassassin-run, the
-tand-Doptions - Spamhaus, DNSBL return codes
- Spamhaus, DNSBL usage FAQ, public resolvers and the test query
- URIBL, query refused
- SURBL lists, the blocked result code
- unbound 1.19.2 unbound.conf manual,
interface,do-ip6andaccess-control - unbound 1.19.2 source,
listen_dnsport.c, the default localhost listeners and how a missing IPv6 is handled - systemd v255 systemd.unit,
StartLimitIntervalSecandStartLimitBurst - systemd v255 systemd-system.conf, the default start limit