Securing BSD Systems with ipfw/natd

BSD ipfw is one of the original firewall applications, and it is still one of the most dependable, even if it's not the most feature-rich. The BSD kernel is one of the more mature kernels around, and its networking stack is well known for being very robust and secure, which is one of the considerations Apple used when selecting the BSD kernel for their Mac OS X operating system (being free probably didn't hurt either).

There are two ways to use ipfw with BSD: as a kernel-compiled option or as a kernel module. Since the kernel module option does not support NAT and is also limited with respect to logging, we will do it the hard way and go with a compiled option.

With ipfw, there is a single rule base (or chain) applied to all packets entering or leaving an interface. Rules are indexed by number—when a packet arrives or is ready to leave, it is compared to each rule in order. The first rule to match on a packet's contents stops the inspection process, and the packet is handled based upon the rule's resulting action: permit, deny, or divert. It's the divert option that allows natd to do its magic and perform NAT on packets. Packets returning from natd are re-sent through the rule chain.

Interestingly there is also a wipfw (Windows IP Firewall), a Microsoft Windows-compatible version of ipfw, available from http://wipfw.sourceforge.net. Since the Windows XP SP2 firewall is very limited, this can be considered a more fully functioned alternative, although it lacks a graphical user interface.

If you decide to try wipfw, be careful with your settings because Windows uses a lot of Inter-Process Communication (IPC) and Remote-Procedure Calls (RPCs) internally to do routine operations; if you accidentally firewall these processes off from each other, you will have a very dead Windows machine on your hands. Read the documentation carefully to avoid these pitfalls if you wish to try wipfw.

The two functions that enable firewalling and NAT in BSD are ipfw and natd, respectively. To use these tools, you must be logged in as root. In order to get all available features from ipfw (including DIVERT, which allows us to use NAT), we need to recompile the kernel if these options are not already enabled. Recompiling your BSD kernel is beyond the context of this book, but the FreeBSD handbook contains a great section on kernel recompiling instructions (available at http://www.freebsd.org/doc/en_us.iso8859-1/books/handbook/index.html). It is not as hard as it sounds, but it is time-consuming.

As of FreeBSD 6.1, the GENERIC kernel configuration file does not include the proper options for these services and requires a kernel conf modification and recompile. Your BSD distro can vary, so check your conf file to make sure. At a minimum ipfw requires the options IPFIREWALL kernel option to be set in order to work. Using natd with ipfw requires the options IPDIVERT kernel option to be set in order to work. You also need to enable options IPFIREWALL_FORWARD to enable forwarding (e.g., for routing). ipfw logs some essential information, but if you would like to have additional logging capability, also add options IPFIREWALL_VERBOSE.

If you are not familiar with BSD kernel compiling, just copy the GENERIC kernel conf file, add the four options, and recompile—leave the driver settings alone unless you know what you are doing. After installing the new kernel and rebooting, look for a new line in your boot-up screen that says:

ipfw2 (+ipv6) initialized, divert loadable, rule-based
forwarding enabled, default to deny, logging unlimited

This tells you whether ipfw is loaded and ready to go. If you have neglected to add all four kernel options, this line may vary. The first two options, IPFIREWALL and DIVERT, are crucial for ipfw and natd. The last two, IPFIREWALL_DIVERT and IPFIREWALL_VERBOSE, are optional but recommended.

There is one additional kernel option relevant to ipfw that I want to warn you not to use: options IPFIREWALL_DEFAULT_TO_ACCEPT. This is a safe option for the novice ipfw user to enable, as it prevents the noobie mistake of loading ipfw without defining a policy, which would lock you out of your box as ipfw blocks all traffic in and out until it's told to permit something. If you try to ping out with ipfw enabled but no policy, you will get permission denied, even as root. The reason I am telling you not to use this option is because doing so is a flawed security model; if you forget to block something, ipfw allows it through and you cannot be sure what it was, which can allow an attacker to own your system. It is better to have a default-deny/explicit-allow configuration, so you know exactly what you are permitting and are blocking everything else, guaranteed.

To enable ipfw on bootup, add firewall_enable="YES" to your /etc/rc.conf file. This tells the system to run the /etc/rc.firewall script that activates the firewall. For an initial setup, also add a second line of firewall_type="OPEN" in your /etc/rc.conf file. This sets a default policy of permit-all and keeps you from accidentally locking yourself out. Do not forget this second parameter or you may be sorry. We will tighten this down in a moment.

Warning

Run the rc.firewall script only interactively from a local session or in the background, and never run the script interactively when remote. The script flushes the session table before applying the new rule, and during the session flush, your remote connection will be dropped. The script drops midprocess before adding any new firewall rules that block every connection in or out of the box until you can get logged in locally to rerun the script. This is a major pain, so don't do it.

Last but not least, let's turn on logging. Set firewall_logging="YES" to enable logging. The logs go to your kernel log—generally, /var/log/messages.

Follow the configuration steps outlined earlier to get the BSD options compiled into your kernel. Be sure to leave out the DEFAULT_TO_ACCEPT option. Assuming you have done this, make sure you set the firewall_type="OPEN" in your /etc/rc.conf file and reboot as mentioned earlier. If you have done all that, you're ready to start locking down the box.

ipfw's rule sets are completely contained within the rc.firewall script. It has several rulesets for you to choose from (you can only use one at a time). In fact, you have used one already: the OPEN ruleset. These rulesets are linked to the firewall_type variable you set in /etc/rc.conf. You can examine the current ipfw ruleset configuration by using the ipfw list command. This provides the rules list in order using ipfw syntax; for example:

00100 allow ip from any to any via lo0
00200 deny ip from any to 127.0.0.0/8
00300 deny ip from 127.0.0.0/8 to any
65000 allow ip from any to any
65535 deny ip from any to any

This listing is the result of selecting the OPEN ruleset using the firewall_type parameter in your rc.conf. The first column is the rule number (rules are examined in the order of their rule number until a match is made), the action (allow, deny, divert, etc.), and the rule body. The rule body contains zero or more patterns that the packet must match before the rule is triggered. Logical associations are allowed if multiple patterns are included and the default logic is and, which means that all patterns must match the packet for the rule to trigger, but it allows for very complex syntax that includes negation (not), alternatives (or), and nesting.

For flexibility purposes, ipfw supports multiple different actions that mean essentially the same thing and handles them the same way; allow, accept, pass, and permit are all valid actions for permitting a packet to pass, while deny and drop are also synonymous and instruct ipfw to drop the packet.

Take a look at the OPEN ruleset above—notice that it first permits any packet sent from the loopback, denies any packet sent to the loopback subnet (this blocks remote localhost attacks), then denies any packet sent from the loopback subnet (this blocks localhost spoofing attacks); it then allows any IP address to connect to any IP address, and finally it blocks any IP address from connecting to any IP address. Since the rules are inspected in order, the last rule (65535) is never reached, as the rule before it (65000) matches any packet.

You need to edit this script in order to set up the firewall rules. Start by keeping things simple and edit the CLIENT ruleset to lock down the box from outside connections, but allow us to initiate connections outbound without impediment. The default CLIENT ruleset does not lock down all incoming connections, and it also blocks some outgoing connections we might want, so you have to do some editing of the script as well. Open ipfw using your favorite editor and find the client ruleset section, which looks something like this:

[Cc][Ll][Ii][Ee][Nn][Tt])
        ############
        # This is a prototype setup that will protect your system somewhat
        # against people from outside your own network.
        ############

        # set these to your network and netmask and ip
        net="192.168.0.0"
        mask="255.255.255.0"
        ip="192.168.0.10"

        setup_loopback

        # Allow any traffic to or from my own net.
        ${fwcmd} add pass all from ${ip} to ${net}:${mask}
        ${fwcmd} add pass all from ${net}:${mask} to ${ip}

        # Allow TCP through if setup succeeded
        ${fwcmd} add pass tcp from any to any established

        # Allow IP fragments to pass through
        ${fwcmd} add pass all from any to any frag

        # Allow setup of incoming email
        ${fwcmd} add pass tcp from any to ${ip} 25 setup

        # Allow setup of outgoing TCP connections only
        ${fwcmd} add pass tcp from ${ip} to any setup

        # Disallow setup of all other TCP connections
        ${fwcmd} add deny tcp from any to any setup

        # Allow DNS queries out in the world
        ${fwcmd} add pass udp from ${ip} to any 53 keep-state

        # Allow NTP queries out in the world
        ${fwcmd} add pass udp from ${ip} to any 123 keep-state

        # Everything else is denied by default, unless the
        # IPFIREWALL_DEFAULT_TO_ACCEPT option is set in your kernel
        # config file.
        ;;

If your CLIENT section is different than the one just shown, you can either make it the same as this one or wing it; it's your call. If it is different, and you are really concerned about it, you can make a backup copy of your rc.firewall file before editing.

First, edit the local subnet and IP address settings at the beginning of the CLIENT ruleset section. Edit the net=, mask=, and ip= variables to match your system. Next, disable some rules that allow incoming connections.

The first rule to disable is Allow setup of incoming mail. TCP port 25 is SMTP, so this rule allows any host (even ones on the Internet) to connect to your box on port 25. This is not a good situation, unless you are a registered mail server (DNS MX record). The solution is easy; simply disable the rule by putting a pound or hash sign (#) before that line, as shown here:

# ${fwcmd} add pass tcp from any to ${ip} 25 setup

This is more prudent than deleting the line, as you may need it later.

Next, disable the Allow any traffic to or from my own net lines that allow other hosts from your subnet to be able to connect to you—in a true lock-down policy, no other host can connect to your box, so let's remove those two rules the same way we did the SMTP line by placing a pound sign in front of these two lines like so:

# Allow any traffic to or from my own net.
# ${fwcmd} add pass all from ${ip} to ${net}:${mask}
# ${fwcmd} add pass all from ${net}:${mask} to ${ip}

Notice the add pass tcp from any to any established line? The established parameter allows ipfw to permit TCP packets returning from connections you have made. It is important to note that if you do not have this line, you drop every response to any connection you make.

Also note the add pass tcp from ${ip} to any setup line—this allows you to create TCP sessions out. The setup parameter is essentially the opposite of established—it triggers on TCP SYN packets, which is what TCP uses to start a connection.

The CLIENT ruleset allows DNS and NTP UDP packets out, but nothing else. This is actually very good security, but if you are interested in doing UDP port scans, you need to add a UDP permit-all line. Add these two lines above the #Everything else is denied by default . . . line to allow UDP scans out from your system:

# Allow all UDP packets out
${fwcmd} add pass udp from ${ip} to any keep-state

The keep-state option is important because UDP does not have a method itself to track session state like TCP does with SYN, ACK, and FIN. This parameter tells ipfw to keep a pseudosession for it to allow UDP replies. It does this by creating dynamic rules automatically to permit the reverse traffic. These rules have a limited lifetime and are purged after an inactivity timeout.

Once you edit the rc.firewall script, you can try the CLIENT ruleset simply by running /etc/rc.firewall CLIENT. If rc.firewall is not set to executable (which sometimes happens), simply perform a chmod 744 /etc/rc.firewall and you are good to go.

Warning

Remember, do not run the rc.firewall script while connected remotely, or when important connections are going outbound, as the script performs a flush of the rule base first. This kills all connections in or out and reloads the rules defined in the section you specify.

This is what the CLIENT mode shows us now with ipfw list:

00100 allow ip from any to any via lo0
00200 deny ip from any to 127.0.0.0/8
00300 deny ip from 127.0.0.0/8 to any
00400 allow tcp from any to any established
00500 allow ip from any to any frag
00600 allow tcp from 10.1.1.89 to any setup
00700 deny tcp from any to any setup
00800 allow udp from 10.1.1.89 to any dst-port 53 keep-state
00900 allow udp from 10.1.1.89 to any dst-port 123 keep-state
01000 allow udp from 10.1.1.89 to any keep-state
65535 deny ip from any to any

To ensure the CLIENT ruleset is loaded on bootup, be sure to change your firewall_type setting to CLIENT in your /etc/rc.conf file. Add the firewall_silent="YES" to /etc/rc.conf, and ipfw does not display the ruleset as it loads.

To allow inbound connections, we're going to have to go back into the rc.firewall script to make some changes. To keep things simple, edit the CLIENT ruleset you are already using to create a pinhole inbound connection policy. In fact, you actually commented out an inbound permit already. Now permit access to SSH on your BSD box so you can connect to it remotely. Also, enable ICMP so it responds to ping.

Open the rc.firewall script in your favorite editor and scroll down to the CLIENT rule set section. Find the section below the SMTP line you commented out, but above the outgoing TCP setup line. Add these lines:

# Allow setup of incoming ssh connections
${fwcmd} add allow tcp from any to ${ip} 22 setup

This is extremely similar to the SMTP rule commented out previously. This line allows (allow) initial TCP connections (setup) from any IP address to your interface's IP address (using the ${ip} variable) on port 22.

Also add an ICMP permit line and limit it to ICMP type 8 (an echo-request) and type 0 (an echo-reply):

# Allow icmp pings
${fwcmd} add allow icmp from any to any icmptypes 8,0

Save your changes and reload the CLIENT ruleset with /etc/rc.firewall CLIENT. Check your settings with ipfw list, and you will see that port 22 is now permitted:

00100 allow ip from any to any via lo0
00200 deny ip from any to 127.0.0.0/8
00300 deny ip from 127.0.0.0/8 to any
00400 allow tcp from any to any established
00500 allow ip from any to any frag
00600 allow tcp from any to 10.1.1.89 dst-port 22 setup
00700 allow icmp from any to any icmptypes 0,8
00800 allow tcp from 10.1.1.89 to any setup
00900 deny tcp from any to any setup
01000 allow udp from 10.1.1.89 to any dst-port 53 keep-state
01100 allow udp from 10.1.1.89 to any dst-port 123 keep-state
01200 allow udp from 10.1.1.89 to any keep-state

Assuming sshd is installed and running, try connecting to it from another machine. It should work.

Next, add IP address filtering protection to your CLIENT ruleset. Remember that earlier you already had some IP address filtering in the form of an allow rule for your local subnet:

# Allow any traffic to or from my own net.
# ${fwcmd} add pass all from ${ip} to ${net}:${mask}
# ${fwcmd} add pass all from ${net}:${mask} to ${ip}

And also the rule you added to permit SSH to the box:

# Allow setup of incoming ssh connections
${fwcmd} add pass tcp from any to ${ip} 22 setup

Combine these into a single line that allows you only to ssh into the box from the local subnet by changing the from any portion to from ${net}:${mask} like so:

# Allow setup of incoming ssh connections only from our subnet
${fwcmd} add pass tcp from ${net}:${mask} to ${ip} 22 setup

Doing another ipfw list (or just the output of the rc.firewall script itself if you did not set firewall_silent="YES" in rc.conf) provides you with:

00100 allow ip from any to any via lo0
00200 deny ip from any to 127.0.0.0/8
00300 deny ip from 127.0.0.0/8 to any
00400 allow tcp from any to any established
00500 allow ip from any to any frag
00600 allow tcp from 10.1.1.0/24 to 10.1.1.89 dst-port 22 setup
00700 allow icmp from any to any icmptypes 0,8
00800 allow tcp from 10.1.1.89 to any setup
00900 deny tcp from any to any setup
01000 allow udp from 10.1.1.89 to any dst-port 53 keep-state
01100 allow udp from 10.1.1.89 to any dst-port 123 keep-state
01200 allow udp from 10.1.1.89 to any keep-state

To add additional subnets or hosts, just add multiple lines, replacing the ${net}:${mask} variables with either IP addresses or IP addresses and netmasks.

Now let's walk through a simple NAT setup. For the purposes of your lab, the inside subnet is 192.168.0.0/24 with your NAT gateway at 192.168.0.1, and the NAT gateway's outside address at 12.34.56.78. In the installation section, recompile the kernel to add DIVERT capability for ipfw. This functionality is used to pass packets to natd for mangling, as natd itself is not a kernel module.

Next, switch the ruleset you use for your firewall and make one from scratch. Open /etc/rc.firewall in a text editor and move down to the bottom of the file. At the very bottom should be the following section:

[Uu][Nn][Kk][Nn][Oo][Ww][Nn])
        ;;
*)
        if [ -r "${firewall_type}" ]; then
                ${fwcmd} ${firewall_flags} ${firewall_type}
        fi
        ;;
esac

You are going to add the new section right in the middle of this part. This is a standard shell script, so be careful of syntax. Below the double semicolons (;;) but above the asterisk-close-parenthesis (*)), insert this new section (do not forget the close parenthesis after the [Tt]):

[Nn][Aa][Tt])
        # set these to your outside interface network and netmask and ip
        oif="lnc0"
        onet="12.34.56.0"
        omask="255.255.255.0"
        oip="12.34.56.78"

        # set these to your inside interface network and netmask and ip
        iif="lnc1"
        inet="192.168.0.0"
        imask="255.255.255.0"
        iip="192.168.0.1"

        # run the loopback permit sub
        setup_loopback

        # Stop spoofing
        ${fwcmd} add deny log all from ${inet}:${imask} to any in via ${oif}
        ${fwcmd} add deny log all from ${onet}:${omask} to any in via ${iif}

        # Network Address Translation.
        ${fwcmd} add divert natd log all from any to any via ${natd_interface}

        # Allow TCP through if setup succeeded
        ${fwcmd} add pass log tcp from any to any established

        # Allow IP fragments to pass through
        ${fwcmd} add pass log all from any to any frag

        # Allow local subnet
        ${fwcmd} add pass log ip from ${inet}:${imask} to any keep-state
        ${fwcmd} add pass log ip from ${oip} to any keep-state

        # Reject&Log all setup of incoming connections from the outside
        ${fwcmd} add deny log tcp from any to any in via ${oif} setup

        ;;

Remember the trailing double semicolons at the bottom. Running ipfw list gives us the following output of rules (we've numbered them 1-12 for easy reference):

1    00100 allow ip from any to any via lo0
2    00200 deny ip from any to 127.0.0.0/8
3    00300 deny ip from 127.0.0.0/8 to any
4    00400 deny log ip from 192.168.0.0/24 to any in via lnc0
5    00500 deny log ip from 12.34.56.0/24 to any in via lnc1
6    00600 divert 8668 log ip from any to any via lnc0
7    00700 allow log tcp from any to any established
8    00800 allow log ip from any to any frag
9    00900 allow log ip from 192.168.0.0/24 to any keep-state
10    01000 allow log ip from 12.34.56.78 to any keep-state
11    01100 deny log tcp from any to any in via lnc0 setup
12    65535 deny ip from any to any

Here is what these rules do, line by line:

  1. Allows loopback interface communication.

  2. Denies loopback destination spoofing.

  3. Denies loopback source spoofing.

  4. Denies inside address spoofing from the outside interface.

  5. Denies outside address spoofing from the inside interface.

  6. Enables divert for NAT handling on all IP's going out lnc0 (our outside interface).

  7. Allows established TCP sessions to stay established.

  8. Allows IP fragments.

  9. Allows inside subnet out, tracking state of sessions.

  10. Allows outside subnet out, tracking state of sessions.

  11. Denies and logs any outside TCP connection from coming in. This is somewhat redundant, as the default-deny on line 12 catches everything else, but this allows you to log the hits for reference.

To enable natd at boot time, be sure to add the following lines to the /etc/rc.conf file:

enable_gateway="YES"    # Adds gateway/ip forwarding support
natd_enable="YES"       # Loads the /etc/rc.d/natd script at boot
natd_interface="lnc0"   # Specifies your 'outside' NAT interface
natd_flags =""          # Allows for additional natd flags

Setting these parameters and rebooting automatically enables NAT on the outside interface. Be sure to change the lnc0 to whatever interface name the outside network card happens to be. And do not forget to update /etc/rc.conf with the new ruleset for firewall_type="NAT".

To set up NAT with inbound connections, first follow the steps to provide basic NAT. You add two rules to allow an inbound connection (one for the outside interface and a second for after it is NAT'd to your inside server) to the NAT ruleset and add a rule to natd on where to send this connection. Edit the /etc/rc.firewall file again and add these lines below the IP Fragment configuration lines:

# Allow setup of http
${fwcmd} add accept tcp from any to ${oip} 80 setup
${fwcmd} add pass tcp from any to 192.168.0.50 80

This adds an accept rule for TCP port 80 from anywhere to the outside IP (the NAT IP), which allows anyone to connect to 192.168.0.50 on port 80, the IP address of your inside server. Next, add the natd configuration to support port forwarding. When you added the natd settings to your /etc/rc.conf, one line was left empty:

natd_flags =""            # Allows for additional natd flags

Now is the time to use this entry. Change it to add a redirect_port parameter:

natd_flags = "-redirect_port tcp 192.168.0.50:80 80"

This instructs natd to listen on port 80 and redirect any connections to it for the server 192.168.0.50:80. Once you change this line, you need to bounce natd to read the new config. Perform a /etc/rc.d/natd restart and wait—natd takes time to flush its session table and may take up to 10 seconds to restart. Once it is done, test your settings. You should be able to view the inside web page at 192.168.0.50:80 by browsing to the outside NAT IP of 12.34.56.78:80.