How to implement an internet facing FreeBSD IPFW firewall
Introduction
I am a FreeBSD user since version 2.2. I used IPFilter as my first packet filtering firewall on FreeBSD. It is still in the FreeBSD base system, but I guess not widely used anymore. When PF was ported to FreeBSD, I started using that and have used it ever since.
So I’ve never used the FreeBSD native packet filtering firewall IPFW before.
But that changed a little while ago, so I’ve decided to write about it and share my insights and gained knowledge about this subject. So this blog post is about implementing an internet facing firewall using FreeBSD IPFW! And we do this by using an example design which is described in the first 3 sections below (Diagram, The requirements for this setup and, Some starting points).
Diagram
The (imaginary) setup used for this blog post is shown in the diagram below.
The requirements for this setup
The high level requirements for this setup are:
- Use an up to date FreeBSD version 12.1
- Use IPFW as packet filtering firewall
- Use IPFW In-kernel NAT
- Only IP version 4 is used
- The firewall is setup using the default deny stance, so (only) traffic which is explicitly allowed is defined and everything else is blocked
- The firewall has 2 network interfaces
- an external public internet facing network interface
- an internal private network interface
- The firewall protects 3 internal networks from the internet:
- It is asummed that these 3 networks are connected to an internal router which is also connected to the network of the firewall internal interface
em1
- The firewall ruleset must not only protect the 3 networks but also the firewall itself
- The firewall itself can only be accessed by the defined management hosts using SSH
- Outgoing NTP traffic is allowed to the whole internet
- Outgoing DNS traffic is only allowed from
- the internal name servers
- the IoT network (IoT devices are often not configurable)
- All denied (or blocked) traffic is logged on the firewall through syslog and send to a central log host
- It must be possible to reload the ruleset with an active SSH session to the firewall
Some starting points
To be able to fill in the details for the IPFW firewall ruleset we define some starting points. These starting points are:
- The internet facing public network interface of the firewall is:
em0
- The internal facing private network interface of the firewall is:
em1
- The internet public IP address of the firewall is:
x.y.z.254/29
- The internal private IP address of the firewall is:
a.b.c.254/24
- The private IP address of the internal router in the
a.b.c.0/24
network is:a.b.c.1/24
- The default gateway of the firewall is set to the IP address of the internet router:
x.y.z.249/29
(the public subnet isx.y.z.248/29
) - The private IP address range of the (mobile) client network is:
f.g.h.0/24
- The private IP address range of the server network is:
p.q.r.0/24
- The private IP address range of the IoT devices network is:
f.g.i.0/24
- The private IP addresses of the internal name servers are:
p.q.r.51
andp.q.r.52
- The private IP addresses of the management hosts are:
f.g.h.201
andf.g.h.202
- The private IP address of the internal log host is:
p.q.r.41
- The public ISP caching name servers are:
M.N.O.6
andM.N.Q.9
- The public ISP NTP servers are:
M.N.O.22
andM.N.Q.22
And last but not least: it is assumed that all commands are done using the root
user of the firewall.
Base setup of the firewall
Some other important aspects of the firewall host configuration are covered first, before we dive into the implementation of IPFW:
Setup of the interfaces
The interfaces of the firewall are setup like:
# sysrc ifconfig_em0="inet x.y.z.254 netmask 255.255.255.248" # sudo sysrc ifconfig_em1="inet a.b.c.254 netmask 255.255.255.0"
Default gateway
We set the default gateway to the IP address of the internet router:
# sysrc defaultrouter="x.y.z.249"
Static routes
And we also have to define some static routes on the firewall to be able to reach the 3 internal networks:
# sysrc static_routes="clients servers iot" # sysrc route_clients="f.g.h.0/24 a.b.c.1" # sysrc route_servers="p.q.r.0/24 a.b.c.1" # sysrc route_iot="f.g.i.0/24/24 a.b.c.1"
Setup of syslog
We have a requirement to log all IPFW blocked packets so we enable system logging:
# sysrc syslogd_enable="YES" # sysrc syslogd_flags="-s"
And our base /etc/syslog.conf
file looks like:
\*.err;kern.warning;auth.notice /dev/console \*.info;authpriv.none;kern.debug;security.none /var/log/messages cron.* /var/log/cron.log authpriv.* /var/log/auth.log daemon.* /var/log/daemon.log include /etc/syslog.d include /usr/local/etc/syslog.d
Setup of sshd
We want to access the firewall through SSH on its internal interface only, so we harden the default SSH configuration a bit:
# cat >> /etc/sshd_config << EOF ListenAddress a.b.c.254 AllowGroups wheel Protocol 2 X11Forwarding no IgnoreRhosts yes PermitEmptyPasswords no PermitRootLogin no ChallengeResponseAuthentication no AllowAgentForwarding no UseDNS no MaxSessions 2 MaxAuthTries 2 LogLevel VERBOSE Compression no ClientAliveCountMax 2 AllowTcpForwarding no EOF
The setup of IPFW
The first item to configure for an IPFW based firewall is to make sure that the FreeBSD operating system can route packets between the 2 interfaces:
# sysrc gateway_enable="YES"
Next is to enable IPFW and set the name of the script for the IPFW firewall rules:
# sysrc firewall_enable="YES" # sysrc firewall_script="/etc/ipfw.rules"
And we also want to do Network Address Translation. This is enabled through the command:
# sysrc firewall_nat_enable="YES"
As we have enabled Network Address Translation, we also have to set the following kernal state variables:
# echo 'net.inet.tcp.tso=0' >> /etc/sysctl.conf # echo 'net.inet.ip.fw.one_pass=0' >> /etc/sysctl.conf
We also want to enable and configure logging of blocked packets. The first step is to enable the firewall logging:
# sysrc firewall_logging="YES"
To enable logging to system logging we have to set the following kernel state variable:
# echo 'net.inet.ip.fw.verbose=1' >> /etc/sysctl.conf
With this IPFW logs to the security
facility.
And we do not want any limits for our logging, so we also set:
# echo 'net.inet.ip.fw.verbose_limit=0' >> /etc/sysctl.conf
And we also have to configure system logging:
# cat << EOF > /etc/syslog.d/ipfw.conf security.* /var/log/ipfw.log security.* @p.q.r.41:514 EOF
The first line defines that IPFW logs to the file /var/log/ipfw.log
and the second line defines that we also send these logs to our loghost (with IP address p.q.r.41
).
We are almost there, only 1 item to configure before we can go and define our firewall ruleset. We want to be able to reload (or restart) our IPFW rules while we have a SSH session with the firewall. To be able to do this we need to set a kernel state variable:
# echo 'net.inet.ip.fw.dyn_keep_states=1' >> /etc/sysctl.conf
The IPFW rules
The IPFW ruleset is a shell script and we have configured that the file of this script is /etc/ipfw.rules
. The rights on this file are 0644
and the file is owned by the root
user and wheel
group.
The IPFW ruleset we build consists of the following parts and each part is discussed in more detail below:
- start of the rules script
- variables
- start of the IPFW rules
- the inbound rules
- the outbound rules
- the final part of the IPFW rules
We define the inbound rules before the outbound rules as packets always arrive inbound first on an interface.
The start of the rules script
The start of the IPFW rules script consists of the following lines:
#!/bin/sh # Flush out the list before we begin. /sbin/ipfw -q -f flush
The first line states that this is a /bin/sh
shell script. And the real first command flushes the rules, such that we always start with a clean ruleset.
Variables
Before we start with the actual firewall rules we first set some (example) variables (see below). Most of these variables are self explanatory and come from the The starting points chapter above.
# Set variables varIPFW="/sbin/ipfw" varCMD="/sbin/ipfw -q add" varSkip="skipto 09999" varNICPub="em0" varNICPri="em1" varIPPub="x.y.z.254" varIPPri="a.b.c.254" varISPNameServers="M.N.O.6, M.N.Q.9" varISPTimeServers="M.N.O.22, M.N.Q.22" varInternalNameServers="p.q.r.51, p.q.r.52" varMgmtHosts="f.g.h.201, f.g.h.202" varPublicNet="x.y.z.248/29" varClientsNet="f.g.h.0/24" varServerNet="p.q.r.0/24" varIoTNet="f.g.i.0/24" varTrustedNets="f.g.h.0/24, p.q.r.0/24, f.g.i.0/24" varSyslogHost="p.q.r.41" varInternalRouter="a.b.c.1" varRFC1918="10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16" varServerNetTCPPortsAllowed="80, 443" varClientsNetTCPPortsAllowed="22, 25, 80, 110, 143, 443, 465, 554, 587, 989, 990, 993, 995" varClientsNetUDPPortsAllowed="80, 443" varIoTNetTCPPortsAllowed="80, 443" varIoTNetUDPPortsAllowed="53, 123"
The start of the IPFW configuration
So with all the variables known and filled in we can start with the actual ruleset for our internet facing firewall. In the first line we disable one_pass
, which makes sure that the packet is reinjected into the firewalll at the next rules, which we want.
${varIPFW} disable one_pass
In the second line we define a NAT instance (with number 1) for incoming traffic on the external public facing interface (in this case em0
or ${varNICPub}
):
${varIPFW} -q nat 1 config if ${varNICPub} same_ports unreg_only reset
Next we define our first numbered rules in which we set no restrictions on the loopback interface of the firewall:
# No restrictions on Loopback Interface ${varCMD} 00010 allow all from any to any via lo0 ${varCMD} 00011 deny all from any to 127.0.0.0/8 ${varCMD} 00012 deny ip from 127.0.0.0/8 to any
Then we queue and reassable possible IPv4 fragments (see more detail in the IPFW manual page):
# Reassemble inbound packets ${varCMD} 00099 reass all from any to any in
And define the NAT for any IPv4 inbound packets (the number must be the same as above!):
# NAT any IPv4 inbound packets ${varCMD} 00100 nat 1 ip4 from any to any in recv ${varNICPub}
Now it is time to check the packets against the dynamic ruleset (aka check-state):
# Check the state ${varCMD} 00101 check-state
The last part of the start of our IPFW configuration is to deny all partial packets:
# Deny partial packets ${varCMD} 00102 deny ip from any to any frag ${varCMD} 00103 deny ip from any to any established
The inbound rules
After we have setup all the basics it is now time to define the inbound rules. In this example we use the following rule numbers for our inbound rules:
- 01000 - 01499: for inbound traffic on the public network interface (
em0
or${varNICPub
}) - 01500 - 01999: for inbound traffic on the private network interface (
em1
or${varNICPri
})
And both rule numbers 1499 and 1999 are used for the deny all
rule on the interface.
Public (external) interface
We start with defining the rules for (known) traffic which we do not want to log for the public network interface, like i.e. Multicast addresses:
# Deny and do NOT log known traffic ${varCMD} 01001 deny ip from ${varPublicNet} to 224.0.0.1 in recv ${varNICPub}
If needed or required you can add any additional rules here.
For this example we only allow incoming ICMP traffic on the public interface (from the whole internet):
# Allow inbound ICMP ${varCMD} 01301 allow icmp from any to me in recv ${varNICPub} keep-state
And we close of with the deny rule for incoming traffic on the public facing interface (em0
):
# Deny all other in ${varCMD} 01499 deny log all from any to any in via ${varNICPub}
Private (internal) interface
So with the external interface all done, we can define the inbound rules on the internal interface. In this example we do not define traffic which we do not want to log on the internal interface (if you hae a requirement yourslef you can add them ofcourse). We first allow SSH traffic to the firewall coming from the management hosts (and limit the number of source addresses to 2!):
# Allow inbound SSH connections ${varCMD} 01501 allow tcp from ${varMgmtHosts} to me dst-port ssh in recv ${varNICPri} setup limit src-addr 2
Next we allow ICMP traffic from all the internal networks and the internal router to the internal interface of the firewall (em1
):
# Allow inbound ICMP ${varCMD} 01701 allow icmp from ${varTrustedNets}, ${varInternalRouter} to me in recv ${varNICPri} keep-state
We use the next rule to allow traffic to the internet from our internal networks and the internal router. At this stage in the ruleset the outgoing Network Address Translation still needs to happen! We allow all IP based traffic here and the allowed traffic will be narrowed down at a later stage in the ruleset when we define the outgoing traffic on the external interface.
# Allow IP traffic on internal interface ${varCMD} 01899 allow ip from ${varTrustedNets}, ${varInternalRouter} to \( not ${varRFC1918} or not me \) in recv ${varNICPri}
And as the last rule for the incoming traffic on the internal interface we block and log all traffic:
# Deny all other in ${varCMD} 01999 deny log all from any to any in via ${varNICPri}
The outbound rules
The inbound rules are all defined, so we now continue with the outbound rules. In this example we use the following rule numbers for our outbound rules:
- 02000 - 02499: for outbound traffic on the public network interface (
em0
or${varNICPub
}) - 02500 - 02999: for outbound traffic on the private network interface (
em1
or${varNICPri
})
And both rule numbers 2499 and 2999 are used for the deny all
rule on the interface.
Public (external) interface
We define the outbound rules on the public external interface for traffic coming from the firewall itself first:
# From me (DNS, NTP, HTTP(S), ICMP) ${varCMD} 02001 allow tcp from me to ${varISPNameServers} dst-port domain out xmit ${varNICPub} setup keep-state ${varCMD} 02002 allow udp from me to ${varISPNameServers} dst-port domain out xmit ${varNICPub} keep-state ${varCMD} 02011 allow udp from me to ${varISPTimeServers} dst-port ntp out xmit ${varNICPub} keep-state ${varCMD} 02021 allow tcp from me to any dst-port http, https out xmit ${varNICPub} setup keep-state ${varCMD} 02031 allow icmp from me to any out xmit ${varNICPub} keep-state
These rules allow the firewall itself to:
- perform domain name resolution using the appointed ISP caching name servers
- keep time using the appointed NTP servers
- ‘browse’ the internet (using HTTP and HTTPS), e.g. used for FreeBSD updates and package updates
- use ICMP or
ping
to the internet
With the outgoing traffic flows for the firewall itself all done, it is now time to define the allowed traffic from all the internal networks ‘behind’ the firewall. And we begin with the DNS traffic:
# Allow DNS out ${varCMD} 02101 ${varSkip} tcp from ${varInternalNameServers} to any dst-port domain out xmit ${varNICPub} setup keep-state ${varCMD} 02102 ${varSkip} udp from ${varInternalNameServers} to any dst-port domain out xmit ${varNICPub} keep-state ${varCMD} 02103 ${varSkip} tcp from ${varIoTNet} to any dst-port domain out xmit ${varNICPub} setup keep-state ${varCMD} 02104 ${varSkip} udp from ${varIoTNet} to any dst-port domain out xmit ${varNICPub} keep-state
The first 2 rules allow the internal name servers to perform name resolution to any name server on the internet. And this is allowed as well from the whole IoT network (see rule numbers 02103 and 02104, we assume that we can not configure the DNS settings of the IoT devices we have!).
We also allow NTP traffic out from all our internal systems:
# Allow NTP out ${varCMD} 02111 ${varSkip} udp from ${varTrustedNets}, ${varInternalRouter} to any dst-port ntp out xmit ${varNICPub} keep-state
With the DNS and NTP traffic defined we now define the allowed traffic from each of the internal networks:
The allowed traffic from our server network:
# Allowed traffic from server network ${varCMD} 02201 ${varSkip} tcp from ${varServerNet} to any dst-port ${varServerNetTCPPortsAllowed} out xmit ${varNICPub} setup keep-state
The allowed traffic from our clients network:
# Allowed traffic from clients network ${varCMD} 02211 ${varSkip} tcp from ${varClientsNet} to any dst-port ${varClientsNetTCPPortsAllowed} out xmit ${varNICPub} setup keep-state ${varCMD} 02212 ${varSkip} udp from ${varClientsNet} to any dst-port ${varClientsNetUDPPortsAllowed} out xmit ${varNICPub} keep-state
The allowed traffic from our IoT network:
# Allowed traffic from IoT network ${varCMD} 02221 ${varSkip} tcp from ${varIoTNet} to any dst-port ${varIoTNetTCPPortsAllowed} out xmit ${varNICPub} setup keep-state
And allow TCP traffic from the internal router:
# Allowed traffic from internal router ${varCMD} 02231 ${varSkip} tcp from ${varInternalRouter} to any dst-port http, https out xmit ${varNICPub} setup keep-state
And as the last allowed rule we allow ICMP traffic out from all internal networks:
# Allow ICMP out ${varCMD} 02301 ${varSkip} icmp from ${varTrustedNets}, ${varInternalRouter} to any out xmit ${varNICPub} keep-state
And as the last rule for the outgoing traffic on the external interface we block and log all traffic:
# Deny all other out ${varCMD} 02499 deny log all from any to any out via ${varNICPub}
Private (internal) interface
We define the outbound rules on the private internal interface for traffic coming from the firewall itself first:
# Allow syslog out ${varCMD} 02501 allow udp from me to ${varSyslogHost} dst-port syslog out xmit ${varNICPri} keep-state
This rule allows the firewall itself to log to the defined syslog host. We have no other requirements for outbound traffic on the private internal interface so the next rule is to deny all outgoing traffic from this interface:
# Deny all other out ${varCMD} 02999 deny log all from any to any out via ${varNICPri}
The last part of the IPFW configuration
We have to add 3 more rules to finish our IPFW firewall ruleset. And the first is to have a deny everything rule:
# Default deny all ${varCMD} 04999 deny log all from any to any
The second, and very important, rule is the rule for the outbound Network Address Translation. This rule is used by the outbound rules on the public external interface (se rule numbers 02101 - 02301) which have the ${varSkip}
variable in the rule!
# Outbound IPv4 NAT ${varCMD} 09999 nat 1 ip4 from any to any out xmit ${varNICPub}
And to allow all the return traffic we have to specify the last rule below:
# Allow all ${varCMD} 10001 allow ip4 from any to any
Note: the above rule is mentioned in a lot of IPFW documentation, but I have not found a proper explanation why this rule is needed. So I’ve experimented with it not using it. But I could not get my rule set working without this rule. I saw that no return traffic was happening without this rule, so that must be a valid explanation.
The complete ruleset
The complete ruleset as described above is also available in a seperate file which you can find here.
Some handy IPFW commands
When you have done the IPFW implementation, you also want to see what ruleset is loaded and which rules are used (or not). Luckily provides IPFW some commands for this.
To see our loaded ruleset we can issue the command:
# ipfw list
And to include accounting records use the command:
# ipfw -a list
This same is accomplished with the command: # ipfw show
.
And to also include the last time a chain entry was matched us the command:
# ipfw -at list
To clear the accounting packet counters use the command:
# ipfw zero
To reload the rules in the /etc/ipfw.rules
file you can use:
# service ipfw restart
Please be aware that this last command can only be used if you have set the net.inet.ip.fw.dyn_keep_states
kernel state variable to 1
!
Wrap up
An example configuration of an internet facing FreeBSD IPFW firewall is described above. A whole lot more is possible with IPFW, like e.g.:
- add the usage of tables to the ruleset
- add rules for incoming traffic to e.g. a web server which is behind the firewall
- add traffic shaping capabilities to the firewall
Resources
Some (other) resources about this subject:
- the online IPFW manual page
- the IPFW chapter from the FreeBSD Handbook