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 behavior 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 noticeable immediately.

Getting pf Running

On OpenBSD, pf is enabled in /etc/rc.conf.local:

pf=YES

On FreeBSD, it's a kernel module:

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

Runtime control is through pfctl:

pfctl -e                    # enable pf
pfctl -d                    # disable pf
pfctl -f /etc/pf.conf       # load config
pfctl -nf /etc/pf.conf      # test config without loading (do this first)
pfctl -sr                   # show current ruleset
pfctl -ss                   # show state table
pfctl -si                   # show info and statistics

Structure of pf.conf

pf.conf has a defined order: macros, tables, options, normalization, then rules. This order matters — pf processes the file top to bottom and some things must be defined before they're referenced.

Macros

Variables that make the config readable and easier to update:

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

Macros are used with a $ prefix: $ext_if, $lan, etc. Changing an interface name or IP range is one line instead of twenty.

Tables

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

table <bruteforce> persist
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>

Add to a table at runtime:

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

Options

set skip on lo              # don't filter loopback traffic
set block-policy drop       # silently drop blocked packets (vs. return RST)
set loginterface $ext_if    # log stats on the external interface
set state-policy if-bound   # bind states to the interface they arrived on

Normalization

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

A Real pf.conf

A complete example for a machine with one external interface acting as a basic firewall:

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

# Block brute-forcers
block in quick from <bruteforce>

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

# Allow specific 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

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

# Allow internal traffic
pass in on em1 from $lan to any keep state

The max-src-conn-rate 15/5, overload <bruteforce> part is pf's built-in brute-force protection: if any source makes more than 15 connections in 5 seconds, it gets added to the bruteforce table and all its existing states are flushed. Elegant.

NAT and Redirection

# Basic masquerade NAT
nat on $ext_if from $lan to any -> ($ext_if)

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

# Redirect local traffic meant for external IP back to internal server
# (hairpin NAT)
rdr on $int_if proto tcp from $lan to $ext_if port 80 -> 192.168.1.10

Logging

Add log to any rule to send matching packets to the pflog0 interface:

pass in log on $ext_if proto tcp to port 22 keep state

Read the log in real time:

tcpdump -n -e -ttt -i pflog0

Or write to a file and inspect later:

tcpdump -n -e -ttt -r /var/log/pflog

Anchors

Anchors let you load additional rulesets from separate files, or update parts of your ruleset without touching the main config. Useful for things like authpf (per-user firewall rules) or separating rules by function:

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