The name’s Pond, James Pond.
My x86 loaded,
licensed to filter.
Packet filtering and traffic manipulation are among the most basic tools in network security. OpenBSD includes a very powerful in-kernel packet filter,
pf(4)
, or PF. This tool not only performs standard filtering, but it can also inspect, reassemble, redirect, and otherwise abuse packets in several ways; translate addresses in several different directions simultaneously; authenticate users; and manage bandwidth. Along with PF, OpenBSD includes programs that let you turn your system into a load balancer, transparent proxy, or any number of other network devices.
PF is one of the high points of OpenBSD and deserves its own book. That book is The Book of PF, 2nd edition, by Peter Hansteen (No Starch Press, 2010), which goes into detail on many different PF use cases. This chapter covers the basics of PF so that you can protect a small network or an individual server. If you want to protect a web farm and transparently relay traffic to only the active servers with sufficient free capacity to handle the load, get Peter’s book.
That said, not even Peter’s book covers PF in its entirety. OpenBSD lets you fold, spindle, and mutilate TCP/IP far beyond anything any reasonable person could ever expect to support in the real world. For complete details on PF, read the pf(4)
, pfctl(8)
, and pf.conf(5)
man pages, and the OpenBSD PF FAQ at http://www.OpenBSD.org/faq/pf/.
PF is still undergoing active development. While the configuration syntax doesn’t change as often as it used to, check pf.conf(5)
for the latest information on your version of OpenBSD.
The word firewall has been tortured beyond recognition over the past 20 or so years, until it has ceased to mean much of anything in particular. In general, a firewall sits between a private and public network, and controls the traffic between the two.
You can buy a firewall for your cable modem for under $100, and you can purchase an enterprise firewall cluster for $1 million. What’s the difference? They’re all firewalls, much as rats and cats and elephants are all mammals, but some are welcome in your home and most are not.[45] Which you permit, of course, is your personal preference. And firewalls are much the same.
Some firewalls filter application-level traffic. Some only filter based on protocol or ports. Some firewalls inspect protocol flags and ensure traffic sanity. Others just pass packets. And some firewalls just translate network addresses and claim that provides security. Worse, the price tag bears no relationship to the feature set.
At their most basic, all firewalls filter packets and can perform network address translation (NAT). OpenBSD can perform those tasks as well or better than most commercial firewalls. If you want application proxies, however, they don’t come with the core OpenBSD system (with the exception of FTP and TFTP proxies, which are necessary for those protocols to function with NAT). Several popular application proxies run quite well on OpenBSD, but they are not part of OpenBSD. For example, I’ve used Squid (/usr/ports/www/squid) and several related packages to build a web proxy and filter on OpenBSD that is comparable to anything the big companies offer, and an assortment of other proxies to manage just about everything else. If you are interested in firewalls, I highly recommend that you assemble your own highly featured firewall from available components at least once, for the sake of education if nothing else.
A firewall is what you make it. You can send all your traffic through a simple OpenBSD packet filter and honestly declare that you have a firewall, or you can set up application proxies, authentication, and so on, and still say you have a firewall. A plain packet filter is a firewall just as much as one of those umpteen-integrated-application-proxy, six-figure-price-tag devices. Remember this the next time someone says he has a firewall.
Realistically, a firewall is not a security device. It is a point of policy enforcement.[46] The firewall doesn’t secure anything; it prevents access to certain services. But blocking access doesn’t secure inherently insecure services—it just means you can’t access them. If your firewall permits access to a service, the firewall doesn’t add any security to that service.
In order to build an effective firewall, you must understand TCP/IP. If Chapter 11 was a revelation to you, get a copy of The TCP/IP Guide (No Starch Press, 2005). Read it. Mark it up. Highlight it. And read it again.
Many of the examples in this chapter assume that you are building a firewall. This means that your host has two or more network interfaces (including VLAN interfaces) and that you want to protect the network on one side from the network on the other side. While this is a popular application for OpenBSD, everything covered here works just as well on individual hosts. I filter packets on lone web servers, on desktops, and on any host sitting naked on the Internet.
OpenBSD enables PF by default at system boot with these rc.conf variables:
pf=YES pf_rules=/etc/pf.conf
To disable PF at boot, set pf
to NO
in rc.conf.local.
The default configuration file for PF is /etc/pf.conf. There’s nothing special about this file—it’s just a standard location. The pf(4)
kernel interface doesn’t read the file directly; the PF control program pfctl(8)
reads the file and sends the configuration to the kernel.
The default PF configuration (hard-coded in /etc/rc) blocks all network traffic except for ICMP and SSH. During boot, PF replaces those defaults with rules from /etc/pf.conf. If an error in pf.conf renders the file unparsable when the system boots, PF can’t load those rules; instead, it retains the default configuration. You’ll be able to connect to your machine to correct your rules, but that’s about it. (And, as anyone who administers remote firewalls can tell you, this ability can save you a lot of driving and phone calls.)
Running PF by default, even with a permissive ruleset, cleans up incoming traffic before the rest of the kernel has to deal with it. PF reassembles packets before handing them to the kernel, and obviously bogus traffic, such as packets too short to be legitimate, is discarded.
If you want to forward packets between interfaces (that is, act like a “firewall”), tell the kernel to forward packets with the net.inet.ip.forwarding
and net.inet6.ip6.forwarding
sysctls. (See /etc/sysctl.conf for commented-out examples.)
#net.inet.ip.forwarding=1 #net.inet6.ip6.forwarding=1
Remove the pound signs and reboot, or use sysctl(8)
to enable and disable packet forwarding on the fly.
Packet filtering is comparing packets to a list of rules and accepting, rejecting, or altering them as those rules dictate. As a network administrator, you get to decide which packets are naughty and which are nice. When you filter packets for a single host, you can legitimately call that host hardened. (The word hardened means almost exactly what firewall means: nothing.) When you send all packets on your network through a single host that filters packets, you have a basic firewall.
A basic packet filter might allow you to filter based on only the TCP or UDP protocol number. Some don’t even allow you to filter by ICMP type or cannot cope with protocols other than those enumerated in the GUI. PF, however, can cope with almost anything you throw at it. If you need a machine to communicate with another over IP protocol 184, PF will support you. Many commercial firewalls won’t let you pass such traffic, or claim that they do but throw a tantrum if you actually try it.
Chapter 11 described how TCP connections can be in a variety of states. A TCP connection that is just starting goes through a three-way handshake process. A client requests a connection by sending a synchronization request, or SYN, packet to the server. The server responds by sending the client an acknowledgment of the SYN, as well as its own SYN request, or a SYN+ACK packet. The client responds with its own ACK.
Every part of this three-way handshake must complete for any actual data to transfer between the two machines. Your packet-filtering rules must permit each part of the three-way handshake and the subsequent data transmission. PF automatically recognizes these three-way handshakes and tracks them through stateful inspection.
PF maintains a list of permitted connections that have completed connection setup, which is called a state table. When a client sends out a SYN packet, PF records that packet in a table and waits for a corresponding SYN+ACK packet. If a SYN+ACK packet arrives at PF, but PF has no record of a corresponding SYN request, the SYN+ACK packet is rejected.
PF has a series of built-in timeouts that dictate how long idle connections remain in the state table, how long to wait for each stage of the three-way handshake, and so on. The state table is self-maintaining, and I’ve never had to adjust any of these timeouts. (On occasion, I have needed to increase the maximum size of the state table.)
UDP is technically stateless, but some applications expect a certain amount of state. When your system transmits a UDP packet, the application might well expect a UDP packet or 10 in response, or no packets, depending on the application.
DNS queries are a common example of UDP packets flowing back and forth, and while UDP has no state, DNS certainly does. (ICMP behaves similarly.) You can have PF either expect this back-and-forth or not, by adding these flows to the state table as your protocol dictates.
PF can also operate without stateful inspection, allowing traffic to and from hosts and ports based on individual packet characteristics. Stateless filtering is slower than stateful inspection, harder to correctly configure, and generally considered less secure and less useful than stateful inspection.
Packets can be mangled during transit, usually by fragmentation. Part of a packet filter’s job is to sensibly reassemble those packets. PF can reassemble and rationalize packets in a variety of ways. (Old versions of PF called this scrubbing.)
One of the essential concepts in packet filtering is the question of default accept versus default deny:
A default accept stance means that you allow any type of connection except what you specifically deny. The default PF rules are an example of a default accept stance.
A default deny stance means that you allow only explicitly permitted connections. All other connections are refused.
Once you have chosen your default, you can adjust your rules to hide or reveal network services as needed. In today’s world, I recommend default deny on all systems, because this stance protects new services as they are added to a system. In most environments where I’ve seen a default accept stance used in the past decade, it’s because the system administrators did not understand the network protocol they were using. This is particularly common in VoIP installations (yes, you can packet filter VoIP servers!)
In addition to packet filtering and reassembly, PF offers several other important features, including NAT, connection redirection, and bandwidth management, to name a few. We’ll consider each separately. All are configured in pf.conf and managed with pfctl(8)
.
Many network administrators who build a firewall carefully filter and restrict incoming traffic, but only apply minimal restrictions on outgoing traffic. While control of incoming traffic is among the most in-your-face issues of network management, control of outgoing traffic is also important.
Even if you trust your users, malware can convert a skilled engineer’s workstation into a garbage-spewing pest. Do not assume that your network can do no wrong. It can be malicious, and one day it will be, but careful traffic control can minimize the damage you inflict on your neighbors, clients, customers, and reputation.
Is there any reason for your staff desktops to connect to any random remote mail server? If not, block it, and even if a workstation is infected with a spambot, the rest of the world won’t blacklist you. Is there any reason for your users to connect to remote DNS servers, or should they use your company’s? Block outbound DNS, and prevent your users from becoming unwitting amplifiers of denial-of-service attacks. I strongly recommend a default deny stance for outbound as well as inbound traffic, and explicitly allowing desirable traffic.
Some networks might be exceptions, of course. If every system on your network runs OpenBSD, you’re pretty safe from routine malware, but already we see malware targeting televisions, Blu-ray, streaming media players, and other appliances with network connectivity. Protect yourself now.
Anytime that you catch yourself thinking that your network can do no wrong, stop and remind yourself that you are not as smart as the combination of every malware author in the world.
Packet filtering controls network connections based entirely on TCP/IP protocols and related characteristics, such as port numbers. If you want to block all traffic from certain IP addresses, packet filtering is your friend. If you want to allow only connections to a particular TCP/IP port, packet filtering will work for you. If you want to allow entrance only to packets with the ECN flag set, but no other flags, PF will support you (even though that’s a pretty daft thing to do).
You can filter protocols that operate at a logical protocol layer such as IPsec, SKIP, VINES, and so on, but only on the network protocol. If it’s a different protocol layer, PF can’t help.
PF can even filter by MAC address. There’s special support for this specific media layer protocol via tags added on bridge(4)
interfaces, as documented in ifconfig(4)
.
Similarly, PF doesn’t know anything about applications or application protocols. If you allow TCP/IP connections to port 25 on a server within your network, you might think that you’re allowing connections to the mail server on that host. Actually, you’re allowing connections to whatever daemon happens to be running on port 25 on that host! PF doesn’t recognize an SMTP data stream; it sees only that the connection goes to port 25. (I have a system that offers SSH on many ports commonly assigned to other services, just so I can saunter past whatever naïve packet filter I happen to be stuck behind.)
Before we dive into PF, let’s look at the basic components of packet filtering on OpenBSD. In addition to the pf(4)
kernel module, we’ll look at the packet filter control program and the configuration file /etc/pf.conf. Knowledge of interface groups also helps.
Use the packet filter control program pfctl(8)
to manage, configure, and extract information from PF. You can see the current packet filter rules and settings, connections being processed, the state of the TCP/IP transactions, debugging information, and all kinds of other details. You can also parse rules files and install them in the actual packet filter.
You’ll see many different options for pfctl
, addressing every aspect of packet-filter management. Many of these are rather lengthy, but you need to type only as much of the word arguments to make a command unique. For example, instead of typing pfctl -s rules
, you can get away with pfctl -sr
because no other argument to pfctl -s
begins with an r
. That said, I give all examples in their full form, as it’s impossible to guarantee that OpenBSD won’t add some other argument that begins with r
in the future.
I focus on using pfctl
for viewing PF output, but OpenBSD also includes PF views in systat(1)
. For a dynamic display of PF activity, somewhat like top(1)
for the network, look at systat
. Run systat
by giving the name of the view as an argument, such as systat pf
. And, as always, any time you want more detail from pfctl
, add one or two -v
arguments for verbose mode.
You configure PF in /etc/pf.conf. The pf.conf file contains statements and rules, whose format varies with the features they configure. You’ll be very good friends with this file before we’re through.
OpenBSD lets you put interfaces in named groups, which you can refer to in PF rules. This abstracts away the actual physical interface, and lets you build policy-based rulesets. Take a look at this interface:
# ifconfig em0
em0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500
…
groups: egress
…
This interface is in the egress
group. An interface is assigned to the egress
group if a default route is reached over it.
To move this interface to a new group, dmz
, remove it from the egress
group and add it to the dmz
group. An interface group is created when you assign the first interface to it, and one interface can be in any number of groups.
# ifconfig em0 -group egress # ifconfig em0 group dmz # ifconfig em0 em0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500 … groups: dmz …
You can now write rules that reference interface groups instead of specific interfaces.
Let’s dismantle the default pf.conf from an OpenBSD system and identify some parts. Many of the default entries are commented out, but identifying them will help you understand how the components fit together.
It begins with an option:
set skip on lo
Options turn features on and off, or set general rules on how other features behave. The skip
option disables PF on a per-interface basis.
Next comes the anchor
setting:
anchor "ftp-proxy/*",
An anchor is a set of dynamic sub-rules for packet filtering. If a packet hits an anchor as it’s processed through the filter rules, it’s dropped into this sub-ruleset for further processing. pfctl
can change the rules running in the kernel, and an anchor is a way of saying, “Add new rules here.”
Anchors are generally used for letting outside software add rules to the firewall. For example, FTP is a complicated protocol that requires all sorts of firewall rules. OpenBSD includes an FTP proxy that dynamically adds the necessary rules for permitted FTP connections.
Then come two packet-filtering rules:
pass in quick inet proto tcp to port ftp divert-to 127.0.0.1 port 8021 pass
The first is a rule to support FTP traffic, in combination with the FTP anchor. We’ll look at anchors and FTP handling in more detail in the next chapter. The other is a much simpler packet-filtering rule, which permits all traffic.
Up next are two tables, which are lists of IP addresses:
table <spamd-white> persist table <nospamd> persist file "/etc/mail/nospamd"
External programs can dynamically alter tables, and you can add addresses to tables directly within pf.conf or in an external file. These two tables are used by the antispam software spamd(8)
.
After the tables is another packet-filtering rule:
pass in on egress proto tcp from any port smtp \
rdr-to 127.0.0.1 port spamd
This rule is interesting in that it refers to an interface group. Traffic is permitted in, as long as it arrives on an interface in the egress
group.
And the final rule is as follows:
block in on ! lo0 proto tcp to port 6000:6010
This packet-filtering rule stops traffic. If a packet arrives on any interface except the loopback interface, and the packet is a TCP protocol going to port 6000 through 6010 inclusive, it is blocked.
This is the sort of thing you’ll see in pf.conf. Let’s dive into some specifics of filtering rules.
Filtering rules are the heart of PF. You can use PF without doing any of the fancy redirection, address translation, load balancing, or redundancy, but packet filtering is the bedrock on which most of these features are based. To start with, however, basic packet filtering is defined as access control for network packets by source, destination, protocol, and protocol characteristics.
PF processes filtering rules in order. The last rule that matches a packet is acted on. A typical packet-filtering rule looks like this:
1pass 2in 3on egress 4proto tcp 5from any 6to 192.0.2.12 7port 80
The first word of the filter rule is a keyword that describes the results of this rule 1. PF will either pass
or block
packets that match a rule. (There’s also match
, which we’ll look at in the next chapter.) The rest of the line is a description of matching packets. If the packet matches the description, the rule is applied.
The second statement is the direction the packet is going. Packets are either going in or out. In this rule, the packet is going in
2—it is entering the system. Not only do we define a direction, but we also define an interface group. Packets must be entering this system on an interface in the egress
group to match this rule 3.
We then have several statements that define traffic characteristics. (This rule is almost like a regular expression for TCP/IP.) This rule applies to TCP connections 4, coming from any IP address 5, if the connection is made to the IP address 192.0.2.12 6 on port 80 7.
If a packet matches all of these characteristics, it can pass. If any of these characteristics isn’t matched, the packet does not match this rule, and PF continues processing the rules, looking for a matching one.
TCP and UDP rules implicitly check connection state. A TCP packet that matches this rule needs to be a SYN packet, the start of a standard TCP/IP connection. PF uses the state table to manage follow-up packets in the same connection (see Filtering Rules and the State Table).
I touched earlier on the idea of default accept versus default deny. Set this stance at the beginning of your packet-filtering rules with one of the following two statements:
pass block
The default pf.conf has a default pass stance, but it’s for people who haven’t yet configured a firewall. I recommend starting your filter rules with a lone block
statement, and then adding rules to explicitly permit desirable traffic. Remember that the last matching rule wins.
One of the most intensive parts of PF is the syntax used to describe packets. Most filter rules describe packets by protocol, port, direction, and other characteristics. PF compares each arriving packet to the state table, and if the packet isn’t part of the state table, it compares the packet to the filter rules. If the rule matches the packet description, the packet is passed or blocked as desired.
Once you define whether you’re in a default accept or default deny stance, the filter rules describe exceptions to your default. So if you block packets by default, most of your filter rules will be pass
statements that describe particular desirable connections.
The keywords in
and out
describe the direction the packets are going. In many commercial firewalls, the word in
means traffic entering the protected network, and out
refers to traffic leaving the protected network. OpenBSD does not magically know which side of the network is protected and which is not. As far as PF knows, it’s managing traffic between two interfaces. The keyword in
means traffic flowing into the machine from the network, and out
means traffic leaving the machine and entering the network.
When you see in
or out
in a PF rule, do not think about your network as a whole. Instead, imagine that you’re very small and sitting on your CPU, grilling steaks over the heat sink and watching packets enter and leave the computer. You cannot see what lies beyond the case, just the packets as they come and go. Packets coming in are approaching you, and packets going out are receding.
The on
keyword describes an interface or interface group to which this rule applies. You must specify an interface.
If you want a rule to match every interface on the system, use the interface name all
. This example stops all traffic entering the machine on the interface fxp0
, but allows all traffic leaving the system on the interface group egress
:
block in on fxp0 pass out on egress
This ruleset implies that interface fxp0
is special for some reason, so it’s not treated like the rest of the egress
group.
Rules can apply to specific address families, either inet
for IPv4 or inet6
for IPv6. Here’s how to prohibit IPv4 but permit IPv6:
block in on egress inet pass in on egress inet6
Presumably, you have later rules that more tightly restrict IPv6.
PF can recognize almost any network protocol by number or name. The proto
keyword tells PF to match a protocol. Network protocols can be given by name from /etc/protocols, protocol number, or even a list (see Using Lists).
block in on egress proto tcp pass in on egress proto udp
You can use this to pass protocols other than IP and IPv6. Here’s how to allow the protocols necessary for IPsec:
pass in on egress proto esp pass in on egress proto ah
This functionality somewhat overlaps the inet
and inet6
statements. If you prefer, you could explicitly allow IP, ICMP, TCP, UDP, and all the various IPv6 protocols.
Almost every filter rule specifies a source and/or destination address.
pass in on egress from 198.51.100.0/24 to 192.0.2.0/24
IP addresses can appear either as individual addresses or as an address with a netmask (as shown in the preceding example). The keyword any
means any IP address. The keyword all
is shorthand for “from any to any.”
You can also use hostnames instead of IP addresses. pfctl
will check the IP address of the host when loading the rules, and insert the actual IP address into the rules.
pass in on egress from www.michaelwlucas.com
If the IP address of the host changes, PF won’t notice until you reload the rules with pfctl
. If the hostname cannot be found, the rules won’t parse, and pfctl
will not be able to load them. I recommend not using hostnames in filter rules, much as I recommend not wearing medieval plate armor while swimming, but it is an available option.
To say “anything but this address,” use the exclamation point as a negation character.
block in from !192.0.2.0/24
This says “block everything except the addresses 192.0.2.0/24.” That’s not the same as saying “pass 192.0.2.0/24,” but it can help simplify your rules.
You can also use lists, macros, and tables as IP addresses. Lists and macros are discussed later in this chapter, and tables are covered in the next chapter.
You can use the name of an interface or interface group instead of an IP address.
pass out on egress from egress
This lets traffic leave via the egress
interface group, from any IP address on any interface in that group, to any IP address.
If you put the interface name or group in parentheses, PF updates its rules whenever the IP address on the interface changes. This is useful for dial-up connections, or if you add and remove IP addresses from an interface.
pass out on egress from (egress)
You can specify a network that is directly attached to an interface or an interface group by following the name with :network
.
pass in on egress from egress:network
Suppose the egress
group has only one interface, and that interface has an IP address of 192.0.2.88/25. This rule would translate to the following:
pass in on egress from 192.0.2.0/25
This rule means that any host on the local network to an egress
interface can communicate anywhere. When you add another interface to the egress
group, the rules automatically update to accommodate the new interface’s network.
To filter on broadcast traffic for an interface or group, use the :broadcast
modifier.
block in on egress from egress:broadcast
Again, suppose that the egress
group has only one interface, and that interface has an IP address of 192.0.2.88/25. This rule would translate to the following, blocking broadcast traffic on the local subnet:
block in on egress from 192.0.2.127
Use the :peer
modifier to indicate the IP address of the far side of a point-to-point link, such as a dial-up connection.
pass in on egress from egress:peer
Here, we completely trust our dial-up provider.
To use only the first IP address on an interface, add the :0
modifier with an interface or group name.
pass out on egress from (egress:0)
The egress
interface group might have 98 IP addresses scattered across three interfaces, but only one address on each interface is the first address. This host can communicate out through the egress
interface group, but only from primary IP addresses. The aliased IP addresses cannot initiate outbound connections.
The problem with the :0
modifier is that the kernel has a very weak idea of what is the “first” address on an interface. The kernel has a list of addresses associated with an interface. The address at the top of this list is the “first” or “main” address at the moment, but this address can change. If this might cause problems, specify an IP address in your rule rather than rely on :0
.
You can attach :0
to any of the other interface modifiers, that is, to IP addresses other than the first from the rule. OpenBSD can’t tell if IP addresses on remote machines are aliases or actual IP addresses, but you can prohibit traffic to or from aliases on the local machine.
Note that the first address on an interface is either an IPv4 address or an IPv6 address. If you want to allow the first address of each protocol, specify the address family in the rule.
pass out on egress inet from egress:0 pass out on egress inet6 from egress:0
Otherwise, PF will use only the first address it sees, regardless of address family.
Filter rules can describe TCP and UDP ports.
pass in on egress proto tcp from any to 192.0.2.12 port 80
This example permits access to TCP port 80 on the server 192.0.2.12. Presumably, this is a web server.
You could use a service name from /etc/services instead of a port number, or even use a list (as described later in this chapter). You can also use ranges, as shown in Table 21-1.
Symbol | Meaning |
| Not equal |
| Less than |
| Greater than |
| Less than or equal to |
| Greater than or equal to |
| Range |
| Inverse range |
For example, to specify all ports over 1024, you could use the greater-than operator (>
).
pass in on egress proto tcp from any to 192.0.2.12 port > 1024
To specify all ports between 1000 and 2000, excluding both 1000 and 2000, use the range operator (><
).
pass in on egress proto tcp from any to 192.0.2.12 port 1000 >< 2000
To include ports 1000 and 2000 in your range, use the inclusive range operator (:
). (Note that you cannot have space on either side of the colon.)
pass in on egress proto tcp from any to 192.0.2.12 port 1000:2000
To pass traffic on all ports less than 1000 and greater than 2000, use the inverse range operator (<>
).
pass in on egress proto tcp from any to 192.0.2.12 port 1000 <> 2000
Ranges let you express large numbers of ports in very few rules.
The following is a complete ruleset for a desktop machine, using many of the features described previously. We’ll look at some more complicated rulesets later, but this illustrates many basic principles of PF rules.
Interface group egress
is attached to the public network, and interface group inside
is connected to my private network.
1 set skip on lo 2 block 3 pass in on egress from egress:network 4 pass in on inside from inside:network 5 pass in on egress proto tcp from any to egress:0 port 22 6 pass out all
The first rule disables packet filtering on the loopback interface 1, and the second defines a default deny stance 2. The second and third rules permit all connections from IP addresses directly connected to the external 3 and internal interfaces 4. If I install a web server on my desktop, I want to be able to view it from any machine on the network I control. Then I permit inbound SSH connections from anywhere in the world to the primary IP address on any egress
interface 5. Finally, I permit all outbound traffic, so my desktop can freely access the outside world 6.
I’ve said before that PF rules are processed in order, and these rules illustrate that. I establish a default, blocking all traffic, and then use individual rules to carve out exceptions to that global block.
For your PF rules to take effect, you must load them into the kernel using pfctl -f
.
# pfctl -f /etc/pf.conf
First, pfctl
reads and parses the rules file. If the file parses correctly, pfctl
expands any variables in the file, performs any necessary DNS lookups to transform hostnames into IP addresses, and feeds the complete rules into the kernel. The kernel reads the new rules, and then swaps between the old and new rules in one operation. At no time are the packet-filtering rules missing, scrambled, or a hybrid of the two rulesets. Also note that pfctl -f
won’t enable PF if it’s disabled.
Personally, I like to know that my edited packet-filter configuration parses before the scheduled change time. It’s embarrassing to announce to your team that “the new firewall configuration will be active at noon” and spend the whole time tracking down a misplaced comma or a parenthesis where you should have put in a curly brace. To test your syntax without installing the rules, use the -n
flag with -f
. Add -v
for verbose mode, to see how pfctl
expands your macros, groups, and so on.
# pfctl -nvf /etc/pf.conf
The rules might still have errors, but only errors of comprehension rather than syntax.
Loading new rules doesn’t remove any existing open connections or state entries. If my old ruleset allowed outbound SSH connections, and I remove that permission from the newly installed rules, existing SSH connections remain open. I can either specifically kill those connections with pfctl -k
or flush the state table.
To see how these rules are interpreted inside PF, view the currently installed ruleset with pfctl -s rules
. Here are the rules generated by the configuration in A Complete Ruleset:
# pfctl -s rules 1 block drop all 2 pass in on egress inet6 from 2001:db8:4::/64 flags S/SA 3 pass in on egress inet from 192.0.2.0/28 flags S/SA 4 pass in on inside inet from 192.168.1.0/24 flags S/SA 5 pass in on egress inet proto tcp to 192.0.2.5 port = 22 flags S/SA 6 pass out all flags S/SA
The first rule establishes a default deny stance 1. I then specifically allow connections from hosts on the networks local to interfaces in the egress
group, for both IPv6 2 and IPv4 3. This desktop also accepts connections from my private network 4.
The private network permits connections only from IPv4 addresses because the interface in the private group has only an IPv4 address. (I really should add an IPv6 address, but it hasn’t caused me any trouble, so I’ll probably forget all about it once again.) Then there’s a rule permitting inbound SSH traffic 5, followed by a final rule to pass all outbound traffic 6.
If I change any IP address on my desktop, my firewall rules update to accommodate them. That’s a really nice feature of interface groups. If I moved my desktop regularly, I would put the interface group names in parentheses so PF would watch for IP address changes.
One thing you’ll probably notice is that the pass rules end with flags S/SA
. This means that out of the SYN and ACK flags, matching packets can have only the SYN flag set, indicating that these are requests to establish a connection. You can filter on TCP flags, but doing so requires in-depth understanding of TCP, and most people should never do it. To see how SYN and SYN+ACK packets affect connections, you need to understand the state table.
To see how often a packet triggers each rule, add -v
to the pfctl
command.
To see how the rules impact traffic in a constantly updating display, run systat rules
.
OpenBSD tracks approved connections in the state table. Packets that are part of an approved connection are allowed to pass. Consider this rule from an earlier example:
pass in on egress proto tcp from any to 192.0.2.12 port 80
If a packet matches this rule, and it has the TCP/IP flags that indicate this is the start of a TCP connection, PF permits the connection. PF also makes an entry in the state table. If a packet arrives that matches the state table, PF passes the packet without consulting the rules.
First, we’ll look at a state table entry for a TCP connection. To view the state table, enter pfctl -s states
.
# pfctl -s states 1all 2tcp 3192.0.2.12:80 <- 4198.51.100.227:55635 5ESTABLISHED:ESTABLISHED …
This state table entry represents one specific connection that the packet filter approved. This state applies to all interfaces 1. If a state applies to only one interface, you’ll see the interface name here.
This TCP connection 2 was bound for 192.0.2.12 port 80 3, and came from the host 198.51.100.227 port 55635 4. When the first SYN packet arrived from 198.51.100.227 port 55635, PF added this entry to the state table. When 192.0.2.12 sent a SYN+ACK packet back to 198.51.100.227 port 55635, PF consulted the state table. This was clearly a match to the permitted SYN packet, so PF permitted that packet, even though no explicit rule in pf.conf permitted that connection. Data exchange between these two hosts and these two ports proceeded.
PF knows what an actual TCP/IP data exchange looks like. There’s a three-way handshake in the beginning, and a similar dance when the connection is finished and PF tracks the state of the connection. This particular connection is established on both sides 5, meaning that the initial setup negotiation succeeded, and data can flow back and forth freely.
If your server is busy enough, and you keep refreshing the state table view, you’ll catch connections in other states. Here’s the same connection as the data exchange ends and is being torn down:
all tcp 192.0.2.12:80 <- 198.51.100.227:55635 FIN_WAIT_2:FIN_WAIT_2
One possible problem with viewing the state table is that pfctl
displays a snapshot. By the time your eyes scroll down the screen, the table has changed. Personally, I find that’s the only way I can cope with the information. If you need to view states in a constantly updating display, in near real time, run systat states
.
The state table is very specific. A state table entry permitting 198.51.100.227 port 55635 to 192.0.2.12 port 80 does not permit traffic between other hosts and ports. PF knows how traffic should flow, and it won’t allow traffic that isn’t obviously part of an existing TCP/IP exchange. If a packet arrives from 198.51.100.227 that looks like it’s part of this data exchange, except that it comes from port 55634 instead of 55635, the state table entry won’t match. Similarly, if PF knows that the connection is in a FIN_WAIT_2
state, or almost finished, a subsequent data packet with an ACK flag set won’t match and will be discarded. This is because a SYN request from the same host, from the same port, should not arrive—the client should know that the port is busy closing the previous connection. A new connection should come from a different port on the client and create a new state table entry.
Without stateful inspection, you would need to write firewall rules that not only permitted incoming traffic, but also permitted the responses. Your firewall rules would need to permit outbound connections to thousands of high-numbered ports, instead of just the single ports attached to desirable connections. Filtering based on TCP flags would be nearly impossible.
As a consultant in the 1990s, I made a couple of rent payments dismantling such rules that had been shoehorned into stateless packet filters because they just aren’t realistic without stateful inspection. Plus, carefully tracking data exchanges not only simplifies rules, but also prevents a whole slew of TCP/IP-based attacks. You don’t hear much about these attacks anymore, thanks to stateful inspection.
The state entries for UDP connections are similar to those for TCP connections.
all udp 192.0.2.12:53 <- 198.51.100.227:38469 SINGLE:MULTIPLE
This is a DNS query, bound for 192.0.2.12 port 53 from 198.51.100.227 port 38469. The client sent a single packet, and the destination replied with multiple packets. While stateful inspection cannot identify the state of this connection by flags, it can track the source and destination addresses and ports. You would need to write only a single rule permitting access to 192.0.2.12 port 53, and stateful inspection would permit the matching reply packets.
ICMP falls somewhere in between TCP and UDP. PF is aware of ICMP types and knows legitimate responses to ICMP packets, and by using stateful inspection, you get all of these benefits automatically. Much as you could write rules that permit specific TCP flags, you can write rules that permit certain ICMP types and codes. Most of us cannot manage that, and those of us who can know better. (ICMP errors referring to an existing TCP or UDP state are matched to the state, and don’t need to be allowed separately.)
PF includes many ways to have one rule reference several similar items, or symbolically represent something with a variable. The basic ways are lists and macros.
A list is a way to represent several similar items in one rule. You might want to use a list if, for example, you want a particular group of TCP ports open on a certain group of hosts, and your rule entries would be repetitions of one rule with minor changes. Opening ports 80 and 443 to one host requires two rules: one for each port. If you have 30 web servers, you would need 60 rules. This is a pain to maintain and error-prone, but lists let you express these common elements more easily.
A list is represented in curly braces within a rule. To make the rule more readable, you can put a comma between items.
pass in on egress proto tcp from any to 192.0.2.12 port {80, 443}
This one pf.conf statement creates two rules, opening both TCP ports 80 and 443 to the target host.
pass in on egress from any to 192.0.2.12 port = 80 flags S/SA pass in on egress from any to 192.0.2.12 port = 443 flags S/SA
You could also use a list to have this rule cover multiple web servers.
pass in on egress proto tcp from any to {192.0.2.12, 192.0.2.13} port {80, 443}
This expands to four rules: one for each combination of server and port.
Remember that each entry in the list creates its own rules. The list entries do not combine to create a single rule.
A macro is a variable that you create and define for use within PF rules. Macros keep pf.conf more readable, maintainable, and manageable.
Macro names must begin with a letter, but can include letters, numbers, and underscores. You cannot give a macro a name that’s used elsewhere in PF, like pass
, block
, or proto
. Frequent uses of macros include interface names, network addresses, and ports.
Earlier, we saw a list that included the popular web ports 80 and 443. You could make these a macro, as follows:
web_ports="{80, 443}"
Our sample rule would then become this:
pass in on egress proto tcp from any to 192.0.2.12 port $web_ports
When combined with braces, macros can simplify your pf.conf file. Consider the following pf.conf snippet:
webservers="{192.0.2.12, 192.0.2.13, 192.0.2.14, 192.0.2.15}" web_ports="{80, 443}" pass in on egress proto tcp from any to $webservers port $web_ports
This expands to eight rules, but requires only three easy-to-understand configuration statements. When you add a new web server, add its IP address to the list in the webservers
macro. What’s more, you might use the webservers
macro in dozens of places throughout your rules. Changing the IP address list once is much easier and more likely to be correct than doing so in each rule.
While you probably use interface groups to represent IP addresses local to your machine, you might have other IP addresses that you need to represent. Macros are great for this, too.
internal_ip="10.10.0.0/16"
Or if you have multiple disparate blocks, you could use a list inside the macro.
internal_ip="{10.0.0.0/24, 10.0.5.0/24, 10.0.10.0/24}"
You don’t see macros or lists when viewing the running PF rules with pfctl
; instead, you see the rules that they expand to.
Lists can be counterintuitive, and it’s easy to write lists that negate other rules. For example, this seems like it should work:
clients = "{192.0.2.0/24, !192.0.2.128/29}" pass in on egress from $clients
The idea here is that our clients have the IP addresses 192.0.2.0/24. We want to permit all of those addresses except for the small chunk in the middle, 192.0.2.128/29. That seems reasonable, right? But much like excluding commands from sudo(8)
, this breaks. Remember that each entry in a list expands into another rule. This creates two rules.
pass in on egress inet from 192.0.2.0/24 flags S/SA pass in on egress inet from ! 192.0.2.128/29 flags S/SA
The first rule passes in everything from the 192.0.2.0/24 subnet. That’s what we wanted. The second rule, however, passes in everything that’s not in the subnet 192.0.2.128/29, also known as “everyone in the world”—not what we were hoping to achieve.
Similarly, negating an entire list expands to negating each individual item in the list. If you need to do this sort of exclusion, use a table, as described in the next chapter.
All sorts of weird traffic arrives at Internet hosts. Some of that traffic is broken garbage. Other parts tell you that someone else is running broken garbage.
PF tries to sanitize and normalize traffic before otherwise processing it. The normalizations include discarding illegal packets, packet reassembly, and packet modification.
Some of the random stuff that arrives at a host is garbage. If a packet is shorter than the IP header, it can’t be a real IP packet, and if a TCP packet is too short to include a full TCP header, it can’t be a real packet.
If the packet length doesn’t match the length given in the header, it’s somehow corrupt. PF has no way to figure out where these packets came from, or if they’re maliciously damaged or just corrupted in transit. Since the kernel can’t do anything with them, PF automatically drops them.
Before the packet filter can decide how to handle a packet, the packet should be free of ambiguities and random weirdness. Reassembly cleans up these ambiguities, and the default reassembly settings are suitable for most environments. You get reassembly when you enable PF.
Sometimes you need to modify packets. These days, PF handles everything for most environments. If you need to modify packets, such as clearing the “do not fragment” bit on fragmented UDP packets, see the scrub
keyword in pf.conf(5)
.
Another classic IP attack is sending packets that appear to come from the private network to a firewall, in an attempt to evade the packet filter. Most firewalls today block this type of attack, so attackers rarely bother, but you should still protect against spoofed attacks. Just because everyone else has had their measles shot doesn’t mean you should go without one.
For an antispoofing rule, use antispoof for
and an interface name.
antispoof for fxp0
When fed into the packet filter, the rules would look something like this:
block drop in on ! fxp0 inet from 192.0.2.5/28 block drop in inet from 192.0.2.5 set ( prio 0 )
The first rule drops any traffic that arrives from an address local to interface fxp0
on any interface other than fxp0
. Packets from an address local to interface fxp0
should always arrive on your system via fxp0
.
The second rule drops any traffic that comes from the address of interface fxp0
. Packets with that source address should never arrive from the outside world. If the system needs to communicate with itself, it uses interface lo0
.
You could use interface groups instead of interface names, but I don’t recommend doing so. If you have multiple egress
interfaces, using antispoofing rules on the egress group won’t block outside packets that arrive at the wrong egress interface. Take the time to enumerate your interfaces in your antispoofing rules.
Instead of listing a single interface, you can also use a list or a macro.
antispoof for {lo0, fxp0, em0}
Antispoofing rules can mess with packets passed over the loopback interface. I recommend skipping filtering on lo0
, although PF includes special built-in protection for 127.0.0.0/8 addresses.
Now that you have basic packet filtering, let’s consider some of PF’s core settings.
Options are basic settings that affect core PF functions. Options answer questions like these:
Do we reassemble fragments into packets?
How many entries should the state table support?
Is logging on?
All options start with the set
keyword. Because options affect how all other parts of PF operate, I recommend placing them at the very top of pf.conf.
Here, we’ll look at some of the more commonly used options.
Will your firewall silently drop forbidden packets, or respond to the client with “sorry, not allowed?” The block policy determines which approach it takes. By default, PF drops blocked packets, but you can override the global block policy on individual filter rules.
Strictly speaking, when PF drops packets, it should return an error to the client, so that legitimate clients can immediately recognize that they cannot connect. Using set block-policy return
tells PF to return these polite errors: an RST for TCP connections and an ICMP unreachable message for other types of connections.
Unfortunately, politeness has largely been overwhelmed by the modern Internet. PF’s default, set block-policy drop
, tells PF to not return any kind of error on blocked packets. Client applications such as web browsers, vulnerability scanners, worms, and other malware must wait for the network protocol to time out before realizing that they cannot connect.
I recommend dropping blocked packets silently.[47]
PF includes limits on the size of various internal tables used to track fragments, states, address tables, and other memory-consuming items. I have needed to adjust these limits on very rare occasions. The existing limits are chosen because they are sufficient for most users in most environments.
View the existing limits with pfctl
.
# pfctl -s memory
states hard limit 10000
src-nodes hard limit 10000
frags hard limit 1536
tables hard limit 1000
table-entries hard limit 200000
Let’s take a look at what each limit represents.
When PF receives a fragmented packet, it holds onto that fragment and waits for other fragments of that packet to arrive. Once it has all the pieces, it reassembles the fragment and processes it. The frags
limit controls the number of packet fragments awaiting reassembly at one time. (You shouldn’t need to change this.)
To see the total number of fragments PF has processed, and how many arrive per second, use pfctl -s info
and look at the Counters section.
# pfctl -s info
…
fragment 368 0.0/s
…
This host has been sitting on the naked Internet for three months in an Internet colocation site, and has received only 368 fragments. I do not need to increase PF’s memory for fragments, and I certainly don’t want to reduce the limit in case I receive a sudden barrage of fragments.
If you suspect that fragments are flowing in, run systat pf
for constantly updating counters of PF statistics.
PF can track a number of states per source address. You might want to limit each client to, say, 10 connections to a specific server. This connection limit includes connections being set up and those still waiting to finish. Here’s an example of this sort of rule:
pass in proto tcp to $webserver port 80 keep state(max-src-states 10)
PF’s load balancer features use src-nodes
to help track which clients are attached to which servers, through the sticky-address
and source-track
options.
If you use these features, and think you might be out of source nodes, check usage with pfctl -s Sources
.
The states
limit controls how many entries can be in the stateful inspection list. The default of 10,000 is adequate for most environments.
You can view the current usage with pfctl -s info
.
# pfctl -s info
Status: Enabled for 1 days 18:01:06 Debug: err
State Table Total Rate
current entries 30
searches 54510751 6.3/s
inserts 2459724 0.3/s
removals 2459694 0.3/s
…
I have needed to change the state table more than once. Each time, it was because of a strangely written application that required clients to make dozens of connections to a single TCP/IP port. I’m certain that the application developers had their reasons for doing so (possible reasons do include ignorance and malice). Multiplied by thousands of simultaneous users, that became a lot of states. As I wasn’t in a position to tell the developers to write their application like normal people, I had to adjust the state table.
If you suspect that the state table is having trouble, use systat pf
and/or systat states
to view state activity in real time.
The tables
and table-entries
limits control how many tables PF can create, and how many entries can go into a single table. I have never had to adjust these, and I would suggest that if your filter rules need more than 1000 tables, you should probably reconsider how you’ve designed it. A table might need to hold more than 100,000 addresses, but that’s very much the exception these days.
To change a limit, use set limit
, the name of the limit, and the new value. Here’s how to double the size of the default state table:
set limit states 20000
Again, don’t change these defaults lightly. Increase them only if existing limits cause a specific problem. And don’t decrease them, or you won’t be prepared for problems and spikes.
PF includes a variety of timeouts, which default to values reasonable for the modern Internet. Some environments, such as satellite uplinks, do require slightly different timeouts.
You can adjust PF’s timeouts with set optimization
. (The name is a leftover from the early days of PF, but has stuck around.) This has four values:
normal
The
normal
optimization is the default. If you don’t specify an optimization, the standard timeouts are used.
conservative
The
conservative
optimization is for environments where you want to be absolutely sure you don’t time out connections. (State table entries will stick around longer.) This setting uses more memory and processor time—possibly much more on a busy network. I use it to ease the minds of managers of industrial networks who are less concerned about buying more hardware and more concerned about the possibility of a meeting caused by some executive’s idle connection timing out.
high-latency
If you connect over a satellite uplink or carrier pigeon, use the
high-latency
optimization.
aggressive
If you have a busy firewall, with many connections coming and going, you might try the
aggressive
optimization. This times out idle connections more quickly, reducing memory and processor use. Many people report thataggressive
timeouts work perfectly well in their environments, but if low timeouts cause trouble for you, turn them off.
Configure any of these by using set optimization
and the optimization name.
set optimization conservative
You can tell PF to not manage an interface. By default, it watches all interfaces, but some interfaces don’t really require filtering. Your loopback interface, lo0
, passes traffic only from the local machine to itself. Packet filtering on lo0
is an interesting educational exercise, but not terribly useful in production.
set skip on lo0
You can also specify multiple interfaces to skip.
set skip on {lo0 fxp0 fxp1}
It’s fairly common to skip filtering on the physical interfaces beneath a trunk in favor of filtering on the trunk itself.
This will get you started with packet filtering. If you have a single server with simple functions, you can protect it quite nicely using the techniques covered in this chapter. But PF can do a lot more than what we’ve talked about here, such as control bandwidth and have applications dynamically change rules. In the next chapter, we’ll touch on a few of PF’s more advanced functions.
[45] Sorry, cats and elephants, find your own place to live.
[46] Blatantly stolen from Henning Brauer. Thankfully, he’s so sick of this book by now, he won’t notice.
[47] Mind you, if PF included an option to insult the client when a packet is dropped, somewhat like sudo, I would need to change my recommendation. But that’s a fault in the underlying network protocol, not PF.