Chapter 21. Packet Filtering

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/.

Note

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.

Enabling and Configuring PF

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.

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.

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.

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.

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).

Default Permit or Default Deny

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.

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.

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.

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.

TCP States

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

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.

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 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.

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.

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:

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.

The set limit Option

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.

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:

Configure any of these by using set optimization and the optimization name.

set optimization conservative


[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.