paulgorman.org/technical

OpenBSD PF

(2017, updated 2021)

PF (packet filter) is the OpenBSD firewall. PF is enabled by default.

PF selectively passes or blocks data packets on a network interface based on the Layer 3 (IPv4 and IPv6) and Layer 4 (TCP, UDP, ICMP, and ICMPv6) headers. The most often used criteria are source and destination address, source and destination port, and protocol. A series of rules specify matching criteria and the action block or pass. PF is a last-matching-rule-wins firewall.

An implicit pass all at the beginning of the ruleset means that if a packet does not match any filter rule the packet passes. A best practice is to add an explicit block all as the first rule of a ruleset.

The syntax for rules is, roughly:

action [direction] [log] [quick] [on interface] [af] [proto protocol]
	[from src_addr [port src_port]] [to dst_addr [port dst_port]]
	[flags tcp_flags] [state]

PF is stateful. A state table stores info about each connection, so PF knows if a packet belongs to an already established connection. If the packet belongs to an established state, it skips further rule evaluation. When a rule creates a state, all further packets match the state, as well as all replies. By default, all “pass” rules create a state upon a match (which can be disabled with “no state”). ICMP traffic associated with an established TCP state also match. Although UPD is a “stateless” protocol, PF creates states for UPD connections that last for a limited lifetime.

Address “spoofing” is when a malicious user fakes the source IP address in packets they transmit. PF offers some protection with the antispoof keyword. Use antispoof only on interfaces with an IP address, and skip to loopback interfaces (really, skip all filterng on loopback anyhow).

antispoof [log] [quick] for interface [af]

set skip on lo0
antispoof for em0 inet

PF reads its configuration rules from pf.conf(5) at boot time, as loaded by the rc scripts.

The pf.conf file has multiple parts:

Lists group multiple things in a rule, like protocols, port numbers, and addresses. Lists are defined in curly braces. They can be used in-line or assigned to variables. Lists can be nested.

block out on fxp0 from { 192.168.0.1, 10.5.32.6 } to any
trusted = "{ 192.168.1.2 192.168.5.36 }"
pass in inet proto tcp from { 10.10.0.0/24 $trusted } to port { 22 80 443 }

Avoid negated lists (e.g. “{ 10.0.0.0/8, !10.1.2.3 }”), because each list item expands to add another rule. For example, “pass in on fxp0 from { 10.0.0.0/8, !10.1.2.3 }” expands to the undesirable and unintended:

pass in on fxp0 from 10.0.0.0/8
pass in on fxp0 from !10.1.2.3

Macros are user-defined variables that can hold IP addresses, port numbers, interface names, etc. Macros (as seen above) can expand to lists. Macors can be defined recursively.

ext_if = "em0"
block in on $ext_if from any to any
host1      = "192.168.1.1"
host2      = "192.168.1.2"
all_hosts  = "{" $host1 $host2 "}"

Tables hold IP addresses. Enclose table names in carrots. Lookups are faster against tables than against lists, so tables are best for holding large address lists. Create tables with the table directive. Populate tables from files with persist. Unlike lists, it’s safe to use negation in tables (e.g. “table { 192.0.2.0/24, !192.0.2.5 }”), since table entries don’t expand to multiple rules. Hosts may also be specified by their hostname. When the hostname is resolved, all resulting IPv4 and IPv6 addresses are placed in the table. IP addresses can also be entered into a table by specifying a valid interface name, interface group, or the self keyword; the table then contains all IP addresses assigned to that interface or group, or to the machine (including loopback addresses).

table <goodguys> { 192.0.2.0/24 }
table <rfc1918>  const { 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8 }
table <spammers> persist
block in on fxp0 from { <rfc1918>, <spammers> } to any
pass  in on fxp0 from <goodguys> to any
table <spammers> persist file "/etc/spammers"
block in on fxp0 from <spammers> to any

NAT and Redirection

# echo  'net.inet.ip.forwarding=1' >> /etc/sysctl.conf
# echo  'net.inet6.ip6.forwarding=1' >> /etc/sysctl.conf

NAT is specified as an optional nat-to parameter to an outbound pass rule. Often, rather than being set directly on the pass rule, a match rule is used.

match out on interface [af] \
   from src_addr to dst_addr \
   nat-to ext_addr [pool_type] [static-port]
[...]
pass out [log] on interface [af] [proto protocol] \
   from ext_addr [port src_port] \
   to dst_addr [port dst_port]

Ports may be redirected to internal hosts:

pass in on egress proto tcp from any to any port 80 rdr-to 192.168.1.20

pfctl

The pfctl utility configures rulesets and parameters, and retrieves status info from PF.

pfctl -vnf /etc/pf.conf         Check /etc/pf.conf for errors, but do not load ruleset.
pfctl -F all -f /etc/pf.conf    Flush all NAT, filter, state, and table rules and reload /etc/pf.conf.
pfctl -e                        Enable PF.
pfctl -d                        Disable PF.
pfctl -s [ rules | nat | states ]    Report on the filter rules, NAT rules, or state table.
pfctl -k host                   Kill all state entries originating from "host"
pfctl -s states -vv             Show state ID's, ages, and rule numbers
pfctl -s rules -vv              Show rules with stats and rule numbers
pfctl -s Tables                 List tables
pfctl -s info                   Show filter stats and counters
pfctl -s all                    Show everything
pfctl -t foo -T show            Show the contents of table "foo"
pfctl -t foo -T add xx.xx.xx.xx Add address "xx.xx.xx.xx" to table "foo"
pfctl -t foo -T delete xx.xx.xx.xx    Delete address "xx.xx.xx.xx" from table "foo"

Example Ruleset

In this example, our firewall NAT’s traffic for two internal networks.

int_if = "{ axen0 axen1 }"
lan_net = "{ 10.0.1.0/24 10.0.2.0/24 }"
table <friendly_ip_addrs> { xxx.0.107.153, xxx.148.204.214, xxx.191.51.254 }
table <firewall> const { self }
table <martians> { 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 224.0.0.0/3 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 }
tcp_pass_out = "{ bootps, bootpc, dhcpv6-client, dhcpv6-server, domain, https, ipp, nicname, ntp, ssh, www, 6667, 6697, 11371, 44422 }"
udp_pass_out = "{ bootps, bootpc, dhcpv6-client, dhcpv6-server, domain, nicname, ntp, 11371, 60000:61000 }"
set block-policy drop
set loginterface egress
set skip on lo0
match in all scrub (no-df)
match out on egress inet from !(egress:network) to any nat-to (egress:0)
block in quick on egress from <martians> to any
block return out quick on egress from any to <martians>
block in quick from urpf-failed
block log
# Pass traffic to and from the local network.
pass in on $int_if proto tcp from $lan_net to port $tcp_pass_out
pass in on $int_if proto udp from $lan_net to port $udp_pass_out
pass in on $int_if proto icmp from $lan_net
pass in on $int_if proto icmp6
pass out on $int_if to $lan_net
# Pass tcp, udp, and icmp out on the external (Internet) interface.
# TCP connections will be modulated, udp/icmp will be tracked statefully.
pass out on egress proto { tcp udp icmp icmp6 } all modulate state
# Outside access
pass in log on egress from <friendly_ip_addrs>
pass on egress proto { tcp udp } to self port { 67 68 }

Packet Capture

For rules marked log, PF makes any matching packets available on a pflog network pseudo-device (see [pflog(4)](https://man.openbsd.org/pflog)). With pf and pflogd enabled, OpenBSD creates one such device by default: pflog0.

$  ifconfig pflog0
pflog0: flags=141<UP,RUNNING,PROMISC> mtu 33136
        index 4 priority 0 llprio 3
        groups: pflog

The tcpdump utility can monitor pflog0 in (nearly) realtime:

#  tcpdump -n -e -ttt -i pflog0

However, reading directly from the interface is not always the best approach. pflogd records packets in a pcap log file (/var/log/pflog).

#  tcpdump -n -e -ttt -r /var/log/pflog port 80 and host 192.168.1.3

N.B. — for stateful rules, PF only logs the first packet! To log all packets, either tag the rule no state or specify log (all).

It’s possible (and not unreasonable) to create an additional pflog interface for a particular use:

#  ifconfig pflog1 up
#  tcpdump -n -e -ttt -i pflog1

With a PF rule like:

pass in log (all, to pflog1) on egress proto tcp from <myhosts> to any port 443