Mastering FreeBSD's pf (Packet Filter): the ruleset structure in /etc/pf.conf, using tables and anchors, scrub for packet normalization, and stateful filtering. You will also learn the nat and rdr rules for port forwarding and basic load balancing.

In the previous episode 11, you built a FreeBSD router with simple pf NAT. Now we dissect pf in depth — FreeBSD's flagship firewall, known for its clean syntax and powerful stateful filtering. It's not just about "allow and deny": with tables, anchors, scrub, and state, pf can build complex yet readable security policies.
All pf rules live in /etc/pf.conf. This file is read line by line and rules are evaluated in order — the last matching rule usually wins. The basic structure:
ext_if = "em0"
lan_if = "em1"
block all
pass in on $ext_if proto tcp to port 22The first lines define macros for the interfaces — a way that keeps the ruleset maintainable. The block all line is a default that denies everything, then pass rules open up what's allowed.
sysrc pf_enable="YES"
service pf start
pfctl -f /etc/pf.conf
pfctl -s rulesDanger
A block all rule at the top without the right pass rules will cut every connection, including administrative SSH. Start with broad pass-through rules, verify, then narrow them down gradually.
pf is stateful: it tracks connections and only allows packets that belong to approved connections. This makes rules much simpler — you just open new connections, and their replies are allowed automatically.
pass in on $lan_if from $lan_if:network to any keep state
pass in on $ext_if proto tcp to port 80 keep statekeep state makes pf record the connection. Without state, you'd have to write rules for both directions — impractical and error-prone.
Info
keep state is not just an optimization — it's also a security protection. pf rejects packets that don't match existing state, preventing attacks that exploit unauthorized connections.
A table is a list of addresses (IPs, CIDRs, networks) that can be updated dynamically without reloading the whole ruleset:
table <bruteforce> persist
block quick from <bruteforce>
pass in on $ext_if proto tcp to port 22pfctl -t bruteforce -T add 203.0.113.7
pfctl -t bruteforce -T showAn anchor is an insertion point that lets rules be managed in separate files or by other processes (like dynamic blacklists):
anchor "spam"
load anchor "spam" from "/etc/pf.spam.conf"scrub in allscrub in all performs fragment reassembly, resets TTLs, and tidies up headers. This is the first line of defense against attacks that exploit fragmented packets.
NAT rewrites the source address of outgoing packets — the basis for sharing one public IP across a whole LAN:
ext_if = "em0"
lan_if = "em1"
nat on $ext_if from $lan_if:network to any -> ($ext_if)rdr (redirect) forwards incoming packets to an internal address or port — the basis of port forwarding:
ext_if = "em0"
web_server = "192.168.1.10"
rdr on $ext_if proto tcp from any to $ext_if port 80 -> $web_server port 80Success
The combination of nat for outgoing traffic and rdr for incoming traffic makes a single FreeBSD machine with pf a complete network perimeter: internal clients are protected, external services are redirected safely.
web1 = "192.168.1.10"
web2 = "192.168.1.11"
rdr on $ext_if proto tcp from any to $ext_if port 80 -> { $web1, $web2 }ext_if = "em0"
lan_if = "em1"
lan_net = "192.168.1.0/24"
table <bad_hosts> persist
set skip on lo0
scrub in all
nat on $ext_if from $lan_net to any -> ($ext_if)
rdr on $ext_if proto tcp to port 80 -> 192.168.1.10 port 80
block all
block quick from <bad_hosts>
pass in on $lan_if from $lan_net to any keep state
pass in on $ext_if proto tcp to port 22,80,443 keep state
pass out all keep statepfctl -s info
pfctl -s states
pfctl -s rulespass log on $ext_if proto tcp to port 22
tcpdump -n -e -ttt -i pflog0Warning
Before guessing at a wrong rule, look at the log first. pf records rejected packets to the pflog0 interface. Use tcpdump -n -i pflog0 to see the rejection reason and which rule matched.
In this episode 12, you mastered pf: the ruleset structure with macros, stateful filtering with keep state, tables for dynamic lists, anchors for modularity, scrub for packet normalization, plus nat, rdr, and load balancing for incoming and outgoing traffic.
Key takeaways:
block all at the top sets a safe default.keep state makes pf track connections and simplifies the ruleset.scrub in all normalizes packets as a fragmentation defense.nat for outgoing traffic, rdr for port forwarding and load balancing.