OpenBSD pf — The Packet Filter

pf is OpenBSD's packet filter, introduced in 3.0 as a replacement for IPFilter after a licensing dispute. It's one of those tools where the design decisions are clearly right — the syntax is readable, the behaviour is predictable, and the documentation is excellent. If you've wrestled with iptables and then sat down with pf for the first time, the difference is immediately noticeable.

Getting pf Running

# OpenBSD — /etc/rc.conf.local:
pf=YES

# FreeBSD — /etc/rc.conf:
pf_enable="YES"
pf_rules="/etc/pf.conf"

pfctl — Runtime Control

CommandPurpose
pfctl -eEnable pf
pfctl -dDisable pf
pfctl -nf /etc/pf.confValidate config without loading (always do this first)
pfctl -f /etc/pf.confLoad config (preserves existing states)
pfctl -srShow current ruleset
pfctl -ssShow state table
pfctl -siStatistics — packets, bytes, states
pfctl -F statesFlush all states (drops active connections — use with care)

Structure of pf.conf

pf.conf must follow this order — sections out of order cause errors:

  1. Macros and variables
  2. Tables
  3. Options (set directives)
  4. Normalization (scrub)
  5. Queueing (traffic shaping, if used)
  6. NAT and redirection (nat, rdr)
  7. Filtering rules (pass, block)

Macros

ext_if = "em0"
int_if = "em1"
lan    = "192.168.1.0/24"
ssh_port  = "22"
web_ports = "{ 80, 443 }"

# Used with $ prefix:
pass in on $ext_if proto tcp to port $web_ports keep state

Tables

Tables hold lists of IP addresses and can be updated at runtime without reloading the entire ruleset:

table <bruteforce> persist    # persist: survives pf reloads
table <martians> const { \
    0.0.0.0/8, 127.0.0.0/8, \
    169.254.0.0/16, 172.16.0.0/12, \
    192.0.2.0/24, 224.0.0.0/3 }

block in quick from <martians>
block in quick from <bruteforce>

# Runtime table management
pfctl -t bruteforce -T add 203.0.113.42
pfctl -t bruteforce -T show              # list entries
pfctl -t bruteforce -T expire 86400      # expire entries older than 1 day

Options and Normalization

set skip on lo              # don't filter loopback — filtering lo causes problems
set block-policy drop       # drop blocked packets silently (vs. RST/ICMP unreachable)
set loginterface $ext_if    # collect stats on the external interface
set state-policy if-bound   # bind states to the interface they arrived on

scrub in all fragment reassemble    # reassemble fragments before filtering
scrub in on $ext_if all random-id   # randomize IP ID (fingerprint hardening)

Last-Match Wins — and quick

Unlike iptables (first match wins), pf evaluates all rules and the last matching rule wins — unless a rule has quick, which exits immediately on match:

pf Rule Evaluation Flowchart showing pf evaluating rules top-to-bottom with quick termination. New Packet Arrives 1. block all Match! State = block 2. block in quick... No Match (eval continues) If matched: abort eval 3. pass in ... Match! State = pass Final Action: PASS (Creates State)
By default, pf evaluates the entire ruleset and the last matching rule wins. Adding the quick keyword causes pf to halt evaluation immediately and apply the current state.
block all                           # default deny — baseline
block in quick from <bruteforce>   # quick: exit immediately, no further evaluation
block in quick from <martians>

pass in on $ext_if proto tcp to port 22 keep state   # SSH — evaluated (last match wins)
pass in on $ext_if proto tcp to port { 80, 443 } keep state

A Complete pf.conf

ext_if = "em0"
lan    = "192.168.1.0/24"

table <bruteforce> persist

set skip on lo
set block-policy drop
scrub in all fragment reassemble

# NAT for internal network
nat on $ext_if from $lan to any -> ($ext_if)

# Default deny
block all

# Quick blocks
block in quick from <bruteforce>

# Allow established (with automatic brute-force protection)
pass in on $ext_if proto tcp from any keep state \
    (max-src-conn-rate 15/5, overload <bruteforce> flush global)

# Inbound services
pass in on $ext_if proto tcp to port 22 keep state
pass in on $ext_if proto tcp to port { 80, 443 } keep state
pass in on $ext_if proto icmp all keep state

# Allow all outbound
pass out on $ext_if all keep state

# Internal network
pass in on em1 from $lan to any keep state

The max-src-conn-rate 15/5, overload <bruteforce> flush global part is built-in brute-force protection: more than 15 connections from one source in 5 seconds → that IP gets added to the bruteforce table and all its existing states are flushed. This replaces fail2ban for many use cases.

NAT and Port Forwarding

# Masquerade — whole network behind external interface
nat on $ext_if from $lan to any -> ($ext_if)

# Port forward — incoming port 80 to internal web server
rdr on $ext_if proto tcp to port 80 -> 192.168.1.10 port 80

# Hairpin NAT — internal clients accessing external IP reach internal server
rdr on em1 proto tcp from $lan to ($ext_if) port 80 -> 192.168.1.10

Logging

# Add 'log' to any rule to capture matching packets
pass in log on $ext_if proto tcp to port 22 keep state
block in log on $ext_if all

# Read the pflog0 interface in real time
tcpdump -n -e -ttt -i pflog0

# Analyze a saved pflog file
tcpdump -n -e -ttt -r /var/log/pflog

Anchors

Anchors let you load additional rulesets from separate files — useful for modular configs or per-user rules (authpf):

anchor "webserver"
load anchor "webserver" from "/etc/pf.webserver.conf"

# Update a sub-ruleset without reloading everything:
pfctl -a webserver -f /etc/pf.webserver.conf

Related Firewall Setup Guides

Packet filtering is easier to operate when the underlying platform is documented and maintained. These practical checklists cover the first setup work around the firewall:

References