Hack #45. Firewall with OpenBSD’s PacketFilter

Use OpenBSD’s firewalling features to protect your network.

PacketFilter, commonly known as PF, is the firewalling system available in OpenBSD. While it is a relatively new addition to the operating system, it has already surpassed IPFilter, the system it replaced, in both features and flexibility and has even become a part of FreeBSD as of 5.3-RELEASE. PF shares many features with Linux’s Netfilter, and while Netfilter is more easily extensible with modules, PF outshines it in its traffic normalization capabilities and enhanced logging features.

OpenBSD supports PF out of the box. However, under FreeBSD, you’ll need to enable at minimum the following kernel configuration options:

device pf
device pflog

If you don’t have these options enabled, add them in and rebuild and reinstall your kernel. For more information on how to do that, see the “Building and Installing a Custom Kernel” section of the FreeBSD Handbook.

To communicate with the kernel portion of PF, you’ll need to use the pfctl command. Unlike the iptables command that is used with Linux’s Netfilter, pfctl is not used to specify individual rules, but instead uses its own configuration and rule specification language. To actually configure PF, you must edit /etc/pf.conf.

PF’s rule specification language is actually very powerful, flexible, and easy to use. The pf.conf file is split up into seven sections, each of which contains a particular type of rule. You don’t need to use all of the sections; if you don’t need a specific type of rule, you can simply omit that section from the file.

The first section is for macros. In this section, you can specify variables to hold either single values or lists of values for use in later sections of the configuration file. Like an environment variable or a programming-language identifier, a macro must start with a letter and may contain digits and underscores.

Here are some example macros:

EXT_IF="de0"
INT_IF="de1"
RFC1918="{ 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8 }"

You can reference a macro later by prefixing it with the $ character:

block drop quick on $EXT_IF from any to $RFC1918

The second section allows you to specify tables of IP addresses to use in later rules. Using tables for lists of IP addresses is much faster than using a macro, especially for large numbers of IP addresses, because when a macro is used in a rule, it will expand to multiple rules, with each one matching on a single value contained in the macro. Using a table adds just a single rule when it is expanded.

Thus, rather than using the macro from the previous example, you could define a table to hold the nonroutable RFC 1918 IP addresses:

table <rfc1918> const { 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8 }

The const keyword ensures that this table cannot be modified once it has been created. Specify tables in a rule in the same way that they were created:

block drop quick on $EXT_IF from any to <rfc1918>

You can also load a list of IP addresses into a table by using the file keyword:

table <spammers> file "/etc/spammers.table"

If you elect not to use the const keyword, you can add addresses to a table by running a command such as this:

pfctl -t spammers -T add 10.1.1.1

Additionally, you can delete an address by running a command like this:

pfctl -t spammers -T delete 10.1.1.1

To list the contents of a table, you can run this command:

pfctl -t spammers -T show

In addition to IP addresses, you can also specify hostnames. In this case, all valid addresses returned by the resolver will be inserted into the table.

The next section of the configuration file contains options that affect the behavior of PF. By modifying options, you can control session timeouts, defragmentation timeouts, state-table transitions, statistic collection, and other behaviors. Specify options by using the set keyword. The available options are too numerous to discuss all of them in any meaningful detail; however, we will discuss the most pertinent and useful ones.

One of the most important options is block-policy . This option specifies the default behavior of the block keyword and can be configured to silently drop matching packets by specifying drop . Alternatively, you can use return to specify that packets matching a block rule will generate a TCP reset or an ICMP unreachable packet, depending on whether the triggering packet is TCP or UDP. This is similar to the REJECT target in Linux’s Netfilter.

For example, to have PF drop packets silently by default, add a line like this to /etc/pf.conf:

set block-policy drop

In addition to setting the block-policy, you can collect other statistics, such as packet and byte counts, for an interface. To enable this for an interface, add a line similar to this to the configuration file:

set loginterface de0

Note that you can collect these statistics on only a single interface at a time. If you do not want to collect any statistics, you can replace the interface name with the none keyword.

To better utilize resources on busy networks, you can also modify the session-timeout values. Setting the timeout interval to a low value can help improve the performance of the firewall on high-traffic networks, but at the expense of dropping valid idle connections.

To set the session timeout (in seconds), put a line similar to this one in /etc/pf.conf:

set timeout interval 20

With this setting in place, any TCP connection that is idle for 20 seconds will automatically be reset.

PF can also optimize performance on low-end hardware by tuning its memory use regarding how many states can be stored at any one time or how many fragments may reside in memory for fragment reassembly. For example, to set the number of states to 20,000 and the number of entries used by the fragment reassembler to 15,000, you could put these lines in your pf.conf file:

set limit states 20000
set limit frags 15000

Alternatively, you could combine these entries into a single statement like this:

set limit { states 20000, frags 15000 }

The next section is for traffic normalization rules. Rules of this type ensure that packets passing through the firewall meet certain criteria regarding fragmentation, IP IDs, minimum TTLs, and other attributes of a TCP datagram. Rules in this section are all prefixed by the scrub keyword. In general, just putting scrub all is fine. However, if necessary, you can get quite detailed in specifying what you want normalized and how you want to normalize it. Since you can use PF’s general filtering-rule syntax to determine what types of packets a scrub rule will match, you can normalize packets with a great deal of control.

One of the more interesting possibilities is to randomize all IP IDs in the packets leaving your network for the outside world. In doing this, you can make sure that passive operating-system-determination methods based on IP IDs will break when trying to figure out the operating system of a system protected by the firewall. Because such methods depend on analyzing how the host operating system increments the IP IDs in its outgoing packets, and your firewall ensures that the IP IDs in all the packets leaving your network are totally random, it will be pretty hard to match them against a known pattern for an operating system.

IP ID randomization also helps to prevent enumeration of machines in a network address translated (NAT) environment. Without random IP IDs, someone outside the network can perform a statistical analysis of the IP IDs being emitted by the NAT gateway in order to count the number of machines on the private network.

To enable random ID generation on an interface, put a line like this in /etc/pf.conf:

scrub out on de0 all random-id

You can also use the scrub directive to reassemble fragmented packets before forwarding them to their destinations. This helps prevent specially fragmented packets (such as packets that overlap) from evading intrusion-detection systems that are sitting behind the firewall.

To enable fragment reassembly on all interfaces, simply put the following line in the configuration file:

scrub fragment reassemble

If you want to limit reassembly to just a single interface, change it to something like:

scrub in on de0 all fragment reassemble

This will enable fragment reassembly for the de0 interface.

The next two sections of the pf.conf file involve packet queuing and address translation, but since this hack focuses on packet filtering, we’ll skip those. This brings us to the last section, which contains the actual packet-filtering rules. In general, the syntax for a filter rule can be defined by the following:

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

In PF, a rule can have only two actions: block and pass. As discussed previously, the block policy affects the behavior of the block action. However, you can modify the block action’s behavior for specific rules by specifying block along with the action, as in block drop or block return. Additionally, you can use block return-icmp, which will return an ICMP unreachable message by default. You can also specify an ICMP type, in which case that type of ICMP message will be returned.

For most purposes, you’ll want to start out with a default deny policy; that way, you can later add rules to allow the specific traffic that you want through the firewall.

To set up a default deny policy for all interfaces, put the following line in /etc/pf.conf:

block all

Now you can add rules to allow traffic through the firewall. First, keep the loopback interface unfiltered by using this rule:

pass quick on lo0 all

Notice the use of the quick keyword. Normally, PF will continue through the rule list even if a rule has already allowed a packet to pass, in order to see whether a more specific rule that appears later in the configuration file will drop the packet. The use of the quick keyword modifies this behavior, causing PF to stop processing the packet at this rule if it matches the packet and to take the specified action. With careful use, this can greatly improve the performance of a ruleset.

To prevent external hosts from spoofing internal addresses, you can use the antispoof keyword:

antispoof quick for $INT_IF inet

Next, you’ll want to block any packets that have a nonroutable RFC 1918 IP address from entering or leaving your external interface. Such packets, unless explicitly allowed later, would be caught by your default deny policy. However, if you use a rule to specifically match these packets and use the quick keyword, as follows, you can increase performance:

block drop quick on $EXT_IF from any to <rfc1918>

If you want to allow traffic destined for a specific web server (say, 192.168.1.20) into the network, use a rule like this:

pass in on $EXT_IF proto tcp from any to 192.168.1.20 port 80 \
  modulate state flags S/SA

This will allow packets destined to TCP port 80 at 192.168.1.20 only if they are establishing new connections (i.e., if the SYN flag is set), and will enter the connections into the state table. The modulate keyword ensures that a high-quality initial sequence number (ISN) is generated for the session, which is important if the operating system in use at either end of the connection uses a poor algorithm for generating its ISNs.

Similarly, if you want to pass traffic to and from a particular email server (say, IP address 192.168.1.21), use this rule:

pass in on $EXT_IF proto tcp from any to 192.168.1.21 \
  port { smtp, pop3, imap2, imaps } modulate state flags S/SA

Notice that you can specify multiple ports for a rule by separating them with commas and enclosing them in curly braces. You can also use service names, as defined in /etc/services, instead of specifying the services’ port numbers.

To allow traffic to a specific DNS server (say, 192.168.1.18), add a rule like this:

pass in on $EXT_IF proto tcp from any to 192.168.1.18 port 53 \
  modulate state flags S/SA

This still leaves the firewall blocking UDP DNS traffic. To allow it through, add a rule like this:

pass in on $EXT_IF proto udp from any to 192.168.1.18 port 53 \
  keep state

Notice that even though this is a rule for UDP packets, you have still used the state keyword. In this case, PF will keep track of the connection using the source and destination IP address and port pairs. Also, since UDP datagrams do not contain sequence numbers, the modulate keyword is not applicable. Using keep state instead specifies stateful inspection when not modulating ISNs. In addition, since UDP datagrams do not contain flags, simply omit them.

Now you’ll want to allow connections initiated within the network to pass through the firewall. To do this, you need to add the following rules to let the traffic into the internal interface of the firewall:

pass in on $INT_IF from $INT_IF:network to any
pass out on $INT_IF from any to $INT_IF:network 
pass out on $EXT_IF proto tcp all modulate state flags S/SA
pass out on $EXT_IF proto { icmp, udp } all keep state

In the past few releases, the popular passive OS fingerprinting tool p0f has been integrated into PF. This enables PF to ascertain the operating systems running on hosts sending traffic to or through a system running PF. Consequently, you can create PF rules that are operating-system-specific. For instance, if you want to block traffic from any system that isn’t running Linux, you can use something like this:

block in
pass in from any os "Linux"

But beware that OS fingerprinting is far from perfect. It’s entirely possible for someone to modify the characteristics of her TCP/IP stack to mimic another operating system [Hack #65].

Once you’re done editing pf.conf, you can enable PF by running the following commands:

# pfctl -e
# pfctl -f /etc/pf.conf
            

The first line enables PF, and the second line loads your configuration. If you want to make changes to your configuration, just run pfctl -f /etc/pf.conf again. To enable PF automatically at startup under OpenBSD, add the following line to /etc/rc.conf.local:

pf=YES

FreeBSD is slightly different. You’ll instead need to add the following line to /etc/rc.conf:

pf_enable="YES"

The next time you reboot, PF should be enabled.

As you can see, OpenBSD has a very powerful and flexible firewalling system. There are too many features and possibilities to discuss here. For more information, look at the excellent PF documentation available online, or the pf.conf manpage.