A Newbie's Guide to Setting up PF on OpenBSD 4.x

Getting Ready
Level: Beginner

Eric Bullen. (ericb-howto@thedeepsky.com)
Sr. Systems Engineer, Yahoo! Inc. January 11, 2008

Since PF replaced IPF on OpenBSD starting with OpenBSD 3.0, it has become a world-class firewalling solution. Within PF, there are some excellent facilities to help the firewaller build a robust solution providing a protection for private networks in a hostile internet. The goal of this article is to give you a good step-by-step on setting up your PF firewall, and explains each step sufficiently, but not so deep that it would confuse.

There are a few things that you need to have ready prior to following this how-to. I will outline how to get these things ready so that we can get to the meat-and-potatoes of this article. The first thing that you need is OpenBSD version 3.0 or later. At a mininum, you should have OpenBSD already installed. I have geared this article for OpenBSD 3.3. While you can find many good how-to's on setting up the firewall, my main focus is how to build and configure your firewall config correctly from the ground-up.

First and formost, you should have your firewall plugged into a network (with at least one other computer on it as well) so that it can receive and send traffic. If you do not have this, it will be very difficult to test. Often times if a network is not available, but you have two computers, you can connect the two computers directly to each other (network card to network card) by using a special Cat-5 cable called a "cross-over cable". I will not go into how to make one here, but any decent computer store carries them. If you plan on setting up NAT (Network Address Translation- where multiple computers can share a single IP address), then you should have two NICs (network interface cards) installed.

Few things are more important to have secure than a firewall. While building/configuring/testing your firewall, it should (ideally) NOT be accessible via the internet, and should be on an internal network. Please refer to RFC1918 for a list of private network blocks, but most ineternal networks are either 192.168.0.0/16 or 10.0.0.0/8. You should be comfortable with setting this up, and if you find this difficult, I would recommend that you do NOT set up a firewall at this point.

Setting Things Up

To activate PF, and have it start automatically on boot-up, edit your /etc/rc.conf.local file so that you add the line pf=YES to it. Although, you can edit the /etc/rc.conf file, it contains the system defaults, and should not be touched. Instead you can make your "overridden" changes in /etc/rc.conf.local, and the system will read that last, and update any changes you had made in it. Here is the contents of my /etc/rc.conf.local file:

Listing 1. /etc/rc.conf.local

#!/bin/sh -
pf=YES                  # Packet filter / NAT
pf_rules=/etc/pf.conf           # Packet filter rules file
pflogd_flags=                   # add more flags, ie. "-s 256"

    	     

Next, create a "pass all" pf.conf so that on boot-up, OpenBSD will read it in. Here is a good template for you to use:

Listing 2. /etc/pf.conf

## Macros 
SYN_ONLY="S/FSRA"


## TABLES 


## GLOBAL OPTIONS 


## TRAFFIC NORMALIZATION 


## QUEUEING RULES


## TRANSLATION RULES (NAT)


## FILTER RULES 
pass in log all keep state 
pass out log all keep state 

    	     

If you look at Listing 2, you will see some place holders beginning with a "##". This will be explained later, but it is good to put it in there now so you know the order in which PF will process the rules file. If you look at the last two lines that begin with "pass..." you can see that it is going to log traffic going in and out of the system. As your system makes or receives connections, it is going to get logged in your /var/log/pflog file. I will show you later how you can read this (it's a binary file, and can not be read in a text editor). Ideally, while you are setting up your firewall, the machine should send and receive little to no traffic unless it is generated by you- it will help you see what is getting logged if you are the one creating traffic. At this point, go ahead and reboot your system for all the changes you have made to take affect, and to make sure everything starts up automatically.

Once you have rebooted, it's time to check to see if everything you made changes to came up successfully. You should try the below section, and make sure that the pflog0 interface is up, and that your rules got loaded.

Listing 3. Checking the pflog0 interface and pfctl -s rules output

cerberus:~# /sbin/ifconfig pflog0
pflog0: flags=141<UP,RUNNING,PROMISC> mtu 33224
cerberus:~# 
cerberus:~# pfctl -s rules
pass in log all keep state
pass out log  all keep state
cerberus:~# 

    	     

Here in Listing 3, you can see that pflog0 is "UP". This will allow you to attach TCPdump (explained later) to the interface to allow you to watch the traffic in real-time. Like I said above, if your computer is being heavily used, the information will fly by way too fast for you to read. Also, by running pfctl -s rules, you can see that your basic ruleset was loaded (not much of a firewall at this point since it's passing ALL traffic. hehe).

Taking it for a Spin

Now, it's time for you to watch some traffic. Although it will seem a bit cryptic, I will show you what to look for. Once you know how to read this, you will be able to better tailor your fireall to handle your network traffic. Listed below is the output of a few lines from TCPdump. Just type TCPdump -n -e -ttt -i pflog0 at the command-prompt to see what your firewall is doing.

Listing 4. Reading the output of TCPdump -n -e -ttt -i pflog0

cerberus:~# TCPdump -n -e -ttt -i pflog0
TCPdump: WARNING: pflog0: no IPv4 address assigned
TCPdump: listening on pflog0
Sep 17 17:07:07.833264 rule 37/0(match): pass in on fxp0: 11.22.33.44 > 192.168.1.2: 
icmp: echo request 
Sep 17 17:07:07.833486 rule 56/0(match): pass in on fxp1: 192.168.1.2 > 11.22.33.44: 
icmp: echo reply 
Sep 17 17:07:32.725126 rule 6/0(match): block in on fxp0: 55.66.77.88.14350 > 66.92.15.252.7777: S 
3925150538:3925150538(0) win 5840 <mss 1460,sackOK,timestamp 311538809 0,nop,wscale 0> (DF) 
Sep 17 17:07:37.443421 rule 14/0(match): pass in on fxp0: 55.66.77.88.14373 > 66.92.15.252.22: S 
3920978973:3920978973(0) win 5840 <mss 1460,sackOK,timestamp 311539281 0,nop,wscale 0> (DF) 
Sep 17 17:07:38.115817 rule 37/0(match): pass in on fxp0: 55.66.77.88 > 192.168.1.2: icmp: echo request 
Sep 17 17:07:38.116021 rule 56/0(match): pass in on fxp1: 192.168.1.2 > 55.66.77.88: icmp: echo reply
^C
6 packets received by filter
0 packets dropped by kernel

    	     

Ok, the IPs 11.22.33.44, and 55.66.77.88 are fictitious, and have been changed so that the real IPs won't be shown here. The IP 192.168.1.2 is an internal system that is on a private network that is being NATted by the firewall. I will explain why the internal private network is being shown in the logs instead of the external (Internet) IP address later (see the NATting section at the end of this document if you can't wait).

I am going to dissect the below line to show you what each part means:

Sep 17 17:07:37.443421 rule 14/0(match): pass in on fxp0: 55.66.77.88.14373 > 66.92.15.252.22: S 3920978973:3920978973(0) win 5840 (DF)

Sep 17 17:07:37.443421 The current date/time including milliseconds.
rule 14/0(match): The ruleset number that caught the packet. You can view what number PF assigned to each rule by typing: pfctl -g -s rules| grep '^@'
pass in on fxp0: This says that the packet passed, coming in on fxp0 (fxp is a Intel Pro100 driver). You may have a different NIC, so fxp will not be there.
55.66.77.88.14373 > 66.92.15.252.22: S This indicates the from-ip:port > to-ip:port that the packet came from, and was destined to go to. The S at the end indicates the flag that the packet was set to (in this case it was a SYN flag).
3920978973:3920978973(0) win 5840 (DF) Ignore this stuff. It is beyond the scope of this document (mainly for more advanced uses).

This should give you somewhat of a base so that when you start building your ruleset, and by monitoring TCPdump, you will be able to make additions and/or corrections to your specific environment.

Now, you have a basic working knowlege of TCPdump and how to read it's output, and have the framework for a basic pf.conf file. Now it's time to give you a primer into the essential pfctl switches. I am not going to go into all the switches, just the ones you would use 90% of the time.

pfctl -d Diable the packet filter
pfctl -e Enable the packet filter
pfctl -Fa -f /etc/pf.conf Flush all (nat, filter, queue, state, info, table) rules and reload from the file /etc/pf.conf
pfctl -s rules Report on the currently loaded filter ruleset.
pfctl -s nat Report on the currently loaded nat ruleset.
pfctl -s state Report on the currently running state table (very useful).
pfctl -v -n -f /etc/pf.conf This does not actually load any rules, but allows you to check for errors in the file before you do load the ruleset. This is obviously good for testing.

Building Something Useful

Now it's time to roll up our sleeves, and delve into creating a useful PF config file. I would recommend that you leave your current /etc/pf.conf file alone (just in case you panic, and want to start fresh), and use a new one (we'll use /etc/pf.conf-new). The key is to start with a simple config, and build from there- if you get too zealous, and build a 60 line pf.conf file, and it doesn't do what it is supposed to do, you will have a hard time finding where the problem is. So let's start off simple, and build from there once we have a solid foundation.

There are essentially two camps for rules: allow all traffic by default, but explicitly deny what you don't want, and the other is to deny all traffic by default, and explicitly allow what you want. There is a big difference between the two, and can you guess which one I am going to show you? The latter one because it's the most secure.

First off, you should make a list of ports that you want to allow to the machine. At a mininum, allowing ssh (port 22/TCP), auth (port 113/TCP), and ICMP pings are good to start with. If you are running additional services (be VERY selective about what you run on a firewall- ideally, it shouldn't run any), you will obviously want to add those as well. We are going to replace the last two lines from Listing 2 with the following lines. I will explain what each line is later on.

Listing 5. /etc/pf.conf-new

## Macros 
SYN_ONLY="S/FSRA"
EXT_NIC="fxp0"
INT_NIC="fxp1"

# Your Internet IP goes in the EXT_IP variable
EXT_IP="11.22.33.44"

# Your private network IP goes in the INT_IP variable
# if you have two NICs on the machine
INT_IP="192.168.1.1"

## TABLES 


## GLOBAL OPTIONS 


## TRAFFIC NORMALIZATION 


## QUEUEING RULES


## TRANSLATION RULES (NAT)


## FILTER RULES 

# Block everything (inbound AND outbound on ALL interfaces) by default (catch-all)
block all

# Default TCP policy
block return-rst in log on $EXT_NIC proto TCP all
   pass in log quick on $EXT_NIC proto TCP from any to $EXT_IP port 22 flags $SYN_ONLY keep state
   pass in log quick on $EXT_NIC proto TCP from any to $EXT_IP port 113 flags $SYN_ONLY keep state

# Default UDP policy
block in log on $EXT_NIC proto udp all
   # It's rare to be hosting a service that requires UDP (unless you are hosting 
   # a dns server for example), so there typically won't be any entries here.

# Default ICMP policy
block in log on $EXT_NIC proto icmp all
   pass in log quick on $EXT_NIC proto icmp from any to $EXT_IP echoreq keep state

block out log on $EXT_NIC all
   pass out log quick on $EXT_NIC from $EXT_IP to any keep state

# Allow the local interface to talk unrestricted
pass in quick on lo0 all
pass out quick on lo0 all

    	     

We aren't using any of the cool features that PF has (aside from some MACRO definitions), but this is meant to be simple initially, and we can build on it later. One thing that tends to confuse alot of people is the concept of "IN" and "OUT" in a filter rule. The rules are always geared to itself (being the firewall), so when you see an "OUT" in a rule, that means it is leaving the firewall (regardless on what interface), and if you see an "IN" in a rule, then that means it is entering the firewall (regardless on what interface).

One thing that I am a stickler for is keeping the ruleset easy to read. There is some redundancy but it helps keep things grouped together so it's easy to make mental map of what you have- especially when you get to a BIG ruleset. Ok, now for the dissection (I am going to skip over the macro section as it should be pretty self-explanatory).

# Block everything (inbound AND outbound on ALL interfaces) by default (catch-all)
block all

The line above is about as wide open as you can get- it blocks everything on all interfaces and all protocols. This ensures that only what you explicitly allow will be allowed through

block return-rst in log on $EXT_NIC proto TCP all
   pass in log quick on $EXT_NIC proto TCP from any to $EXT_IP port 22 flags $SYN_ONLY keep state
   pass in log quick on $EXT_NIC proto TCP from any to $EXT_IP port 113 flags $SYN_ONLY keep state
     

These three lines are the grouping for all TCP-related traffic. The big differerence between the three lines is the word "quick". Without a "quick" option, the rule that matches a packet gets "tagged" with what the rule says to do, but the rule evalutation continues. If no other rules match, than the "tag" sticks, if a following rule matches, then the tag is replaced with what the new rule says. This is a nice way to set default behaviour for whatever you want, and then specify what you specifically want to do in the following rules. Here in this case, the last two lines have the "quick" option, so that if a packet matches either port 22/TCP or 113/TCP (as well as other constraints specified within the rule), then they are allowed through the firewall (hence the word "pass" in the beginning of the rule, and no other processing will be done on the packet. This is a critical piece to understand, so make sure it makes sense to you.

block in log on $EXT_NIC proto udp all
   # It's rare to be hosting a service that requires UDP (unless you are hosting 
   # a dns server for example), so there typically won't be any entries here.
     

These three lines are much the same as the TCP section above, but this time it is a section focusing on UDP. Like it says in the ruleset, few machines need to listen on incoming UDP unless you are running a DNS server for example. For the most part, you can leave this section like it is. Here you can see that there is no "quick" option for this section- since no other rules match, UDP will be silently dropped. On a side note if a port is closed, the right thing to do is for the server to send back a return-reset if it is a TCP port, but since UDP and ICMP are stateless, you don't have to return anything, so dropping the packet without sending anything back is perfectly legal. I think that hiding the fact that you have a firewall is just as important as having one. No one needs to know you have one because it gives potential attackers more information about what you have. If you have a server hanging on the net, and it receives a UDP packet destined for a port it doesn't listen on, it will drop the packet with no reply, but if it receives a TCP packet, it sends back a tcp-reset packet letting the sender know it's not listening. So, the point is, if your firewall sends back a packet for a closed UDP port, then the attacker can say with a high probability that a firewall sent that, and not a server.

block in log on $EXT_NIC proto icmp all
   pass in log quick on $EXT_NIC proto icmp from any to $EXT_IP echoreq keep state
     

Here is the ICMP section. You should be getting the hang of this stuff by now, so I will keep it short. Although I did say that ICMP is stateless, PF can do some nice tricks to allow for some intelligence when dealing with UDP and ICMP. For example, if you restrict outbound ICMP traffic, but just allow ICMP-pings, if you have a "keep state" flag on that rule, then PF is smart enough to allow ICMP-replies to come back without you needing to create a specific rule for it to come back in. Nice eh?

block out log on $EXT_NIC all
   pass out log quick on $EXT_NIC from $EXT_IP to any keep state
     

Remember that one of the first lines in your new /etc/pf.conf-new file reads block all. This means it will block all outbound traffic as well as inbound traffic (ie. you won't be able to send any traffic out of your firewall. The above two lines permit unrestricted traffic to leave your firewall. If you are super-paranoid, you can lock this down further, and only allow specific traffic to leave the firewall (like web access for example).

# Allow the local interface to talk unrestricted
pass in quick on lo0 all
pass out quick on lo0 all
     

Just like I mentioned in the above paragraph, the block all line blocks ALL traffic on all interfaces, and that includes your local interface as well. The above two lines allow unrestricted access on your local interface. I am sure you notices that there is no "keep state" option on these two lines- the reason for that is that it requires more cpu processing to manage states, so in a case where all traffic is allowed, it's really pointless to keep state.

Final Touches

At this point, using the pfctl commands I listed above, fire up your new ruleset, and try things out. Since I like to have a "log" option for all rules, when you run tcpdump on your pflog0 interface, you should see log entries of connections coming in and out whether it is passed or denied. If you see traffic that is getting blocked, but you want to allow it, you can add that rule to your /etc/pf.conf-new file, and reload, and you should see it passing.

To go a bit Deeper

At this point, you should have the basics down pretty well, but alot was left out that makes PF so great. In this section, I am going to expand on what we discussed earlier, and show you some of the more popular features. I think the best way to approach this is by going through each section of your new /etc/pf.conf-new file, and explain some of these new features.

Macros

The first section is your macro section. This is just a fancy way of creating names (variables), and assigning values to them. This gets really handy when you have to move to a new set of IP addresses, and if you used macros everywhere, you don't have to find them all in your pf.conf file- you just change the macro definition, reload the config, and your'e done. Keep in mind that you can use macros for substituting anything- such as interface names, ips, ports, parts of rules, etc.

Tables

The next section is your tables section. This is a very unique feature, and is kinda like macros, but can be changed without reloading your config, and if the table has alot of IPs (like 1000's), PF will able to do some serious optimization that it wouldn't be able to do with macros. Yes, you read that right, you can add/remove IPs from a table from the commandline, and your firewall will automagically know the new config. Very nice! I am not gong to re-create the pf.conf for you, but I will show you what I have set up for my tables on my firewalls.

## TABLES
table <block_hosts> persist
table <private> const { 10/8, 172.16/12, 192.168/16, 224/8 }
     

Essentially, you create a name for a table (like "block_hosts"), and you can optionally add a flag like const or persist. What const means is that once the table is loaded, you can not change the values of it from the commandline. For persist, it means that the table will stay in ram so you can add to it later even if there are no entries in it. Normally, when a table no longer has any data in it, it is unloaded from memory- persist prevents that from happening. Now, to actually use a table in your rules, you would do something like I have below (which I place at the top of my filter rule section).

# Global filter stuff
block in log quick on $EXT_NIC from <block_hosts> to any
block in log quick on $EXT_NIC from <private> to any
     
Global Options

This section is where you define generally how PF behaves. You will find a wide variety of options to set, and although I will not go through them all, I will list some of the more interesting ones.

## GLOBAL OPTIONS
set loginterface $EXT_NIC
set block-policy return
     

The first option (set loginterface $EXT_NIC) is a must for just about every situation. It allows you to gather some really interesting statistics that normally aren't captured. Here's an example output of one of my firewalls. To get this information, use pfctl -s info. I don't think I need to explain the below output.

cerberus:~/# pfctl -s info
Status: Enabled for 69 days 07:04:35            Debug: None

Interface Stats for fxp0              IPv4             IPv6
  Bytes In                       385597759                0
  Bytes Out                      179194907                0
  Packets In
    Passed                          801631                0
    Blocked                         190642                0
  Packets Out
    Passed                          713633                0
    Blocked                              4                0

State Table                          Total             Rate
  current entries                       16               
  searches                        15272773            2.6/s
  inserts                           218763            0.0/s
  removals                          218747            0.0/s
Counters
  match                           13746071            2.3/s
  bad-offset                             0            0.0/s
  fragment                               0            0.0/s
  short                                  0            0.0/s
  normalize                              0            0.0/s
  memory                             37403            0.0/s
     

The next line (set block-policy return) can be seen as a "catch-all" option. Although in your filter rules section, you specify explicitly how to handle every packet, this should mainly be seen as a saftey-net in case something new comes along that you don't have a rule for.

Traffic Normalization

With PF, it doesn't just pass packets on through, you also have the ability for it to clean up "dirty" packets before it is passed on. To go into detail of what all this means is beyond the scope of this article, but it is generally a good idea to do this. Below is some of the more interesting normalization options that you can use.

## TRAFFIC NORMALIZATION
scrub in on $EXT_NIC all fragment reassemble
scrub out on $EXT_NIC all fragment reassemble random-id no-df

# For NFS
scrub in on $INT_NIC all no-df
scrub out on $INT_NIC all no-df
     

Notice that I scrub all traffic going in and out of my external interface. The option random-id is a obscure option that prevents monitoring systems from detecting how many systems your firewall is NATting for. With the random-id option, they won't be able to detect this. The no-df option is especially good if you are running nfs over that interface (like I am on my internal interface). Although, this isn't specific to the traffic normalization section (but kinda goes along with it), when you put a modulate state option at the end of your filter rules, it hides the shortcomings of different network stacks found on some OS's. The benefit is that if your firewall is port-forwarding traffic to different systems (like port 80 traffic going to Linux, and port 25 traffic going to Solaris), external monitoring systems will be able to determine that 1) You are running two different servers one for each port, and 2) What OS is running on those systems. Please note that if you use modulate state for TCP filter rules, you do not need the keep state option. Also, modulate state works with TCP only.

Queueing

Unfortunately, to properly discuss PF's queueing facility, It would probably be best to write up a separate paper discussing this topic. If there is enough interest, I may do that.

Translation (NAT)

Having a firewall that doesn't do NAT is like using a table with only three legs- the two just go hand in hand. Of course, you must have two interfaces in two different subnet masks for this to work. On a typical firewall, your external inteface is connected to your Internet, and your internal interface is connected to a hub or switch for your internal network (refer back to the private network IP addresses listed earlier). With translation (NAT), you can do some really cool stuff, such as: 1) creating a static map where a specific internal IP address is mapped to a specific external IP address (if you have multiple IPs bound to on your external NIC), 2) Forwarding traffic for a specific port coming in on your ext. NIC, and passing it on to a server on your internal network for it to be handled, and 3) Allowing your private network of computers to access the internet via a single IP address. I will go through how to do each one.

rdr on $EXT_NIC proto TCP from any to 33.11.33.55 port 25 -> 192.168.1.33 port 25
binat on $EXT_NIC from 192.168.1.55 to any -> 11.22.44.33 
nat on $EXT_NIC from 192.168.0.0/16 to any -> 22.33.11.55
     

You should recognise the macros in the above three lines. The first line basically says that traffic coming in on the external interface going for IP address 33.11.33.55 port 25 should be passed on to 192.168.1.33 on port 25.

The second line says that the machine at 192.168.1.55 on the internal interface should "own" the IP address 11.22.44.33 on the external interface. What this means is that all outbound traffic leaving 192.168.1.55 will go out through the IP address 11.22.44.33, and vice-versa. It is important to note that filtering rules still are applied for all traffic.

The last line says that NATting will occur for all machines on the 192.168.0.0/16 network destined to any ip address to appear on the internet as though it is coming from 22.33.11.55.

NOTE: This is one part that will screw up alot of people. Please note that NAT translation happens before any filtering happens. What this means is that if you do any filtering for a NATted IP address, your filter rule has to specify the NATted IP address, not it's external IP address (which would be needed in your ruleset for the port redirection above to work). I will give you an example below.

pass in log quick on $EXT_NIC proto TCP from any to 192.168.1.33 port 25 keep state
     
Grouping

Although, this isn't really a section, it's a very nice feature that PF has. Simply put, it allows you to combine similar rules into one rule keeping your ruleset nice and tidy. It can be completely explained in the below examples:

     pass in log quick on $EXT_NIC proto tcp from any to 11.22.33.44 port 80 keep state
     pass in log quick on $EXT_NIC proto tcp from any to 11.22.33.44 port 443 keep state
     

Becomes...

     pass in log quick on $EXT_NIC proto tcp from any to 11.22.33.44 port { 80, 443 } keep state
     

and

     pass in log quick on $EXT_NIC proto tcp from any to 192.168.1.33 port 53 keep state
     pass in log quick on $EXT_NIC proto udp from any to 192.168.1.33 port 53 keep state
     

Becomes...

     pass in log quick on $EXT_NIC proto { tcp, udp } from any to 192.168.1.33 port 53 keep state
     

Nice eh? This can be a real space-saver, and will make your firewall config much easier to manage. I used the "{ and }" grouping symbols in my table definitions above, and you can use them everywhere in your pf.conf file.

Conclusion

I hope this helped you understand how to set up a PF firewall. While just using these guidelines will build a pretty solid firewall, there are many parts of PF that you should take a deeper look into that you may want to leverage. If you have any comments or suggestions for other articles, please send them to me at the email address listed above.

Resources
About the author
Eric Bullen Eric Bullen is one of those happy go-lucky type of people- NOT! To try to put him in any one category is a futile effort. He is a geek amongst geeks, and enjoys all things computer related especially programming. To find out more about him, please go to his website, and check out his resume, and blog.
$Id: newbie_pf_guide.php,v 1.9 2008/01/11 22:59:59 root Exp $