---
title: "Howto Block the Spamhaus DROP Lists with ufw"
description: "Block the Spamhaus Do Not Route Or Peer (DROP) lists on a Linux server with ufw, ipset, and a small update script that keeps the data fresh."
url: "https://mailmum.io/posts/howto-block-spamhaus-drop-lists-with-ufw/"
---

# Howto Block the Spamhaus DROP Lists with ufw

The [Spamhaus Do Not Route Or Peer
(DROP)](https://www.spamhaus.org/blocklists/do-not-route-or-peer/) lists are the
worst of the worst: netblocks that are leased, hijacked, or outright owned by
professional [spam](https://mailmum.io/glossary/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](https://mailmum.io/glossary/simple-mail-transfer-protocol-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:

```sh
# 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](https://ipset.netfilter.org): 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

- Debian or Ubuntu with a working, enabled `ufw`
- `ipset` and `curl`
- root access

```sh
apt install ipset curl
```

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

```config
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.

```sh
#!/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.:

- **Atomic swap.** The new data goes into `spamhaus-drop-new` first and is then
  swapped with the live set in one kernel operation. There is no window in which
  the set is empty or half full.
- **Sanity limits.** If the download returns a captive portal login page, a
  Cloudflare error, or three lines of nothing, the script aborts and keeps the
  set it already has. `MIN_V4`/`MIN_V6` are deliberately far below the real list
  sizes.
- **Conditional GET.** `curl --time-cond` sends `If-Modified-Since` based on the
  cached copy, and `--remote-time` stamps the cache with the server's
  `Last-Modified`. If Spamhaus answers *304 Not Modified*, nothing is
  re-downloaded and nothing is reloaded. Be a good citizen with a free dataset:
  fetch at most once an hour.
- **The firewall is never touched.** ufw only ever refers to the *set name*, so
  updating the data never means reloading rules.

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:

```sh
chmod 700 /usr/local/sbin/spamhaus-drop-update
/usr/local/sbin/spamhaus-drop-update
```

```sh
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:

```config
*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:

```config
# 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](https://mailmum.io/posts/monitoring-your-local-postfix-blacklist-with-munin/).

Reload ufw to activate:

```sh
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:

```sh
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
```

```sh
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:

```sh
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:

```sh
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`:

```config
[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`:

```config
[Unit]
Description=Update Spamhaus DROP ipsets daily

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

[Install]
WantedBy=timers.target
```

```sh
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:

```config
# /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`:

```sh
#!/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`:

```config
[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
```

```sh
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:

```sh
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:

```sh
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](https://www.spamhaus.org/sbl/) record
behind each listing, which is handy if you ever need to look up *why* a network
is blocked, or point an [abuse desk](https://mailmum.io/glossary/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:

```sh
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)](https://mailmum.io/glossary/dns-blocklist-dnsbl/) consulted during
the [SMTP](https://mailmum.io/glossary/simple-mail-transfer-protocol-smtp/) transaction, plus your
own [local blacklist for the repeat offenders you see in your
logs](https://mailmum.io/posts/blacklisting-single-hosts-ip-addresses-and-even-networks-in-postfix/).

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