Vivian Voss

pf vs nftables: Bruteforce

freebsd linux security unix

The Unix Way ■ Episode 1

Fresh server. Fifteen minutes old. You have installed the operating system, enabled SSH, and gone to make a cup of tea. By the time the kettle boils, auth.log is filling up. Someone in a data centre you have never heard of is methodically trying root, admin, test, oracle, and every default credential known to the collective unconscious of the internet. This is not a targeted attack. It is Tuesday.

The question is not whether to stop it. The question is how.

The Linux Path

On Linux, the standard answer is fail2ban. A Python daemon that watches log files, matches lines against regular expressions, counts the matches, and, after a configurable threshold, calls out to the packet filter to block the offending address. The architecture is admirably straightforward: one daemon reads what another daemon has written, and if the pattern matches often enough, a third system intervenes. Three moving parts. Two log parsers. One race condition you would rather not think about.

The installation proceeds as follows. Install fail2ban. Create jail.local. Define the regular expression for your SSH log format. Configure the action, and here you must choose: nftables? iptables? ufw? firewalld? The Linux firewall ecosystem offers four front ends to two back ends, and fail2ban must be told which dialect you are speaking today. Restart the daemon. Test. Hope.

Now the system is operational. An attacker connects. Attempts a login. Fails. The SSH daemon writes a line to the log. fail2ban reads the log. Parses the line. Matches the regex. Increments a counter. The attacker attempts again. And again. After the configured threshold (typically five, sometimes ten) fail2ban calls nftables and the address is blocked. The attacker has had fifty attempts at the door before anyone thought to lock it. If fail2ban crashes, the door stays open.

Linux: reactive protection Three daemons, two log parsers, one hope Attacker sshd auth.log fail2ban (Python) read → regex → count → act nftables 50 attempts pass before block fires If fail2ban crashes: Protection stops. Silently. install → jail.local → regex → choose backend → action → restart → test

Two systems. Two failure modes. Two things that must be running, configured correctly, and speaking the same dialect for an SSH brute-force attack to be stopped. The architecture is reactive by design: something bad happens, the system notices, and then it responds. The attacker is already inside the conversation before the bouncer has been informed.

The FreeBSD Path

On FreeBSD, the answer is pf. Four lines.

table <bruteforce> persist
block quick from <bruteforce>
pass in on egress proto tcp to port ssh \
    max-src-conn 20 max-src-conn-rate 10/1 \
    overload <bruteforce> flush global

That is the entire configuration. No daemon. No log parsing. No regular expressions. No Python. The firewall itself, running in the kernel, tracks connection states, counts connection rates, and when a threshold is exceeded, adds the source address to a table and drops every packet from it. The word “flush global” terminates all existing connections from that address. Not just future ones. All of them. Instantly.

There is no window of fifty failed attempts. There is no daemon that must remain running. There is no regex that must match the log format of this particular SSH version. The firewall sees the connections directly, in real time, at the network layer. It does not need to be told what an attack looks like. It was told what normal looks like, and it enforces the boundary.

FreeBSD: proactive protection In the kernel. No daemon. Four lines. Attacker pf (kernel) Stateful tracking Rate limit + overload Block + flush global Blocked. Instantly. Built into pf since 2001 Stateful inspection antispoof synproxy dynamic tables Rate limiting Overload actions scrub / normalize

The Difference Is Architectural

The distinction between these two approaches is not one of capability. nftables is a competent packet filter. It can do stateful inspection. It can do rate limiting, with sufficient configuration. The distinction is one of design philosophy.

nftables filters packets. That is its job, and it does it well. But it does not anticipate security problems. It does not ship with built-in rate limiting per source address. It does not have overload tables. It does not offer flush global. These features must be assembled from separate tools, separate daemons, separate configuration files, and separate failure modes.

pf was designed as a firewall that anticipates your problems. Rate limiting is not an extension. Overload tables are not a third-party add-on. SYN proxy is not a kernel module you compile separately. antispoof is a single keyword. scrub normalises packets in one line. These features exist because the people who wrote pf understood that a firewall which only filters packets is solving half the problem.

What ships in the box Feature pf nftables Stateful inspection Built-in Built-in Rate limiting per source Built-in Manual Overload tables Built-in ipset / sets SYN proxy synproxy SYNPROXY target Anti-spoofing antispoof Manual rules Packet normalisation scrub Not available Brute-force protection 4 lines fail2ban Same syntax since 2001. Security was not bolted on. It was designed in.

Twenty-Five Years of the Same Syntax

pf was written by Daniel Hartmeier for OpenBSD in 2001. The syntax has not changed. The pf.conf you write today is the same language as the pf.conf from twenty-five years ago. FreeBSD imported it. The feature set grew (synproxy, antispoof, scrub, dynamic tables, overload actions) but the grammar remained stable. Your configuration does not break on upgrade. Your muscle memory transfers. Your documentation from 2005 still applies.

On Linux, in the same twenty-five years, the packet filtering story has been: ipchains, then iptables, then nftables, with ufw and firewalld as competing front ends, and fail2ban configured differently for each. The tools improve. The churn is the cost. Every migration is a rewrite. Every rewrite is an opportunity for misconfiguration. Every misconfiguration is a window.

The Kernel Advantage

pf runs in the kernel. This is not an implementation detail. It is the architecture. A userspace daemon can crash, can be killed by the OOM killer, can fail to start after a reboot, can have its configuration file syntax-checked only at restart. A kernel packet filter cannot crash independently of the system it protects. If the kernel is running, pf is running. There is no gap. There is no “fail2ban didn’t start because Python had a dependency conflict” scenario. There is no moment between boot and protection.

pf sees attacks in real time because it is the network stack. It does not wait for sshd to write a log line, for the filesystem to flush, for a daemon to read, for a regex to match. The packet arrives. The state table is consulted. The rate is checked. The decision is made. Microseconds, not seconds.

nftables is a capable packet filter. pf is a firewall that anticipates your problems.

Sometimes the elegant solution already exists. It is just not fashionable.