ONLamp.com    
 Published on ONLamp.com (http://www.onlamp.com/)
 See this if you're having trouble printing code examples


Securing Small Networks with OpenBSD NAT with pf

by Jacek Artymiak
03/06/2003

Catching Up with Changes in pf(4): Macros, Options, Scrubbing, and Network Address Translation

Despite what some doomsayers predicted when the OpenBSD project abandoned ipf (see the famous CVS log entry), the new pf(4) packet filter is doing its job very well and is steadily growing in functionality. A lot has changed since its official appearance in OpenBSD 3.0, and it is high time to have another look at what Daniel Hartmeier and others have been working on.

pf(4) has undergone numerous changes since I last wrote about it. It is now easier to use and has a greater range of available filtering options. If you have never used pf(4) before, you'll be delighted to know that it does packet filtering, network address translation (NAT), load balancing, protects against spam (coming in OpenBSD 3.3), and provides--after the merger with ALTQ--resource-sharing and quality of service control (QoS). In its current shape, pf(4) can be used to secure networks, connect multiple hosts to an external network through a single network interface, balance load between multiple servers, manage bandwidth, and keep logs of its activity. Because of this, pf(4) is quickly becoming an advanced tool for network management and security practitioners.

Related Reading

TCP/IP Network Administration
By Craig Hunt

This article assumes that you are using OpenBSD 3.2 with all of the latest security patches applied. (If you still don't know how to keep your system up to date, here are some hints to get you started.) The examples presented use the DMZ network configuration described earlier in this series. (Additional sources of information on DMZ design can be found in [Zwicky, Cooper, and Chapman 2000] and [Bishop 2003].) I will also answer some recent reader questions. Because there is so much new ground to cover, this article is split into four parts: (1) network address translation, (2) packet filtering (including transparent filtering), (3) ALTQ, and (4) load balancing and a preview of what's coming in OpenBSD 3.3. For the last installment you'll need to be running OpenBSD-current, as not all of the features I will be writing about are available in OpenBSD 3.2.

It also advisable (but by no means required) that you keep a copy of a good book on the workings of TCP/IP at hand while you are reading this. When I get stuck, I usually look things up in [Hunt 2002] or [Stevens 1994]. If these don't answer my questions, I dig through man pages and RFCs. (Browsing through their book catalog, I noticed that O'Reilly & Associates have a book on Internet protocols [Hall 2000], which could be helpful, but I have not read it. Another good book is [McKusick 1996], which focuses on the design of 4.4BSD and includes a lot of information about TCP/IP itself).

General Setup Tips

A firewall does not need expensive hardware to run, just a reasonably fast machine capable of running OpenBSD 3.2, with two or more network interfaces. (Depending on the internal network topology, it could even be just one interface; for example, the DMZ design described in one of my earlier articles uses three network interfaces on the firewall.) An old Pentium or Pentium II PC should be enough to cope with traffic up to 10Mbps even with packet logging enabled. As for the system memory, 32MB of RAM is quite enough although 64MB of RAM gives the operating system more breathing space. External storage is a more important consideration when the firewall is logging large amounts of traffic. This issue was discussed earlier, but I will return to it later in this series. Finally, on the hardware side, make sure that the firewall's network interfaces are capable of working at the speeds of their connected networks, i.e. a 100Mbps card connected to a network working at speeds up to 100Mbps, not a 10Mbps card when the network is working at 100Mbps. In most cases, a 10/100Mbps card solves this problem rather well, but if you want more information about Ethernet, read [Spurgeon 2000].

Ethernet: The Definitive Guide

Related Reading

Ethernet: The Definitive Guide
By Charles E. Spurgeon

Remember not to run any network services and not to store any tools beyond what's needed to run the firewall and the intrusion detection software. The basic set of archives (base32 and etc32) should be enough in most cases. If you need the C/C++ compiler and other software development tools (comp32), run them on a workstation machine and transfer the binaries you create on it to the firewall via scp(1). Similarly, the game32, man32, misc32, xbase32, xfont32, xserv32, and xshare32 archives can be left uninstalled, as they only take up valuable storage space (not to mention the fact that, in case of a break-in, they can be used by the intruder for potential attacks).

/etc/rc.conf

Start the OpenBSD packet filter with pfctl(8), its control tool:

$ sudo pfctl -e -f /etc/pf.conf

To start pf(4) automatically on every system reboot, edit /etc/rc.conf, the "system daemon configuration database", with vi(1) or any other plain text editor:

$ sudo vi /etc/rc.conf

After you open /etc/rc.conf, change the following line:

pf=NO                   # Packet filter / NAT

to:

pf=YES                  # Packet filter / NAT

This file contains two more variables file related to pf(4). First is the rule file location variable:

pf_rules=/etc/pf.conf           # Packet filter rules file

Here you can change the location of the rule file from the default /etc/pf.conf to whatever you like, but it's probably best to leave it unchanged. Another variable of interest is:

pflogd_flags=                   # add more flags, ie. "-s 256"

This variable lists options for the pflogd(8) packet filter logging daemon. We'll look at it more closely a little later.

/etc/pf.conf

pf(4) stores its filtering rules in /etc/pf.conf, a plain ASCII file that you can edit with vi(1) or other plain text editor. Every rule is a single, continuous line that begins with a special keyword. Keywords define a macro, set some global variable, or describe an action to take for packets that match a rule. These keywords are

Note that /etc/pf.conf contains all rules for both NAT and filtering. (They were previously stored in two separate files.) The rule file is divided into five sections: macro definitions, options, scrub, NAT, and filter. Any of these sections may be missing, but the order of sections must be maintained, as in the following:

#################################################################
# macro definitions

#################################################################
# options: "set"

#################################################################
# scrub rules: "scrub"

#################################################################
# NAT rules: "rdr", "nat", "binat"

#################################################################
# filtering rules: "antispoof", "block", "pass"

When you think about it, this grouping is quite practical: later sections rely on the previous ones, so it is natural to use the order that simplifies the work of the firewall administrator. The order of the rules inside each section is a different story. It depends on your packet filtering policy and will be the subject of interest throughout this series.

Basic pfctl(8) Operations

Here are a few basic pfctl(8) operations that you should memorize, as you'll use them often while designing your own rules:

Macro Definitions

NAT and filter rules can quickly become complex, so it is very convenient to define macros to use in place of real names of network interfaces, addresses, protocols, ports, and other repetitive information found in filter rules. Life is much simpler with macros; they make it easier to adapt existing rule sets to changes in hardware configuration. For example, the only necessary change after modifying the external interface on the firewall machine is to edit the macro definition. Macro names must start with a letter from the a-zA-Z range of the lower part of the ascii(7) set and may contain letters from the same range, digits, and underscores. The string that the macro expands to must be enclosed in a pair of double quotes ("). Macros are not expanded recursively for simplicity and security. When you're referring to a macro, precede its name with a dollar sign ($), as in:

#################################################################
# macro definitions

ext_if  = "ne1"
dmz1_if = "ne2"
dmz2_if = "ne3"

#################################################################
# options: "set"

#################################################################
# scrub rules: "scrub"

scrub in on $ext_if all
scrub in on $dmz1_if all
scrub in on $dmz2_if all

Options

The early release of pfctl(8) included with OpenBSD 3.0 (and 3.1) used the -O command-line option to specify the algorithm for filter rules optimization. Because the new version of pf(4) included with OpenBSD 3.2 allows more influence over the filtering engine behavior, these options have moved to the rules file. It is simply more convenient this way.

There are four categories of options that you may define in your packet filter rules file:

The -O option is still available, but its purpose has changed. It tells pfctl(8) to load only the options section from the rules file, as in

$ sudo pfctl -f /etc/pf.conf -O

The already loaded NAT or filter rules will not be modified. Remember that if you disable or remove an option that was defined before, that option will be reset to its default value.

limit

The limit frags n and the limit states m options set hard limits on the number of memory pools used by the packet filter. They tell pf(4) how much memory it can use to store packet fragments (fragments are stored in memory before reassembly and are reassembled when you use scrub rules) and state table entries (for stateful filtering; enable it with the keep state rules in the filter section). If you set this option, pf(4) will store only n fragments or m state table entries. Administrators use this option to avoid performance hits and to prevent attacks from overwhelming the firewall's resources. Both limits are independent. You may set either or both, or you may combine both in a single rule:

#################################################################
# macro definitions

#################################################################
# options: "set"

# ex. 1: limit the number of fragments kept in memory to 30000
set limit frags 30000

# ex. 2: limit the number of state table entries to 25000
set limit states 25000

# ex. 3: combine  
set limit { frags 30000, states 25000 }

You can check the current limits:

$ sudo pfctl -s memory
states     hard limit   25000
frags      hard limit   30000

If a limit is unset, you'll see output similar to this one:

$ sudo pfctl -s memory
states     unlimited
frags      unlimited

Note that there is no way to unset these limits in OpenBSD 3.2. You can change the values of limits, if you can change and reload /etc/pf.conf:

$ sudo vi /etc/pf.conf
$ sudo pfctl -f /etc/pf.conf

To reset limits to their unlimited state, you have to comment out or remove set limit ... rules in /etc/pf.conf, and reboot your firewall:

$ sudo vi /etc/pf.conf
$ sudo reboot

This behavior has been fixed in the -current branch and is expected to appear in OpenBSD 3.3.

For more information about packet fragmentation and reassembly, including the issue of timeouts, consult RFC815 [Clark 1982]. If that doesn't answer your questions, read [Stevens 1994, 2:275-300].

loginterface

The new loginterface ifname option specifies the name of the network interface on which pf(4) will collect statistics. These statistics are

The following example shows how to activate this option:

#################################################################
# macro definitions

ext_if = "ne1"

#################################################################
# options: "set"

# ex. 1: collect statistics on ne1
set loginterface $ext_if

pfctl(8) can display these statistics:

$ sudo pfctl -s info

...
Interface Stats for ne1               IPv4             IPv6
  Bytes In                             760                0
  Bytes Out                            696                0
  Packets In
    Passed                              10                0
    Blocked                              0                0
  Packets Out
    Passed                               6                0
    Blocked                              0                0
...

You can collect statistics for only one interface, even if you use multiple set loginterface rules. This snippet

set loginterface ne1
set loginterface ne2

will only collect statistics on ne2 because it was the last loginterface rule. To switch it off, you must add the set interface none rule (as the last set loginterface rule) to /etc/pf.conf and reload the options with

$ sudo pfctl -O -f /etc/pf.conf

optimization

This rule controls the packet filter engine optimization options. The old optimization options -O found in earlier version of have been replaced with the optimization algorithm rule. There are six values of the algorithm argument:

Don't forget to reload the new options after changing the optimization algorithm:

$ sudo pfctl -O -f /etc/pf.conf

Before you rush to enable these optimization rules, you should know that these algorithms make a difference in special cases like high-latency connections, or very busy corporate, government, or education networks. Small networks and networks with low traffic will see no noticeable performance improvements.

The optimization rule is a shortcut for setting a bunch of timeout rules quickly. If none of them seem to work in your particular setup, consider adjusting the timeout values yourself, as described below.

timeout

The timeout option rule adjusts the expiration time of stateful connections. These rules only apply to packets matching stateful connections (established with the keep state keyword in pass filter rules). The general syntax of this rule is set timeout protocol.connectionstate timeout, for example:

#################################################################
# options: "set"

# ex. 1 sets timeout of the stateful connection to 20 seconds
# after receiving the first packet from the host initializing
# this connection.
set timeout tcp.first 20

# ex. 2 sets timeout of the stateful connection to 20 seconds
# after receiving the first packet from the host initializing
# this connection, then, if the connection is established, 
# every packet that matches the established state of a TCP 
# connection resets the timeout of the TCP connection it is a
# part of to 10 seconds.  This is very aggressive, and will result
# in a high percentage of lost valid connections on slow links. 
set timeout tcp.first 20
set timeout tcp.established 10

# ex. 3 same as ex. 2, but both rules have been combined on a
# single line (the order of protocol.state rules is not relevant)
set timeout { tcp.first 20, tcp.established 10 }

Example 1 above sets a very aggressive rule. If the connection is not established in 20 seconds, it will be dropped. In example 2, the connection will be dropped if the firewall does not receive a packet that is a part of the established TCP connection in 10 seconds. This is a very aggressive setting.

The protocol.connectionstate can be one of these values:

All of the above values match various states of a TCP connection cycle. (To learn more about the TCP connection state transition cycle, consult RFC761 [Postel 1980], and if you are still looking for more information, read [Stevens 1994, 1:240-242, 2:805-807].)

You can check global timeout settings with

$ sudo pfctl -s timeouts
tcp.first                   120s
tcp.opening                  30s
tcp.established           86400s
tcp.closing                 900s
tcp.finwait                  45s
tcp.closed                   90s
udp.first                    60s
udp.single                   30s
udp.multiple                 60s
icmp.first                   20s
icmp.error                   10s
other.first                  60s
other.single                 30s
other.multiple               60s
frag                         30s
interval                     10s

As you can see, it is possible to control other protocols, like UDP, or ICMP, but the number of protocol.state matches is more limited:

other is a catch-all category for protocols which are neither TCP, UDP, nor ICMP.

The last two timeouts (interval and frag) specify the interval between flushing expired states and fragments and the time before unassembled fragments are flushed.

#################################################################
# options: "set"

# this connection.
set timeout interval 20
set timeout frags 20

Because optimization rules reset various timeout settings, you should always list optimization rules before your timeout rules; otherwise your timeout settings will be overwritten the values introduced by optimization rules.

Scrub Rules

Not all IP packets sent over the Internet are well-formed, which may cause problems to hosts or routers running IP stacks that cannot properly handle packet fragmentation and reassembly. Improperly formed packets may be sent by poorly written software running on some external or internal machines, or, quite frequently, by attackers trying to compromise your network.

You can scrub incoming or outgoing packets. There are two schools of thought. One claims that it is enough to scrub only the packets arriving at the external interface from the outside. The other claims that all packets that match in rules on all interfaces (packets sent from the outside and destined to the inside of your network, and packets sent from your network to outside hosts) ought to be scrubbed. While a paranoid mind gravitates toward the second solution, remember that every rule costs CPU cycles and memory. Also, there are times when scrubbing may interfere with network intrusion detection systems (NIDS) because packet normalization may not detect rogue traffic which would be detected if no scrubbing were done. Of course, it is possible to log such packets and set your NIDS to take appropriate action. As you can see, there is no one-size-fits-all solution.

#################################################################
# macro definitions

ext_if = "ne1"

#################################################################
# options: "set"

#################################################################
# scrub rules: "scrub"

# ex. 1: scrub all incoming packets on all interfaces
scrub in all

# ex. 2: scrub all incoming packets on the external interface 
scrub in on $ext_if all

The process of packet normalization with the scrub rules can be refined with these keywords:

Note that only the fragment reassemble modifier works when NAT rules are used at the same time. The fragment crop and fragment drop-ovl do not work with NAT in OpenBSD 3.2.

It is also possible to set some options:

The above options are of interest to administrators of large networks. Their use and the way they affect performance of networks is best described in [Stevens 1994].

All scrub modifiers are listed at the end of the rule:

#################################################################
# macro definitions

ext_if = "ne1"
ext_ad = "any"
prv_ad = "f.f.f.f/24"

#################################################################
# options: "set"

#################################################################
# scrub rules: "scrub"

scrub in on $ext_if all no-df min-ttl 100 max-mss 1440 fragment reassemble
scrub in on $ext_if all no-df min-ttl 100 max-mss 1440 fragment crop 
scrub in on $ext_if from $ext_ad to $prv_ad no-df min-ttl 
   100 max-mss 1440 fragment drop-ovl 

Only IPv4 fragments can be processed in this way. IPv6 fragments are blocked.

For more information on packet normalization, read the pf.conf(5), RFC815 [Clark 1982], and this interesting paper [Handley and Paxson 2001].

NAT

Network address translation (NAT) is a technique for connecting hosts hidden behind firewalls to the Internet. It can be used to redirect traffic between external and internal hosts as a proxy. It's also often used to increase the number of hosts connected to the Internet.

In the early days of the Internet, every host (or, more precisely, every network interface connecting that host to other hosts on the Internet) needed a unique IP number. The dynamic expansion of the number of hosts connected to the Internet during the last decade made it obvious that the old pool of IP numbers will soon end. Of course, the best answer for that is IPv6, but before the world switches to it, NAT will see much use as a technique for better utilization of the existing pool of IPv4 numbers, as well as for some useful security setups.

Diagram.
Figure 1. A general outline of the network described in this article.

By definition, if a host has no public IP address assigned to it, it cannot be reached from the outside, even if you plug it into the connector that your Internet provider gave you, which is good from the point of view of security. It gets even better when you put a firewall between the internal network and the outside network, as shown in Figure 1 above. Then you can carefully log and screen all incoming and outgoing traffic to ensure maximum security. If you split your internal network into two or more segments, then your network becomes even more secure. However, since your internal networks use a private address space (read RFC1918 [Rekhter, Moskowitz, Karrenberg, de Groot, and Lear 1996]), they are unreachable from the outside and they cannot reach outside hosts. Such a setup is secure, but not very functional, which is why we need NAT.

Turning IP Routing On and Off

If you want to run NAT, you must enable IP routing before you load NAT rules. This is done with

$ sudo sysctl -w net.inet.ip.forwarding=1
net.inet.ip.forwarding: 0 -> 1

To turn IP routing off, use this command:

$ sudo sysctl -w net.inet.ip.forwarding=0 
net.inet.ip.forwarding: 1 -> 0

When you're unsure if IP routing is on or off, you can check it with

$ sudo sysctl net.inet.ip.forwarding      
net.inet.ip.forwarding = 0

To make changes permanent and always enable IP routing at system startup, edit /etc/sysctl.conf and change this line:

#net.inet.ip.forwarding=1   # 1=Permit forwarding (routing) of packets

to

net.inet.ip.forwarding=1     # 1=Permit forwarding (routing) of packets

NAT rules

There are three kinds of NAT rules:

While experimenting with your NAT setup, the following commands will make your life a little easier:

rdr

The rdr rules redirect traffic from one port to another. A classic example of using traffic redirection is the case of an HTTP server hidden in a DMZ, yet accessible to hosts outside your network. Ordinarily, such a server must listen on a privileged port 80, and the machine it runs on must be directly accessible to external hosts. This setup is not very safe, so you might consider moving the server behind a firewall, into a DMZ network segment. However, if you do that, the server is inaccessible, because it is the firewall which receives requests for the HTTP server. The firewall must now redirect these packets to the web server residing inside the DMZ network segment. This is accomplished with the following rule:

#################################################################
# macro definitions

ext_if = "ne2"
ext_ad = "f.f.f.f/32"
dmz_ad = "w.w.w.w/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $ext_if proto tcp from any to $ext_ad port 80 -> 
   $dmz_ad port 8080

The above rule redirects all TCP (proto tcp) packets arriving at the firewall's external address (on $ext_if), originating from any source address (from any) and destined to the HTTP server, listening on port 80 (to $ext_ad port 80) to the network interface located in the DMZ (-> $dmz_ad). The server listens on port 8080 (port 8080). That port is unprivileged, and the attacker has less chance of breaking things, should the server be compromised.

As you will soon discover, this rule works for connections made from the outside to your web server, but not from your private network. This is solved by adding another rule:

#################################################################
# macro definitions

ext_if = "ne2"
prv_if = "ne1"
ext_ad = "f.f.f.f/32"
prv_ad = "f.f.f.f/24"
dmz_ad = "w.w.w.w/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $ext_if proto tcp from any to $ext_ad port 80 -> 
   $dmz_ad port 8080
rdr on $prv_if proto tcp from $prv_ad to $ext_ad port 80 -> 
   $dmz_ad port 8080

You can rewrite it in the following way (notice that I put interface names and addresses in curly braces):

#################################################################
# macro definitions

rdr_ifs = "{ ne2, ne1 }" 
rdr_ads = "{ any, p.p.p.p/24 }" 
ext_ad = "f.f.f.f/32"
dmz_ad = "w.w.w.w/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $rdr_ifs proto tcp from $rdr_ads to $ext_ad port 80 
   -> $dmz_ad port 8080

You can use curly braces to list interface names, protocol names, and addresses in rdr rules. You can also replace port numbers with their names, e.g. port www and port 80 are equivalent. These names and numbers can be found in /etc/services.

The above rule could be tightened a little. For example, if you want to redirect only IPv4 packets, add the inet keyword:

#################################################################
# macro definitions

rdr_ifs = "{ ne2, ne1 }" 
rdr_ads = "{ any, p.p.p.p/24 }" 
ext_ad = "f.f.f.f/32"
dmz_ad = "w.w.w.w/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $rdr_ifs inet proto tcp from $rdr_ads to $ext_ad port 80 
   -> $dmz_ad port 8080

Similarly, to redirect only IPv6 packets, use the inet6 keyword.

What if you wanted to redirect all queries to port 80 on all addresses to a web cache? Return to an earlier setup with two separate rules and change the second rule:

#################################################################
# macro definitions

ext_if = "ne2"
prv_if = "ne1"
ext_ad = "f.f.f.f/32"
prv_ad = "f.f.f.f/24"
www_ad = "w.w.w.w/32"
cch_ad = "c.c.c.c/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $ext_if proto tcp from any to $ext_ad port 80 
   -> $www_ad port 8080
rdr on $prv_if proto tcp from $prv_ad to any port 80 
   -> $cch_ad port 1080

In the example above, the web cache listens on port 1080. Note that this technique of forcing everyone on the internal network to connect to the Web through the cache server is controversial, and you must not impose it on your users without careful thought. For more information consult [Wessels 2001].

Web Caching

Related Reading

Web Caching
By Duane Wessels

What if you want to bypass the cache yourself? Use the no modifier, as in:

#################################################################
# macro definitions

ext_if = "ne2"
prv_if = "ne1"
ext_ad = "f.f.f.f/32"
prv_ad = "p.p.p.p/24"
bos_ad = "p.p.p.b/24"
www_ad = "w.w.w.w/32"
cch_ad = "c.c.c.c/32"

#################################################################
# NAT rules: "rdr", "nat", "binat"

rdr on $ext_if proto tcp from any to $ext_ad port 80 -> $www_ad port 8080
rdr on $prv_if proto tcp from $prv_ad to any port 80 -> $cch_ad port 1080
no rdr on $prv_if proto tcp from $bos_ad to any port 80

As you can see, the no modifier makes the -> ... part of the rule unnecessary (and such rules do not parse, as they do not make sense).

Another modifier is !, which negates the values (interface names, source and target addresses) it precedes:

rdr on ! ne1 inet proto tcp from ! s.s.s.s/32 to ! 
   e.e.e.e/32 port 80 -> d.d.d.d/32 port 8080

The above rule redirects all IPv4 TCP packets arriving on any interface except ne1 from any address except s.s.s.s/32 and destined to any address except e.e.e.e/32.

The rdr rules are very handy because they can be used to configure proxies, redirect traffic from a dead host to a backup host. and so on. Recently, rdr rules have been used to fight spam. If a suspicious host tries to connect to the smtp port on your firewall, its request will be directed to a special program that keeps it waiting forever for a connection confirmation; then, just when the spammer's MTA thinks it will be able to send mail it receives an error message and the connection closes. Such delays of several minutes seriously slow spammers and are a good way to make their life harder. OpenBSD 3.3 is supposed to include some interesting tools for fighting spam.

nat

NAT rules perform network address translation for groups of internal hosts, with private addresses hidden behind a firewall, which access the outside world through a single interface with one public IP. (The external interface could have more IP addresses assigned to it, but let's focus on the most severe case.) This not only solves the problem of connecting more than one host through a single interface, but it also hides details of your internal network's layout, the number of hosts, and other information that an intruder may find useful.

The magic is possible because the firewall keeps a record of who sent what and where, so it can send replies to the right host. To do that it must keep a table of sorts and mark packets it sends to the Internet. This marking allows attackers to deduct how many hosts are hidden behind the firewall and gives them an idea of what might be hiding behind your firewall, provided they can capture that traffic. It is also used by companies selling DSL access to the Internet to find out who's breaching their contracts. (Some DSL access providers forbid their customers to use NAT, and impose penalties on those who use it. If your provider does this, consider switching to another.) The latest versions of pf(4) can fool these detection systems, but you need to be running OpenBSD-current, which is still experimental. We'll look at what -current has to offer in part 4.

How do you connect your private network to the outside world? It's quite simple, actually:

#################################################################
# macro definitions

ext_if = "ne1" 
ext_ad = "f.f.f.f/32"
prv_ads = "p.p.p.p/24" 
nat_p = "{tcp, udp, icmp}"

#################################################################
# NAT rules: "rdr", "nat", "binat"

nat on $ext_if proto $nat_p from $prv_ads to any -> $ext_ad

When it is time to add a new network segment, modify the macros:

#################################################################
# macro definitions

ext_if = "ne1" 
ext_ad = "f.f.f.f/32"
prv_ads = "{ p.p.p.p/24, d.d.d.d/24 }" 
nat_p = "{tcp, udp, icmp}"

#################################################################
# NAT rules: "rdr", "nat", "binat"

nat on $ext_if proto $nat_p from $prv_ads to any -> $ext_ad

Shouldn't we use the names of the interfaces that connect our private networks to the firewall? No, address translation is done on the external interface.

Just like rdr rules, nat rules allow us to use the no and ! modifiers before interface names and private host addresses. It is also possible to limit their scope to IPv4 or IPv6 packets (inet and inet6, respectively). The pf.conf(5) man page has more detailed information about using intricate modifiers like binary or unary operators.

binat

The last of the three NAT rules are binat rules, which bind an external public address to an internal private address. VPN setups use this bidirectional translation, and it can provide additional security for hosts exposing public services. These rules are similar to rdr rule, but they do not allow such fine degrees of control. While the following rule set works with rdr rules, it is not possible with binat rules.

rdr on $ext_if proto tcp from any to $ext_ad port 22 -> 192.168.1.1 port 1022 
rdr on $ext_if proto tcp from any to $ext_ad port 25 -> 192.168.1.2 port 1025 
rdr on $ext_if proto tcp from any to $ext_ad port 53 -> 192.168.1.3 port 1053
rdr on $ext_if proto tcp from any to $ext_ad port 80 -> 192.168.1.4 port 8080

Compare it with binat rules:

binat on $ext_if proto tcp from 192.168.1.37 to any -> $ext_ad_1
binat on $ext_if proto tcp from 192.168.1.38 to any -> $ext_ad_2
binat on $ext_if proto tcp from 192.168.1.54 to any -> $ext_ad_3

As you can see, every internal address must have its own equivalent external address. They can all be bound to the same external interface, though. If you want to know more, consult the ifconfig(8) man page (look for information about aliases).

Again, no and ! modifiers are allowed, as are address class modifiers (inet and inet6).

Always remember that NAT rules do not filter traffic. They redirect it. There must be another rule that filters out traffic redirected to another interface or port. The sender of the original packet does not know anything about what goes on behind the firewall. All it knows is that the packet has reached its destination at $ext_ad. The next installment of this series will cover filtering.

Jacek Artymiak started his adventure with computers in 1986 with Sinclair ZX Spectrum. He's been using various commercial and Open Source Unix systems since 1991. Today, Jacek runs devGuide.net, writes and teaches about Open Source software and security, and tries to make things happen.


Read more Securing Small Networks with OpenBSD columns.

Return to the BSD DevCenter.

Copyright © 2009 O'Reilly Media, Inc.