How To Configure Packet Filter (PF) on FreeBSD 12.1

The author selected the COVID-19 Relief Fund to receive a donation as part of the Write for DOnations program.


The firewall is arguably one of the most important lines of defense against cyber attacks. The ability to configure a firewall from scratch is an empowering skill that enables the administrator to take control of their networks.

Packet Filter (PF) is a renown firewall application that is maintained upstream by the security-driven OpenBSD project. It is more accurately expressed as a packet filtering tool, hence the name, and it is known for its simple syntax, user-friendliness, and extensive features. PF is a stateful firewall by default, storing information about connections in a state table that can be accessed for analytical purposes. PF is part of the FreeBSD base system and is supported by a strong community of developers. Although there are differences between the FreeBSD and OpenBSD versions of PF related to kernel architectures, in general their syntax is similar. Depending on their complexity, common rulesets can be modified to work on either distribution with relatively little effort.

In this tutorial you’ll build a firewall from the ground up on a FreeBSD 12.1 server with PF. You’ll design a base ruleset that can be used as a template for future projects. You’ll also explore some of PF’s advanced features such as packet hygiene, brute force prevention, monitoring and logging, and other third-party tools.


Before you start this tutorial, you’ll need the following:

  • A 1G FreeBSD 12.1 server (either ZFS or UFS). You can use our How To Get Started with FreeBSD tutorial to set your server up to your preferred configuration.
  • FreeBSD has no firewall enabled by default—customization is a hallmark of the FreeBSD ethos. Therefore when you first launch your server, you need temporary protection while PF is being configured. If you’re using DigitalOcean, you can enable your cloud firewall immediately after spinning up the server. Refer to DigitalOcean’s Firewall Quickstart for instructions on configuring a cloud firewall. If you’re using another cloud provider, determine the fastest route to immediate protection before you begin. Whichever method you choose, your temporary firewall must permit only inbound SSH traffic, and can allow all types of outbound traffic.

Step 1 — Building Your Preliminary Ruleset

You’ll begin this tutorial by drafting a preliminary ruleset that provides basic protection and access to critical services from the internet. At this point you have a running FreeBSD 12.1 server with an active cloud firewall.

There are two approaches to building a firewall: default deny and default permit. The default deny approach blocks all traffic, and only permits what is specified in a rule. The default permit approach does the exact opposite: it passes all traffic, and only blocks what is specified in a rule. You’ll use the default deny approach.

PF rulesets are written in a configuration file named /etc/pf.conf, which is also its default location. It is OK to store this file somewhere else as long as it is specified in the /etc/rc.conf configuration file. In this tutorial you’ll use the default location.

Log in to your server with your non-root user:

Next create your /etc/pf.conf file:

  • sudo vi /etc/pf.conf

Note: If you would like to see the complete base ruleset at any point in the tutorial, you can refer to the examples in Step 4 or Step 8.

PF filters packets according to three core actions: block, pass, and match. When combined with other options they form rules. An action is taken when a packet meets the criteria specified in a rule. As you may expect, pass and block rules will pass and block traffic. A match rule performs an action on a packet when it finds a matching criteria, but doesn’t pass or block it. For example, you can perform network address translation (NAT) on a matching packet without passing or blocking it, and it will sit there until you tell it to do something in another rule, such as route it to another machine or gateway.

Next add the first rule to your /etc/pf.conf file:


block all 

This rule blocks all forms of traffic in every direction. Since it does not specify a direction, it defaults to both in and out. This rule is legitimate for a local workstation that needs to be insulated from the world, but it is largely impractical, and will not work on a remote server because it does not permit SSH traffic. In fact, had you enabled PF, you would have locked yourself out of the server.

Revise your /etc/pf.conf file to allow SSH traffic with the following highlighted line:


block all pass in proto tcp to port 22 

Note: Alternatively, you can use the name of the protocol:


block all pass in proto tcp to port ssh 

For the sake of consistency we will use port numbers, unless there is a valid reason not to. There is a detailed list of protocols and their respective port numbers in the /etc/services file, which you are encouraged to view.

PF processes rules sequentially from top-to-bottom, therefore your current ruleset initially blocks all traffic, but then passes it if the criteria on the next line is matched, which in this case is SSH traffic.

You can now SSH in to your server, but you’re still blocking all forms of outbound traffic. This is problematic because you can’t access critical services from the internet to install packages, update your time settings, and so on.

To address this, append the following highlighted rule to the end of your /etc/pf.conf file:


block all pass in proto tcp to port { 22 } pass out proto { tcp udp } to port { 22 53 80 123 443 } 

Your ruleset now permits outbound SSH, DNS, HTTP, NTP, and HTTPS traffic, as well as blocking all inward traffic, (with the exception of SSH). You place the port numbers and protocols inside curly brackets, which forms a list in PF syntax, allowing you to add more port numbers if needed. You also add a pass out rule for the UDP protocol on ports 53 and 123 because DNS and NTP often toggle between both the TCP and UDP protocols. You’re almost finished with the preliminary ruleset, and only need to add a couple of rules to achieve basic functionality.

Complete the preliminary ruleset with the highlighted rules:

Preliminary Ruleset /etc/pf.conf

set skip on lo0 block all pass in proto tcp to port { 22 } pass out proto { tcp udp } to port { 22 53 80 123 443 } pass out inet proto icmp icmp-type { echoreq } 

Save and exit the file.

You create a set skip rule for the loopback device because it does not need to filter traffic and would likely bring your server to a crawl. You add a pass out inet rule for the ICMP protocol, which allows you to use the ping(8) utility for troubleshooting. The inet option represents the IPv4 address family.

ICMP is a multi-purpose messaging protocol used by networking devices for various types of communication. The ping utility for example uses a type of message known as an echo request, which you’ve added to your icmp_type list. As a precaution, you only permit the message types that you need to prevent unwelcome devices from contacting your server. As your needs increase you can add more message types to your list.

You now have a working ruleset that provides basic functionality to most machines. In the next section, let’s confirm that everything is working correctly by enabling PF and testing your preliminary ruleset.

Step 2 — Testing Your Preliminary Ruleset

In this step you’ll test your preliminary ruleset and make the transition from your cloud firewall to your PF firewall, allowing PF to completely take over. You’ll activate your ruleset with the pfctl utility, which is PF’s built-in command-line tool, and the primary method of interfacing with PF.

PF rulesets are nothing more than text files, which means there are no delicate procedures involved with loading new rulesets. You can load a new ruleset, and the old one is gone. There is rarely, if ever, a need to flush an existing ruleset.

FreeBSD uses a web of shell scripts known as the rc system to manage how services are started at boot-time; we specify those services in various rc configuration files. For global services such as PF, you use the /etc/rc.conf file. Since rc files are critical to the well being of a FreeBSD system, they should not be edited directly. Instead FreeBSD provides a command-line utility known as sysrc designed to help you edit these files safely.

Let’s enable PF using the sysrc command-line utility:

  • sudo sysrc pf_enable="YES"
  • sudo sysrc pflog_enable="YES"

Verify these changes by printing the contents of your /etc/rc.conf file:

  • sudo cat /etc/rc.conf

You will see the following output:

Outputpf_enable="YES" pflog_enable="YES" 

You also enable the pflog service, which in turn, enables the pflogd daemon for logging in PF.(You’ll work with logging in a later step.

You specify two global services in your /etc/rc.conf file, but they won’t initialize until you reboot the server or start them manually. Reboot the server so that you can also test your SSH access.

Start PF by rebooting the server:

  • sudo reboot

The connection will be dropped. Give it a few minutes to update.

Now SSH back in to the server:

Although you’ve initialized your PF services, you haven’t actually loaded your /etc/pf.conf ruleset, which means your firewall is not yet active.

Load the ruleset with pfctl:

  • sudo pfctl -f /etc/pf.conf

If there are no errors or messages, it means your ruleset has no errors and the firewall is active.

Now that PF is running, you can detach your server from your cloud firewall. This can be accomplished at the control panel in your DigitalOcean account by removing your Droplet from your cloud firewall’s portal. If you’re using another cloud provider, ensure that whatever you are using for temporary protection is disabled. Running two different firewalls on a server will almost certainly cause problems.

For good measure, reboot your server again:

  • sudo reboot

After a few minutes, SSH back in to your server:

PF is now your acting firewall. You can ensure that it is running by accessing some data with the pfctl utility.

Let’s view some statistics and counters with pfctl -si:

  • sudo pfctl -si

You pass the -si flags, which stand for show info. This is one of the many filter parameter combinations you can use with pfctl to parse data about your firewall activity.

You will see the following tabular data (the values will vary from machine-to-machine):

OutputStatus: Enabled for 0 days 00:01:53           Debug: Urgent  State Table                          Total             Rate   current entries                        5   searches                             144            1.3/s   inserts                               11            0.1/s   removals                               6            0.1/s Counters   match                                 23            0.2/s   bad-offset                             0            0.0/s   fragment                               0            0.0/s   short                                  0            0.0/s   normalize                              0            0.0/s   memory                                 0            0.0/s   bad-timestamp                          0            0.0/s   congestion                             0            0.0/s   ip-option                              0            0.0/s   proto-cksum                            0            0.0/s   state-insert                           0            0.0/s   state-limit                            0            0.0/s   src-limit                              0            0.0/s   synproxy                               0            0.0/s   map-failed                             0            0.0/s 

Since you just activated your ruleset, you won’t see a lot of information yet. However this output shows that PF already recorded 23 matched rules, meaning that the criteria of your ruleset was matched 23 times. The output also confirms that your firewall is working.

Your ruleset also permits outbound traffic to access some critical services from the internet, including the ping utility.

Let’s check for internet connectivity and DNS service with ping against

  • ping -c 3

Since you ran the count flag -c 3, you’ll see three successful connection responses:

OutputPING ( 56 data bytes 64 bytes from icmp_seq=0 ttl=56 time=2.088 ms 64 bytes from icmp_seq=1 ttl=56 time=1.469 ms 64 bytes from icmp_seq=2 ttl=56 time=1.466 ms  --- ping statistics --- 3 packets transmitted, 3 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.466/1.674/2.088/0.293 ms 

Ensure that you can access the the pkgs repository with the following command:

  • sudo pkg upgrade

If there are any packages to upgrade, go ahead and upgrade them.

If both of these services are working, it means your firewall is working and you can now proceed. Although your preliminary ruleset provides protection and functionality, it is still an elementary ruleset, and could use some enhancements. In the remaining sections you’ll complete your base ruleset, and use some of PF’s advanced features.

Step 3 — Completing Your Base Ruleset

In this step you’ll build off of the preliminary ruleset to complete your base ruleset. You’ll reorganize some of your rules and work with more advanced concepts.

Incorporating Macros and Tables

In your preliminary ruleset you hard coded all of your parameters into each rule, that is, the port numbers that make up the lists. This may become unmanageable in the future, depending on the nature of your networks. For organizational purposes PF includes macros, lists, and tables. You’ve already included lists directly in your rules, but you can also separate them from your rules and assign them to a variable using macros.

Open your file to transfer some of your parameters into macros:

  • sudo vi /etc/pf.conf

Now add the following content to the very top of the ruleset:


vtnet0 = "vtnet0" icmp_types = "{ echoreq }" . . . 

Modify your previous SSH and ICMP rules with your new variables:


. . . pass in on $vtnet0 proto tcp to port { 22 } . . . pass inet proto icmp icmp-type $icmp_types . . . 

Your previous SSH and ICMP rules now use macros. The variable names are denoted by PF’s dollar sign syntax. You assign your vtnet0 interface to a variable with the same name just as a formality, which gives you the option to rename it in the future if needed. Other common variable names for public facing interfaces include $pub_if or $ext_if.

Next you’ll implement a table, which is similar to a macro, but designed to hold groups of IP addresses. Let’s create a table for non-routable IP addresses, which often play a role in denial of service attacks (DOS). You can use the IP addresses specified in RFC6890, which defines special-purpose IP address registries. Your server should not send or receive packets to or from these addresses via the public facing interface.

Create this table by adding the following content directly under the icmp_types macro:


. . . table <rfc6890> {                                                      } . . . 

Now add your rules for the <rfc6890> table underneath the set skip on lo0 rule:


. . . set skip on lo0 block in quick on egress from <rfc6890> block return out quick on egress to <rfc6890> . . . 

Here you introduce the return option, which complements your block out rule. This will drop the packets and also send an RST message to the host that tried to make those connections, which is useful for analyzing host activity. Then, you add the egress keyword, which automatically finds the default route(s) on any given interface(s). This is typically a cleaner method of finding default routes, especially with complex networks. The quick keyword executes rules immediately without considering the rest of the ruleset. For example, if a packet with an illogical IP addresses tries to connect to the server, you want to drop the connection immediately, and have no reason to run that packet through the remainder of the ruleset.

Protecting Your SSH Ports

Since your SSH port is open to the public, it is subject to exploitation. One of the more obvious warning signs of an attacker is mass quantities of log-in attempts. For example if the same IP address tries to log in to your server ten times in one second, you can assume that it was not done with human hands, but with computer software that was trying to crack your login password. These types of systematic exploits are often referred to as brute force attacks, and usually succeed if the server has weak passwords.

Warning: We strongly recommend using public-key authentication on all servers. Refer to DigitalOcean’s tutorial on key-based authentication.

PF has built-in features for handling brute force and other similar attacks. With PF you can limit the number of simultaneous connection attempts allowed by a single host. If a host exceeds those limits, the connection will be dropped, and they will be banned from the server. To accomplish this you’ll use PF’s overload mechanism, which maintains a table of banned IP addresses.

Modify your previous SSH rule to limit the number of simultaneous connections from a single host as per the following:


. . . pass in on $vtnet0 proto tcp to port { 22 }      keep state (max-src-conn 15, max-src-conn-rate 3/1,          overload <bruteforce> flush global) . . . 

You add the keep state option that allows you to define the state criteria for the overload table. You pass the max-src-conn parameter to specify the number of simultaneous connections allowed from a single host per second, and the max-src-conn-rate parameter to specify the number of new connections allowed from a single host per second. You specify 15 connections for max-src-conn, and 3 connections for max-src-conn-rate. If these limits are exceeded by a host, the overload mechanism adds the source IP to the <bruteforce> table, which bans them from the server. Finally, the flush global option immediately drops the connection.

You’ve defined an overload table in your SSH rule, but haven’t declared that table in your ruleset.

Add the <bruteforce> table underneath the icmp_types macro:


. . . icmp_types = "{ echoreq }" table <bruteforce> persist . . . 

The persist keyword allows an empty table to exist in the ruleset. Without it, PF will complain that there are no IP addresses in the table.

These measures ensure that your SSH port is protected by a powerful security mechanism. PF allows you to configure quick solutions to protect from disastrous forms of exploitation. In the next sections you’ll take steps to clean up packets as they arrive at your server.

Sanitizing Your Traffic

Note: The following sections describe basic fundamentals of the TCP/IP protocol suite. If you plan on building web applications or networks, it is in your best interest to master these concepts. Have a look at DigitalOcean’s Introduction to Networking Terminology, Interfaces, and Protocols tutorial.

Due to the complexity of the TCP/IP protocol suite, and the perserverance of malicious actors, packets often arrive with discrepancies and ambiguities such as overlapping IP fragments, phony IP addresses, and more. It is imperative that you sanitize your traffic before it enters the system. The technical term for this process is normalization.

When data travels through the internet, it is typically broken up into smaller fragments at its source to accommodate for the transmission parameters of the target host, where it is reassembled into complete packets. Unfortunately an intruder can hijack this process in a number of ways that span beyond the scope of this tutorial. However, with PF you can manage fragmentation with one rule. PF includes a scrub keyword that normalizes packets.

Add the scrub keyword directly preceding your block all rule:


. . . set skip on lo0 scrub in all fragment reassemble max-mss 1440 block all . . . 

This rule applies scrubbing to all incoming traffic. You include the fragment reassemble option that prevents fragments from entering the system. Instead they are cached in memory until they are reassembled into complete packets, which means your filter rules will only have to contend with uniform packets. You also include the max-mss 1440 option, which represents the maximum segment size of reassembled TCP packets, also known as the payload. You specify a value of 1440 bytes, which strikes a balance between size and performance, leaving plenty of room for the headers.

Another important aspect of fragmentation is a term known as the maximum transmission unit (MTU). The TCP/IP protocols enable devices to negotiate packet sizes for making connections. The target host uses ICMP messages to inform the source IP of its MTU, a process known as MTU path discovery. The specific ICMP message type is the destination unreachable. You’ll enable MTU path discovery by adding the unreach message type to your icmp_types list.

You’ll use your server’s default MTU of 1500 bytes, which can be determined with the ifconfig command:

  • ifconfig

You will see the following output that includes your current MTU:


Update the icmp_types list to include the destination unreachable message type:


vtnet0 = "vtnet0" icmp_types = "{ echoreq unreach}" . . . 

Now that you have policies in place to handle fragmentation, the packets that enter your system will be uniform and consistent. This is desirable because there are so many devices exchanging data over the internet.

You’ll now work to prevent another security concern known as IP spoofing. Attackers often change their source IPs to make it appear as if they reside on a trusted node within an organization. PF includes an antispoofing directive for handling spoofed source IPs. When applied to a specific interface(s), antispoofing blocks all traffic from the network of that interface (unless it originates from that interface). For example, if you apply antispoofing to an interface(s) that resides at, all traffic from the network cannot communicate with the system unless it originates from that interface(s).

Add the following highlighted content to apply antispoofing to your vtnet0 interface:


. . . set skip on lo0 scrub in antispoof quick for $vtnet0 block all . . . 

Save and exit the file.

This antispoofing rule says that all traffic from vtnet0’s network(s) can only pass through the vtnet0 interface, or it will be dropped immediately with the quick keyword. Bad actors will not be able to hide in vtnet0’s network and communicate with other nodes.

To demonstrate your antispoofing rule, you’ll print your ruleset to the screen in its verbose form. Rules in PF are typically written in a shortened form, but they can also be written in a verbose form. It is generally impractical to write rules this way, but for testing purposes it can be useful.

Print the contents of /etc/pf.conf using pfctl with the following command:

  • sudo pfctl -nvf /etc/pf.conf

This pfctl command takes the -nvf flags, which print the ruleset and test it without actually loading anything, also known as a dry run. You will now see the entire contents of /etc/pf.conf in its verbose form.

You’ll see something similar to the following output within the antispoofing portion:

Output. . . block drop in quick on ! vtnet0 inet from your_server_ip/20 to any block drop in quick on ! vtnet0 inet from network_address/16 to any block drop in quick inet from your_server_ip to any block drop in quick inet from network_address to any block drop in quick on vtnet0 inet6 from your_IPv6_address to any . . . 

Your antispoofing rule discovered that it is part of the your_server_ip/20 network. It also detected that (for this tutorial’s example) the server is part of a network_address/16 network, and has an additional IPv6 address. Antispoofing blocks all of these networks from communicating with the system, unless their traffic passes through the vtnet0 interface.

Your antispoofing rule is the last addition to your base ruleset. In the next step you’ll initiate these changes and perform some testing.

Step 4 — Testing Your Base Ruleset

In this step you’ll review and test your base ruleset to ensure that everything is functioning properly. It’s best to avoid implementing too many rules at once without testing them. Best practice is to start with the essentials, expand incrementally, and back work up while making configuration changes.

Here is your complete base ruleset:

Base Ruleset /etc/pf.conf

vtnet0 = "vtnet0" icmp_types = "{ echoreq unreach }" table <bruteforce> persist table <rfc6890> {                                                      }  set skip on lo0 scrub in all fragment reassemble max-mss 1440 antispoof quick for $vtnet0 block in quick on $vtnet0 from <rfc6890> block return out quick on egress to <rfc6890> block all pass in on $vtnet0 proto tcp to port { 22 }      keep state (max-src-conn 15, max-src-conn-rate 3/1,          overload <bruteforce> flush global) pass out proto { tcp udp } to port { 22 53 80 123 443 } pass inet proto icmp icmp-type $icmp_types 

Be sure that your /etc/pf.conf file is identical to the complete base ruleset here before continuing. Then save and exit the file.

Your complete base ruleset provides you with:

  • A collection of macros that can define key services and devices.
  • Network hygiene policies to address packet fragmentation and illogical IP addresses.
  • A default deny filtering structure that blocks everything and permits only what you specify.
  • Inbound SSH access with limits on the number of simultaneous connections that can be made by a host.
  • Outbound traffic policies that give you access to some critical services from the internet.
  • ICMP policies that provide access to the ping utility and MTU path discovery.

Run the following pfctl command to take a dry run:

  • sudo pfctl -nf /etc/pf.conf

You pass the -nf flags that tell pfctl to run the ruleset without loading it, which will throw errors if anything is wrong.

Now, with no encountered errors, load the ruleset:

  • sudo pfctl -f /etc/pf.conf

If there are no errors, it means your base ruleset is active and functioning properly. As earlier in the tutorial, you’ll perform a few tests on your ruleset.

First test for internet connectivity and DNS service:

  • ping -c 3

You will see the following output:

OutputPING ( 56 data bytes 64 bytes from icmp_seq=0 ttl=56 time=2.088 ms 64 bytes from icmp_seq=1 ttl=56 time=1.469 ms 64 bytes from icmp_seq=2 ttl=56 time=1.466 ms  --- ping statistics --- 3 packets transmitted, 3 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.466/1.674/2.088/0.293 ms 

Then, check that you reach the pkgs repository:

  • sudo pkg upgrade

Once again, upgrade packages if it’s needed.

Finally, reboot your server:

  • sudo reboot

Give your server a few minutes to reboot. You’ve completed and implemented your base ruleset, which is a significant step in terms of your progress. You’re now ready to explore some of PF’s advanced features. In the next step you will continue to prevent brute force attacks.

Step 5 — Managing Your Overload Table

Over time the <bruteforce> overload table will become full of malicious IP addresses and will need to be cleared periodically. It is unlikely that an attacker will continue using the same IP address, so it is counterintuitive to store them in the overload table for long periods of time.

You’ll use pfctl to manually clear IP addresses that have been stored in the overload table for 48 hours or more with the following command:

  • sudo pfctl -t bruteforce -T expire 172800

You will see output similar to:

Output0/0 addresses expired. 

You pass the -t bruteforce flag, which stands for table bruteforce, and the -T flag, which lets you run a handful of built-in commands. In this case you run the expire command to clear all entries from -t bruteforce with a time value represented in seconds. Since you’re working on a fresh server, there are probably no IP addresses in the overload table yet.

This rule works for quick fixes, but a more robust solution would be to automate the process with cron, FreeBSD’s job scheduler. Let’s create a shell script that runs this command sequence instead.

Create a shell script file in the /usr/local/bin directory:

  • sudo vi /usr/local/bin/

Add the following content to the shell script:


#!/bin/sh  pfctl -t bruteforce -T expire 172800 

Make the file executable with the following command:

  • sudo chmod 755 /usr/local/bin/

Next you’ll create a cron job. These are jobs that will run repetitively according to a time that you specify. They are commonly used for backups, or any process that needs to run at the same time every day. You create cron jobs with crontab files. Please refer to the man pages to learn more about cron(8) and crontab(5).

Create a root user crontab file with the following command:

  • sudo crontab -e

Now add the following contents to the crontab file:


# minute    hour    mday    month   wday    command    *             0     *       *     *     /usr/local/bin/ 

Save and exit the file.

Note: Please align every value to its corresponding table entry for readability if things do not align properly when you add the content.

This cron job runs the script every day at midnight, removing IP addresses that are 48 hours old from the overload table <bruteforce>. Next you’ll add anchors to your ruleset.

Step 6 — Introducing Anchors to Your Rulesets

In this step you’ll introduce anchors, which are used for sourcing rules into the main ruleset, either manually or from an external text file. Anchors can contain rule snippets, tables, and even other anchors, known as nested anchors. Let’s demonstrate how anchors work by adding a table to an external file, and sourcing it into your base ruleset. Your table will include a group of internal hosts that you want to prevent from connecting to the outside world.

Create a file named /etc/blocked-hosts-anchor:

  • sudo vi /etc/blocked-hosts-anchor

Add the following contents to the file:


table <blocked-hosts> { }  block return out quick on egress from <blocked-hosts> 

Save and exit the file.

These rules declare and define the <blocked-hosts> table, and then prevent every IP address in the <blocked-hosts> table from accessing services from the outside world. You use the egress keyword as a preferred method of finding the default route, or way out, to the internet.

You still need to declare the anchor in your /etc/pf.conf file:

  • sudo vi /etc/pf.conf

Now add the following anchor rules after the block all rule:


. . . block all anchor blocked_hosts load anchor blocked_hosts from "/etc/blocked-hosts-anchor" . . . 

Save and exit the file.

These rules declare the blocked_hosts and load the anchor rules into your main ruleset from the /etc/blocked-hosts-anchor file.

Now initiate these changes by reloading your ruleset with pfctl:

  • sudo pfctl -f /etc/pf.conf

If there are no errors, it means that there are no errors in your ruleset and your changes are active.

Use pfctl to verify that your anchor is running:

  • sudo pfctl -s Anchors

The -s Anchors flag stands for “show anchors”. You’ll see the following output:


The pfctl utility can also parse the specific rules of your anchor with the -a and -s flags:

  • sudo pfctl -a blocked_hosts -s rules

You will see the following output:

Outputblock return out quick on egress from <blocked-hosts> to any 

Another feature of anchors is that they allow you to add rules on-demand without having to reload the ruleset. This can be useful for testing, quick-fixes, emergencies, and so on. For example if an internal host is acting peculiar and you want to block it from making outward connections, you can have an anchor in place that allows you to intervene quickly from the command line.

Let’s open /etc/pf.conf and add another anchor:

  • sudo vi /etc/pf.conf

You’ll name the anchor rogue_hosts, and place it in the block all rule:


. . . block all anchor rogue_hosts . . . 

Save and exit the file.

To initiate these changes, reload the ruleset with pfctl:

  • sudo pfctl -f /etc/pf.conf

Once again, use pfctl to verify that the anchor is running:

  • sudo pfctl -s Anchors

This will generate the following output:

Outputblocked_hosts rogue_hosts 

Now that the anchor is running, you can add rules to it at anytime. Test this by adding the following rule:

  • sudo sh -c 'echo "block return out quick on egress from" | pfctl -a rogue_hosts -f -'

This invokes the echo command and its string content, which is then piped into the pfctl utility with the | symbol, where it is processed into an anchor rule. You open another shell session with the sh -c command. This is because you establish a pipe between two processes, but need sudo privileges to persist throughout the entire command sequence. There are multiple ways of resolving this; here you open an additional shell process with sudo privileges using sudo sh -c.

Now, use pfctl again to verify that these rules are active:

  • sudo pfctl -a rogue_hosts -s rules

This will generate the following output:

Outputblock return out quick on egress inet from to any 

The use of anchors is completely situational and often subjective. Like any other feature there are pros and cons to using anchors. Some applications such as blacklistd interface with anchors by design. Next you’ll focus on logging with PF, which is a critical aspect of network security. Your firewall is not useful if you can’t see what it is doing.

Step 7 — Logging Your Firewall’s Activity

In this step you’ll work with PF logging, which is managed by a pseudo-interface named pflog. Logging is enabled at boot-time by adding pflog_enabled=YES to the /etc/rc.conf file, which you did in Step 2. This enables the pflogd daemon that brings up an interface named pflog0 and writes logs in binary format to a file named /var/log/pflog. Logs can be parsed in realtime from the interface, or read from the /var/log/pflog file with the tcpdump(8) utility.

First access some logs from the /var/log/pflog file:

  • sudo tcpdump -ner /var/log/pflog

You pass the -ner flags that format the output for readability, and also specify a file to read from, which in your case is /var/log/pflog.

You will see the following output:

Outputreading from file /var/log/pflog, link-type PFLOG (OpenBSD pflog file) 

In these early stages there may not be any data in the /var/log/pflog file. In a short period of time the log file will begin to grow.

You can also view logs in realtime from the pflog0 interface by using the following command:

  • sudo tcpdump -nei pflog0

You pass the -nei flags, which also format the output for readability, but this time specify an interface, which in your case is pflog0.

You will see the following output:

Outputtcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on pflog0, link-type PFLOG (OpenBSD pflog file), capture size 262144 bytes 

You will now see connections in realtime. If possible, ping your server from a remote machine and you will see the connections occurring. The server will remain in this state until you exit out of it.

To exit out of this state and return to the command line hit CTRL + Z.

There is a wealth of information on the internet about tcpdump(8), including the official website.

Accessing Log Files with pftop

The pftop utility is a tool for quickly viewing firewall activity in realtime. Its name is influenced by the well-known Unix top utility.

To use it, you need to install the pftop package:

  • sudo pkg install pftop

Now run the pftop binary:

  • sudo pftop

This will generate the following output (your IPs will differ):

OutputPR    DIR SRC                   DEST                           STATE                AGE       EXP   PKTS BYTES tcp   In     ESTABLISHED:ESTABLISHED  00:12:35  23:59:55   1890  265K tcp   In       TIME_WAIT:TIME_WAIT    00:01:25  00:00:06     22  3801 udp   Out           MULTIPLE:SINGLE       00:00:14  00:00:16      2   227 

Creating Additional Log Interfaces

Like any other interface, multiple log interfaces can be created and named with a /etc/hostname file. You may find this useful for organizational purposes, for example if you want to log certain types of activity separately.

Create an additional logging interface named pflog1:

  • sudo vi /etc/hostname.pflog1

Add the following contents to the /etc/hostname.pflog1 file:



Now enable the device at boot-time in your /etc/rc.conf file:

  • sudo sysrc pflog1_enable="YES"

You can now monitor and log your firewall activity. This allows you to see who is making connections to your server and the types of connections being made.

Throughout this tutorial you’ve incorporated some advanced concepts into your PF ruleset. It’s only necessary to implement advanced features as you need them. That said, in the next step you’ll revert back to the base ruleset.

Step 8 — Reverting Back to Your Base Ruleset (Optional)

In this final section you’ll revert back to your base ruleset. This is a quick step that will bring you back to a minimalist state of functionality.

Open the base ruleset with the following command:

  • sudo vi /etc/pf.conf

Delete the current ruleset in your file and replace it with the following base ruleset:


vtnet0 = "vtnet0" icmp_types = "{ echoreq unreach }" table <bruteforce> persist table <rfc6890> {                                                      }  set skip on lo0 scrub in all fragment reassemble max-mss 1440 antispoof quick for $vtnet0 block in quick on $vtnet0 from <rfc6890> block return out quick on egress to <rfc6890> block all pass in on $vtnet0 proto tcp to port { 22 }      keep state (max-src-conn 15, max-src-conn-rate 3/1,          overload <bruteforce> flush global) pass out proto { tcp udp } to port { 22 53 80 123 443 } pass inet proto icmp icmp-type $icmp_types 

Save and exit the file.

Reload the ruleset:

  • sudo pfctl -f /etc/pf.conf

If there are no errors from the command, then there are no errors in your ruleset and your firewall is functioning properly.

You also need to disable the pflog1 interface that you created. Since you might not know if you need it yet, you can disable pflog1 with the sysrc utility:

  • sudo sysrc pflog1_enable="NO"

Now remove the /etc/hostname.pflog1 file from the /etc directory:

  • sudo rm /etc/hostname.pflog1

Before signing off, reboot the server once more to ensure that all of your changes are active and persistent:

  • sudo reboot

Wait a few minutes before logging in to your server.

Optionally, if you would like to implement PF with a webserver, the following is a ruleset for this scenario. This ruleset is a sufficient starting point for most web applications.

Simple Web Server Ruleset

vtnet0 = "vtnet0" icmp_types = "{ echoreq unreach }" table <bruteforce> persist table <webcrawlers> persist table <rfc6890> {                                                      }  set skip on lo0 scrub in all fragment reassemble max-mss 1440 antispoof quick for $vtnet0 block in quick on $vtnet0 from <rfc6890> block return out quick on egress to <rfc6890> block all pass in on $vtnet0 proto tcp to port { 22 }      keep state (max-src-conn 15, max-src-conn-rate 3/1,          overload <bruteforce> flush global) pass in on $vtnet0 proto tcp to port { 80 443 }      keep state (max-src-conn 45, max-src-conn-rate 9/1,          overload <webcrawlers> flush global) pass out proto { tcp udp } to port { 22 53 80 123 443 } pass inet proto icmp icmp-type $icmp_types 

This creates an overload table named <webcrawlers>, which has a more liberal overload policy than your SSH port based on the values of max-src-conn 45 and max-src-conn-rate. This is because not all overloads are from bad actors. They also can originate from non-malicious netbots, so you avoid excessive security measures on ports 80 and 443. If you decide to implement the webserver ruleset, you need to add the <webcrawlers> table to /etc/pf.conf, and clear the IPs from the table periodically. Refer to Step 5 for this.


In this tutorial, you configured PF on FreeBSD 12.1. You now have a base ruleset that can serve as a starting point for all of your FreeBSD projects. For further information on PF take a look at the pf.conf(5) man pages.

Visit our FreeBSD topic page for more tutorials and Q&A.