Configuring pf on FreeBSD: A Practical Guide to Packet Filtering
How FreeBSD PF evaluates rules, creates state, uses tables and anchors, performs NAT, logs decisions, and supports safe remote changes.
FreeBSD includes PF, IPFW, and IPFILTER as kernel-level firewall choices. PF originated in OpenBSD and is maintained as a FreeBSD port of that technology. The syntax is recognizably related, but features and versions are not interchangeable: write and validate rules against FreeBSD’s installed pf.conf(5), pfctl(8), and release notes rather than copying the current OpenBSD FAQ blindly.
A firewall ruleset is executable policy. Before enabling it, define the protected interfaces, address families, required services, administrative source networks, routing role, and rollback path. A syntactically valid default-deny ruleset can still lock out the operator or block DHCP, IPv6 neighbor discovery, monitoring, or return traffic.
Enabling pf
PF has kernel support and a userland control program, pfctl. The rc system loads /etc/pf.conf and can enable pflog. Use sysrc to update persistent settings:
sysrc pf_enable=YES
sysrc pf_rules=/etc/pf.conf
sysrc pflog_enable=YES
pfctl -vnf /etc/pf.conf
service pf start
pfctl -sr
pfctl -si
The first command parses and expands the rules without loading them. -v makes the inspection more useful and -n prevents changes. Enabling PF remotely should happen only after a console or independently tested recovery path exists.
The evaluation model: last match wins
For filter rules, PF normally evaluates matching rules in order and uses the last matching rule. quick stops evaluation at the first matching quick rule. This permits a broad policy followed by narrower exceptions, but it also means a late broad pass can undo an earlier block.
# /etc/pf.conf
ext_if = "vtnet0"
admin_net = "192.0.2.0/24"
set skip on lo0
block return log all
pass out on $ext_if inet all keep state
pass in on $ext_if inet proto tcp from $admin_net \
to ($ext_if) port 22 flags S/SA keep state
pass in on $ext_if inet proto tcp to ($ext_if) port { 80, 443 } \
flags S/SA keep state
The documentation prefix is not a production admin network. Replace interface and addresses from actual inventory. to ($ext_if) tracks the interface’s current address, which is useful with dynamic addressing. The example covers IPv4 only; an IPv6 policy needs explicit ICMPv6 and service design rather than an assumption that IPv4 rules automatically cover it.
block return actively rejects where possible; block drop silently discards. Neither is universally superior. Silent dropping can slow legitimate diagnosis, while rejection can reveal policy and is inappropriate for some threat models. Choose deliberately.
Use quick sparingly for policy that must not be overridden:
block quick from <bruteforce>
Inspect the expanded, numbered rules with pfctl -vvs rules; macros and interface expansions can make the active rule differ from the source line an operator expects.
Tables: dynamic lists of addresses
A table is an efficient named address set. persist retains an empty table so runtime clients can populate it. const prevents userland changes after load and is useful for static policy; do not add it to a table that automation must update.
table <bruteforce> persist
block in log quick from <bruteforce>
# Add an offending address at runtime
pfctl -t bruteforce -T add 203.0.113.9
# List current table contents
pfctl -t bruteforce -T show
pfctl -t bruteforce -T delete 203.0.113.9
Runtime table changes can disappear after a reload if the file-backed or declared table is reconstructed differently. Decide whether entries are ephemeral incident response, generated state, or durable configuration. Limit who can invoke pfctl; permission to mutate a block table is permission to deny network access.
State tracking and keep state
State records flow information so return packets can be recognized without independently opening the reverse direction. FreeBSD PF defaults are stateful for pass rules in common cases, but spelling out TCP flags and keep state makes intent reviewable and avoids relying on memory of another PF version.
pfctl -ss # dump the current state table
pfctl -vvs states
Rule reload and state lifetime are separate. Existing states may continue traffic after the matching rule changes unless states are flushed or expire. That is normally valuable for non-disruptive reloads, but it can invalidate a security test. Inspect states before and after a policy change and remove only the intended state when immediate revocation is required.
Interface-bound state policy, asymmetric routing, NAT, and multi-WAN paths can change which return traffic matches. Test from real client paths, not only with nc on the firewall itself.
Anchors: composable sub-rulesets
Anchors provide named ruleset locations that can be maintained separately. The main ruleset must declare where the anchor is evaluated:
anchor "local/*"
load anchor "local/services" from "/etc/pf.anchors/services"
pfctl -a local/services -vnf /etc/pf.anchors/services
pfctl -a local/services -f /etc/pf.anchors/services
pfctl -a local/services -sr
An anchor is not automatically safer than the main file. Its position controls which packets reach it, and a process allowed to reload it controls policy at that point. Use an ownership boundary, a naming convention, and validation for generated anchors.
NAT and redirection
PF translation rules can perform outbound source NAT and inbound redirection. A routing firewall also needs IP forwarding enabled; NAT alone does not turn a host into a router.
nat on $ext_if from 192.168.1.0/24 to any -> ($ext_if)
rdr on $ext_if inet proto tcp to ($ext_if) port 8080 \
-> 192.168.1.50 port 80
pass in on $ext_if inet proto tcp to 192.168.1.50 port 80 \
flags S/SA keep state
PF evaluates filtering with translated addresses according to its documented translation pipeline. The explicit pass rule must match the post-redirection destination as FreeBSD PF exposes it. Confirm the loaded NAT and rules with pfctl -sn and pfctl -sr, then capture on external and internal interfaces.
For an IPv4 router, gateway_enable="YES" in rc.conf controls forwarding through the rc system. IPv6 routing has a separate setting. Enabling forwarding changes the host’s role and requires policies for every participating interface.
Logging and inspecting traffic
pflog exposes logged PF decisions through a pseudo-interface. Only rules carrying log generate those records:
block in log quick from <bruteforce>
tcpdump -n -e -ttt -i pflog0
The PF log includes rule and interface context useful for correlating a block. Logging every packet can consume CPU, disk, and analyst attention; log policy boundaries and unexpected denies, then rate-control upstream or with operational tooling where needed. Packet logs can contain addresses, ports, and payload excerpts and require retention controls.
Use pfctl -si for global status, pfctl -vvs rules for counters, pfctl -ss for states, pfctl -s Tables for tables, and pfctl -sn for translation. A zero rule counter may mean the rule is unreachable, not that the service is quiet.
Testing a ruleset before committing to it
pfctl -nf parses without loading. A controlled remote change should also preserve the current file, keep an existing console or SSH session open, arrange a timed rollback outside PF, and test a second new session before canceling that rollback.
cp -p /etc/pf.conf /etc/pf.conf.pre-change
pfctl -vnf /etc/pf.conf
pfctl -f /etc/pf.conf
pfctl -vvs rules
Parsing proves syntax and expansion, not reachability. Verify DNS if macros depend on names, interfaces if addresses are dynamic, required IPv6 control traffic, DHCP, monitoring, and the administrative source path. On a securelevel that prohibits firewall changes, even root may be unable to restore policy without rebooting into an appropriate state.
PF is strongest when the ruleset is small enough to explain. Default deny, narrowly scoped services, explicit management sources, measured state, and observable counters are easier to audit than hundreds of copied exceptions. Comments should explain business intent and expiry, not restate syntax.
Related:
Sources: