Howto Block the Spamhaus DROP Lists with ufw

Reading time: 11 minutes

The Spamhaus Do Not Route Or Peer (DROP) lists are the worst of the worst: netblocks that are leased, hijacked, or outright owned by professional spam and cyber-crime operations. Spamhaus calls them an advisory “drop all traffic” dataset, and that is exactly how you should treat them. No SMTP session, no HTTP request, no SSH login attempt from those ranges is ever going to be something you want.

DROP is a small subset of the Spamhaus Blocklist (SBL) - roughly 1,700 IPv4 networks and 90 IPv6 networks at the time of writing - and false positives are extremely low, because address space under the control of a legitimate network is never listed. That makes it one of the very few public lists you can safely drop at the firewall instead of only scoring it in your mail filter.

This tutorial shows how to enforce both lists with ufw on Debian or Ubuntu, kept up to date by a small shell script.

Why not just ufw deny from

The obvious idea is a loop:

# Don't do this
while read -r net; do ufw insert 1 deny from "$net"; done < drop.txt

It works, technically. But every single ufw command rewrites the rule files and reloads the whole firewall, so ~1,800 networks take a very long time to apply, ufw status becomes unreadable, and every packet is matched against a linear list of 1,800 rules. Updating the list the next day means finding and deleting the stale rules again.

The clean way is an ipset: one kernel-side hash set holding all networks, matched by a single firewall rule with constant lookup cost. ufw supports this through its before.rules hook, so the setup stays inside ufw instead of fighting it.

What you need

apt install ipset curl

If you want the IPv6 list enforced too, make sure IPv6 is enabled in /etc/default/ufw:

IPV6=yes

The update script

Save this as /usr/local/sbin/spamhaus-drop-update. It downloads both lists, sanity-checks them, and swaps the result into the running ipsets atomically.

#!/usr/bin/env bash
#
# spamhaus-drop-update - fetch the Spamhaus DROP lists and load them into the
# ipset sets that ufw matches against.
#
# https://www.spamhaus.org/blocklists/do-not-route-or-peer/

set -euo pipefail

URL_V4="https://www.spamhaus.org/drop/drop.txt"
URL_V6="https://www.spamhaus.org/drop/dropv6.txt"

SET_V4="spamhaus-drop"
SET_V6="spamhaus-dropv6"

STATE_DIR="/var/lib/spamhaus-drop"
SAVE_FILE="${STATE_DIR}/ipsets.save"

# Refuse to load a list that looks truncated, empty, or replaced by an error
# page. Better to keep yesterday's data than to flush a working set.
MIN_V4=1000
MIN_V6=20

MAXELEM=262144
USER_AGENT="spamhaus-drop-update (ufw/ipset updater)"

FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1

log()  { logger -t spamhaus-drop -s -- "$*"; }
die()  { log "ERROR: $*"; exit 1; }

[ "$(id -u)" -eq 0 ] || die "must run as root"
command -v ipset >/dev/null || die "ipset is not installed"
command -v curl  >/dev/null || die "curl is not installed"

mkdir -p "$STATE_DIR"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

# Conditional GET against the cached copy: Spamhaus rebuilds the lists roughly
# once an hour, so an unchanged list must not cost them a full download.
fetch() {
  local url="$1" cache="$2" out="$3"
  local args=(--fail --silent --show-error --location
              --retry 3 --retry-delay 5 --max-time 120
              --user-agent "$USER_AGENT" --remote-time --output "$out")
  [ -s "$cache" ] && args+=(--time-cond "$cache")
  curl "${args[@]}" "$url"
}

# Strip the "; SBLxxxxx" comments and keep well-formed CIDR networks only.
parse() {
  sed -e 's/;.*//' -e 's/[[:space:]]//g' "$1" |
    grep -E '^[0-9a-fA-F.:]+/[0-9]{1,3}$' || true
}

# Build a temporary set, then swap it in. The swap is atomic, so the firewall
# never sees a half-loaded list.
load_set() {
  local set="$1" family="$2" file="$3" tmpset="${1}-new"

  ipset -exist create "$set" hash:net family "$family" maxelem "$MAXELEM"
  ipset destroy "$tmpset" 2>/dev/null || true
  {
    printf 'create %s hash:net family %s maxelem %s\n' "$tmpset" "$family" "$MAXELEM"
    sed "s|^|add ${tmpset} |" "$file"
  } | ipset -exist restore
  ipset swap "$tmpset" "$set"
  ipset destroy "$tmpset"
}

update() {
  local url="$1" set="$2" family="$3" min="$4"
  local cache="${STATE_DIR}/$(basename "$url")"
  local raw="${TMP}/$(basename "$url")" nets="${TMP}/${set}.nets"

  fetch "$url" "$cache" "$raw" || die "download of $url failed"

  # An empty body means the server answered 304 Not Modified.
  if [ ! -s "$raw" ]; then
    if [ "$FORCE" -eq 0 ] &&
       [ "$(ipset save "$set" 2>/dev/null | grep -c '^add ')" -ge "$min" ]; then
      log "$set unchanged (304 Not Modified), nothing to do"
      return 1
    fi
    # Cache hit but the set is missing (fresh install or after a reboot):
    # rebuild it from the cached copy.
    cp "$cache" "$raw"
  fi

  parse "$raw" > "$nets"
  local count
  count="$(wc -l < "$nets")"
  [ "$count" -ge "$min" ] ||
    die "$url returned only $count networks (expected >= $min), keeping the current set"

  load_set "$set" "$family" "$nets"
  cp "$raw" "$cache"
  log "loaded $count networks into $set"
  return 0
}

changed=0
update "$URL_V4" "$SET_V4" inet  "$MIN_V4" && changed=1
update "$URL_V6" "$SET_V6" inet6 "$MIN_V6" && changed=1

if [ "$changed" -eq 1 ]; then
  # Persist for the boot-time restore unit.
  ipset save "$SET_V4" >  "${SAVE_FILE}.tmp"
  ipset save "$SET_V6" >> "${SAVE_FILE}.tmp"
  mv "${SAVE_FILE}.tmp" "$SAVE_FILE"
fi

exit 0

A few details worth pointing out, because they are what separates a script you can leave running from one that takes your server down at 3 a.m.:

Make it executable and run it once, before you add any ufw rules - the sets have to exist or ufw will refuse to load its ruleset:

chmod 700 /usr/local/sbin/spamhaus-drop-update
/usr/local/sbin/spamhaus-drop-update
root@server $ ipset list -t
Name: spamhaus-drop
Type: hash:net
Revision: 7
Header: family inet hashsize 1024 maxelem 262144 bucketsize 12 initval 0xd2c1f3a4
Size in memory: 108216
References: 0
Number of entries: 1708

Name: spamhaus-dropv6
Type: hash:net
Revision: 7
Header: family inet6 hashsize 1024 maxelem 262144 bucketsize 12 initval 0x5b8e7c11
Size in memory: 7488
References: 0
Number of entries: 92

Hook the sets into ufw

ufw applies /etc/ufw/before.rules before any of your own rules, which is exactly where a “drop this traffic, no questions asked” filter belongs.

Edit /etc/ufw/before.rules and add the Spamhaus block right after the *filter section header and its chain definitions, before the loopback rules:

*filter
:ufw-before-input - [0:0]
:ufw-before-output - [0:0]
:ufw-before-forward - [0:0]
:ufw-not-local - [0:0]

# Spamhaus DROP - drop all traffic to and from listed networks
-A ufw-before-input   -m set --match-set spamhaus-drop src -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW SPAMHAUS DROP] "
-A ufw-before-input   -m set --match-set spamhaus-drop src -j DROP
-A ufw-before-output  -m set --match-set spamhaus-drop dst -j DROP
-A ufw-before-forward -m set --match-set spamhaus-drop src -j DROP
-A ufw-before-forward -m set --match-set spamhaus-drop dst -j DROP

# allow all on loopback
-A ufw-before-input -i lo -j ACCEPT
...

Then the same for IPv6 in /etc/ufw/before6.rules, using the v6 set:

# Spamhaus DROPv6 - drop all traffic to and from listed networks
-A ufw-before-input   -m set --match-set spamhaus-dropv6 src -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW SPAMHAUS DROP6] "
-A ufw-before-input   -m set --match-set spamhaus-dropv6 src -j DROP
-A ufw-before-output  -m set --match-set spamhaus-dropv6 dst -j DROP
-A ufw-before-forward -m set --match-set spamhaus-dropv6 src -j DROP
-A ufw-before-forward -m set --match-set spamhaus-dropv6 dst -j DROP

“Do not route or peer” means both directions. The dst rules matter more than people assume: they stop an already infected machine on your network from talking to a botnet controller or a bulletproof-hosted drop site.

The LOG rule is optional, but it is what lets you answer “did this actually block anything?” later. The rate limit keeps a scan from filling your disk. Everything shows up in kern.log/journalctl with the prefix you set, so it is easy to graph - the same idea as monitoring your local Postfix blacklist with Munin.

Reload ufw to activate:

ufw reload

If ufw reload errors out with something like Set cannot be destroyed: it is in use by a kernel component, or Couldn’t load match `set’, the sets do not exist yet - run the update script first.

Verify it

Check that the rules are in place and counting packets:

root@server $ iptables -L ufw-before-input -v -n | grep spamhaus
  132  6810 LOG   all -- * * 0.0.0.0/0  0.0.0.0/0  match-set spamhaus-drop src limit: avg 3/min burst 10 LOG flags 0 level 4 prefix "[UFW SPAMHAUS DROP] "
 1841 92050 DROP  all -- * * 0.0.0.0/0  0.0.0.0/0  match-set spamhaus-drop src
root@server $ ip6tables -L ufw-before-input -v -n | grep spamhaus

Test a single address against the set - pick one from the list, and while you are at it, make sure your own admin IP address is not listed:

root@server $ ipset test spamhaus-drop 1.10.16.1
1.10.16.1 is in set spamhaus-drop.
root@server $ ipset test spamhaus-drop 198.51.100.7
198.51.100.7 is NOT in set spamhaus-drop.

And watch the hits roll in:

journalctl -k -f | grep "SPAMHAUS DROP"

Keep the lists fresh

DROP changes daily: netblocks get reassigned, and an outdated list will eventually block a network that has since gone back to a legitimate owner. Spamhaus explicitly asks you not to import this data without keeping it up to date.

A systemd timer does the job. /etc/systemd/system/spamhaus-drop-update.service:

[Unit]
Description=Update Spamhaus DROP ipsets
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/spamhaus-drop-update

/etc/systemd/system/spamhaus-drop-update.timer:

[Unit]
Description=Update Spamhaus DROP ipsets daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now spamhaus-drop-update.timer
systemctl list-timers spamhaus-drop-update.timer

RandomizedDelaySec spreads the load so that not every server on the planet hits Spamhaus at midnight, and Persistent=true catches up after downtime. If you prefer cron, this is the equivalent:

# /etc/cron.d/spamhaus-drop
17 4 * * * root /usr/local/sbin/spamhaus-drop-update

Because of the conditional GET, running it more often than once a day is cheap - just keep it to at most once per hour.

Survive a reboot

ipsets live in kernel memory and are gone after a reboot. ufw, on the other hand, starts early and will fail to load its ruleset if the sets it references do not exist - which would leave you without a firewall at all. So restore the sets before ufw starts.

/usr/local/sbin/spamhaus-drop-restore:

#!/bin/sh
#
# Recreate the Spamhaus DROP ipsets at boot, before ufw loads its rules.
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin

SAVE_FILE=/var/lib/spamhaus-drop/ipsets.save

# Always create the (possibly empty) sets, so the ufw rules referencing them
# load even if the saved data is missing.
ipset -exist create spamhaus-drop   hash:net family inet  maxelem 262144
ipset -exist create spamhaus-dropv6 hash:net family inet6 maxelem 262144

if [ -s "$SAVE_FILE" ]; then
  ipset -exist restore -f "$SAVE_FILE"
fi

exit 0

/etc/systemd/system/spamhaus-drop-restore.service:

[Unit]
Description=Restore Spamhaus DROP ipsets
DefaultDependencies=no
After=local-fs.target
RequiresMountsFor=/var/lib/spamhaus-drop
Before=ufw.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/spamhaus-drop-restore

[Install]
WantedBy=multi-user.target
chmod 700 /usr/local/sbin/spamhaus-drop-restore
systemctl daemon-reload
systemctl enable spamhaus-drop-restore.service

DefaultDependencies=no is needed because ufw itself starts before the normal boot targets; without it, systemd would order the restore after ufw. The empty-set creation is the safety net: even with no saved data, ufw still loads.

Text files today, JSON tomorrow

Spamhaus now publishes DROP primarily as JSON (drop_v4.json, drop_v6.json, asndrop.json) and has announced that the plain text files will eventually be deprecated, with notice beforehand. The text files are still generated and are by far the easiest thing to parse in shell, which is why the script above uses them.

Switching later is a two-line change - install jq and replace the URLs and the parse() function:

URL_V4="https://www.spamhaus.org/drop/drop_v4.json"
URL_V6="https://www.spamhaus.org/drop/drop_v6.json"

parse() {
  jq -r 'select(.cidr) | .cidr' "$1"
}

The JSON is newline-delimited, one object per network plus a trailing metadata record, so select(.cidr) is enough to skip the metadata line:

root@server $ curl -s https://www.spamhaus.org/drop/drop_v4.json | tail -2
{"cidr":"223.254.0.0/16","sblid":"SBL212803","rir":"apnic"}
{"type":"metadata","timestamp":1788810242,"size":104218,"records":1708,"copyright":"(c) 2026 The Spamhaus Project SLU","terms":"https://www.spamhaus.org/drop/terms/"}

The sblid is the Spamhaus Blocklist record behind each listing, which is handy if you ever need to look up why a network is blocked, or point an abuse desk at the evidence.

Rolling it back

If you need to undo everything, comment out the rules in /etc/ufw/before.rules and /etc/ufw/before6.rules, reload, and only then remove the sets:

ufw reload
ipset destroy spamhaus-drop
ipset destroy spamhaus-dropv6
systemctl disable --now spamhaus-drop-update.timer
systemctl disable spamhaus-drop-restore.service

The order matters - a set that is still referenced by a firewall rule cannot be destroyed.

What this does and does not buy you

DROP is infrastructure-level protection. It stops traffic from networks that have no business talking to you at all, on every port, before your services see it - and it does so with one rule and a hash lookup, so it costs you nothing in throughput.

What it is not: a mail filter. DROP is a tiny, extremely conservative subset of the SBL, and it only lists whole netblocks belonging to criminal operations. The single compromised mail server, the hijacked mailbox at a legitimate provider, the snowshoe sender rotating through a hosting company’s address space - none of that is in DROP, and none of it should be blocked at the firewall. For that you want a DNS blocklist (DNSBL) consulted during the SMTP transaction, plus your own local blacklist for the repeat offenders you see in your logs.

Two lists, two layers: DROP at the firewall for the networks nobody should route to, and IP blacklisting at the mail server for everything that is merely sending you spam and phishing today. If you would rather not maintain the second layer by hand across a fleet of mail servers, that is exactly what MailMum does for you.


Newsletter


See Also


Tags