The OpenBSD packet filter (PF) was introduced a little more than 20 years ago as part of OpenBSD 3.0. In a series of two posts, I invite you to take a short tour of PF features and tools that I have enjoyed using.
At the time the OpenBSD project introduced its new packet filter subsystem in 2001, I was nowhere near the essentially full-time OpenBSD user I would soon become. I did, however, quickly recognize that even what was later dubbed ’the working prototype’ was reported to perform better in most contexts than the code it replaced.
The reason PF’s predecessor needed to be replaced has been covered extensively by myself and others elsewhere, so I’ll limit myself to noting that the reason was that several somebodies finally read and understood the code’s license and decided that it was not, in fact, open source in any acceptable meaning of the term.
Anyway, the initial PF release was very close in features and syntax to the code it replaced. And even at that time, the config syntax was a lot more human-readable than the alternative I had been handling up to then, which was Linux’s IPtables. The less said about IPtables, the better.
But soon visible improvements in user-friendliness, or at least admin friendliness, started appearing. With OpenBSD 3.2, the separate /etc/nat.conf Network Address Translation (NAT) configuration file moved to the attic and the NAT and redirection options moved into the main PF config file /etc/pf.conf.
The next version, OpenBSD 3.3, saw the ALTQ queueing configuration move into pf.conf as well, and the previously separate altq.conf file became obsolete. What did not change, however, was the syntax, which was to remain just bothersome enough that many of us put off playing with traffic shaping until some years later. Other PF news in that release included anchors, or named sub-rulesets, as well as tables, described as “a very efficient way for large address lists in rules”, and the initial release of spamd(8), the spam deferral daemon.
More on these things later; I will not bore you with a detailed history of PF features introduced or changed in OpenBSD over the last twenty-some years.
PF rulesets: The basics
So how do we go about writing that perfect firewall config?
I could go on about that at length, and I have been known to on occasion, but let us start with the simplest possible, yet absolutely secure PF ruleset:
block
With that in place, you are totally secure. No traffic will pass.
Or as they say in the trade, you have virtually unplugged yourself from the rest of the world.
That particular ruleset will expand to the following:
block drop all
But we are getting ahead of ourselves.
To provide you with a few tools and some context, these are the basic building blocks of a PF rule:
verb criteria action … options
Here are a few sample rules to put it into context, all lifted from configurations I have put into production:
pass in on egress proto tcp to egress port ssh
This first sample says that if a packet arrives on the egress — an interface belonging to the group of interfaces that has a default route — and that packet is a TCP packet with a destination service ssh, let the packet pass to the interfaces belonging to the egress interface group.
Yes, when you write PF rulesets, you do not necessarily need to write port numbers for services and memorize what services hide behind port 80, 53 or 443. The common or standard services are known to the rules parsing part of pfctl(8), generally, and with the service names, you can look these up in the /etc/services file.
The interface groups concept is, as far as I know, an OpenBSD innovation. You can put interfaces into logical groups and reference the group name in PF configurations. A few default interface groups exist without you doing anything; egress is one, and another common one is WLAN where all configured Wi-Fi interfaces are members by default. Keep in mind that you can create your own interface groups — set them up using ifconfig(8).
match out on egress nat-to egress
This one matches outbound traffic, again on egress (which in the simpler cases consists of one interface) and applies the nat-to action on the packets, transforming them so that the next hops all the way to the destination will see packets where the source address is equal to the egress interface’s address. If your network runs IPv4 and you have only one routable address assigned, you will more than likely have something like this configured on your Internet-facing gateway.
It is worth noting that early PF versions did not have the matching verb. After a few years of PF practice, developers and practitioners alike saw the need for a way to apply actions such as nat-to or other transformations without making a decision on whether to pass or block the traffic. The match keyword arrived in OpenBSD 4.6, and in retrospect, seems like a prelude to more extensive changes that followed over the next few releases.
Next up is a variation on the initial absolutely secure ruleset.
block all
I will tell you now so you will not be surprised later — if you had made a configuration with those three rules in that order, your configuration would be functionally the same as the one-word one we started with. This is because, in PF configurations, the rules are evaluated from top to bottom, and the last matching rule wins.
The only escape from this progression is to insert a quick modifier after the verb, as in:
pass quick from (self)
This will stop evaluation when a packet matches the criteria in the quick rule. Please use this sparingly, if at all.
There is a specific reason why PF behaves like this. The system that PF replaced in OpenBSD had the top to bottom, last-match wins logic, and the developers did not want to break existing configurations too badly during the transition away from the old system.
So, in practice, you would put them in this order for a more functional setup, but likely supplemented by a few other items.
block all
match out on egress nat-to egress
pass in on egress proto tcp to egress port ssh
For those supplementing items, we can examine some of the PF features that can help you write readable and maintainable rulesets. And while a readable ruleset is not automatically a more secure one, readability certainly helps spot errors in your logic that could put the systems and users in your care in reach of potential threats.
To help that readability, it is important to be aware of these features:
Options: General configuration options that set the parameters for the ruleset, such as
set limit states 100000
set debug debug
set loginterface dc0
set timeout tcp.first 120
set timeout tcp.established 86400
set timeout { adaptive.start 6000, adaptive.end 12000 }
If the meaning of some of those does not seem terribly obvious to you at this point, that’s fine. They are all extensively documented in the pf.conf man page.
Macros: Content that will expand in place, such as lists of services, interface names or other items you feel useful. Below are some examples along with rules that use them:
ext_if = "kue0"
all_ifs = "{" $ext_if lo0 "}"
pass out on $ext_if from any to any
pass in on $ext_if proto tcp from any to any port 25
Keep in mind that if your macros expand to lists of either ports or IP addresses, the macro expansion will create several rules to cover your definitions in the ruleset that is eventually loaded.
Tables: Data structures that are specifically designed to store IP addresses and networks. There were originally devised to be a more efficient way to store IP addresses than macros that contained IP addresses and expanded to several rules that needed to be evaluated separately. Rules can refer to tables so the rule will match any member of the table.
table <badhosts> persist counters file "/home/peter/badhosts"
# ...
block from <badhosts>
Here the table is loaded from a file. You can also initialize a table in pf.conf itself, and you can even manipulate table contents from the command line without reloading the rules:
$ doas pfctl -t badhosts -T add 192.0.2.11 2001:db8::dead:beef:baad:f00d
In addition, several of the daemons in the OpenBSD base system such as spamd, bgpd and dhcpd can be set up to interact with your PF rules.
Rules: The rules with the verbs, criteria and actions that determine how your system handles network traffic.
A very simple and reasonable baseline is one that blocks all incoming traffic but allows all traffic initiated on the local system:
block
pass from (self)
The pass rule lets our traffic pass to elsewhere, and since PF is a stateful firewall by default, return traffic for the connections the local system sends out will be allowed back.
You probably noticed the configuration here references something called (self).
The string self is a default macro that expands to all configured local interfaces on the host. Here, self is set inside parentheses () indicating that one or more of the interfaces in self may have dynamically allocated addresses and that PF will detect any changes in the configured interface IP addresses.
This exact ruleset expanded to this on my laptop in my home network at one point:
$ doas pfctl -vnf /etc/pf.conf
block drop all
pass inet6 from ::1 to any flags S/SA
pass on lo0 inet6 from fe80::1 to any flags S/SA
pass on iwm0 inet6 from fe80::a2a8:cdff:fe63:abb9 to any flags S/SA
pass inet6 from 2001:470:28:658:a2a8:cdff:fe63:abb9 to any flags S/SA
pass inet6 from 2001:470:28:658:8c43:4c81:e110:9d83 to any flags S/SA
pass inet from 127.0.0.1 to any flags S/SA
pass inet from 192.168.103.126 to any flags S/SA
The pfctl command here says to verbosely parse but do not load rules from the file /etc/pf.conf.
This shows what the loaded ruleset will be, after any macro expansions or optimizations.
For that exact reason, it is strongly recommended to review the output of the pfctl -vnf command on configurations you write before loading it as your running configuration.
If you look closely at that command output, you will see both the inet and inet6 keywords. These designate IPv4 and IPv6 addresses respectively. Since the earliest days, PF has supported both, and if you do not specify which address family your rule applies to, it will apply to both.
But this has all been on a boring single-host configuration. In my experience, the more interesting setting for PF use is when the configuration is for a host that handles traffic for other hosts, such as a gateway or other intermediate host.
To forward traffic to and from other hosts, you need to enable forwarding. You can do that from the command line:
# sysctl net.inet.ip.forwarding=1
# sysctl net.inet6.ip6.forwarding=1
But you will want to make the change permanent by putting the following lines in your /etc/sysctl.conf so the change survives reboots.
net.inet.ip.forwarding=1
net.inet6.ip6.forwarding=1
With these settings in place, a configuration (/etc/pf.conf) like this might make sense if your system has two network interfaces that are both of the bge kind:
ext_if=bge0
int_if=bge1
client_out = "{ ftp-data ftp ssh domain pop3, imaps nntp https }"
udp_services = "{ domain ntp }"
icmp_types = "echoreq unreach"
match out on egress inet nat-to ($ext_if)
block
pass inet proto icmp all icmp-type $icmp_types keep state
pass quick proto { tcp, udp } to port $udp_services keep state
pass proto tcp from $int_if:network to port $client_out
pass proto tcp to self port ssh
Your network likely differs in one or more ways from this example. I’ll put some references at the end of Part 2 for a more thorough treatment of all these options.
And once again, please use the readability features of the PF syntax to keep you sane and safe.
NOTE: If you are more of a slides person, the summary for the SEMIBUG user group meeting is available. A version without trackers but ‘classical’ formatting is also available.
Continue reading in A few more of my favourite things about the OpenBSD Packet Filter tools.
Peter N. M. Hansteen is a puffyist, daemon charmer, and penguin wrangler.
Adapted from original post which appeared on BSDLY.
The views expressed by the authors of this blog are their own and do not necessarily reflect the views of APNIC. Please note a Code of Conduct applies to this blog.