This page looks best with JavaScript enabled

Basic PF

 ·  🎃 kr0m

Packet Filter or PF is a TCP/IP traffic filtering and NAT system. PF is also capable of normalizing and conditioning TCP/IP traffic, as well as providing bandwidth control and packet prioritization mechanisms. PF has been part of FreeBSD since version 5.3. Over the years, the versions of OpenBSD and FreeBSD’s PF have diverged considerably, so some functionalities will work differently depending on the underlying system.


Installation

To start PF, we must create a rules file. Initially, it can be empty since we only generate it to check that PF starts:

touch /etc/pf.conf
chmod 600 /etc/pf.conf

We enable PF at startup:

sysrc pf_enable=YES
sysrc pf_rules="/etc/pf.conf"

PF allows us to log traffic to be viewed in real-time through a traffic analyzer on the pflog0 interface or to have a record in a text file.

 The pflog interface is a device which makes visible all packets logged by the packet filter, pf(4).
 Logged packets can easily be monitored in realtime by invoking tcpdump(1) on the pflog interface, or stored to disk using pflogd(8).

We enable PF-LOG at startup and configure the log file:

sysrc pflog_enable=YES
sysrc pflog_logfile="/var/log/pflog"

If the device is going to act as a router or NAT traffic, we must enable forwarding at the sysctl level:

sysctl net.inet.ip.forwarding=1
sysctl net.inet6.ip6.forwarding=1

To make the configuration persistent, we must modify the following RC parameters:

sysrc gateway_enable=YES
sysrc ipv6_gateway_enable=YES

We start the two services:

service pf start
service pflog start

When starting the pflog service, we will see that an additional network interface has been generated:

ifconfig pflog0

pflog0: flags=141<UP,RUNNING,PROMISC> metric 0 mtu 33160
	groups: pflog

PF comes with many configuration examples:

ls -la /usr/share/examples/pf/

total 62
drwxr-xr-x   2 root  wheel    12 May 12  2022 .
drwxr-xr-x  41 root  wheel    41 May 12  2022 ..
-r--r--r--   1 root  wheel  1245 May 12  2022 ackpri
-r--r--r--   1 root  wheel   937 May 12  2022 faq-example1
-r--r--r--   1 root  wheel  3141 May 12  2022 faq-example2
-r--r--r--   1 root  wheel  4723 May 12  2022 faq-example3
-r--r--r--   1 root  wheel  1074 May 12  2022 pf.conf
-r--r--r--   1 root  wheel   860 May 12  2022 queue1
-r--r--r--   1 root  wheel  1122 May 12  2022 queue2
-r--r--r--   1 root  wheel   492 May 12  2022 queue3
-r--r--r--   1 root  wheel   912 May 12  2022 queue4
-r--r--r--   1 root  wheel   268 May 12  2022 spamd

Useful commands

Once PF is started, it can be managed using the pfctl command. The most commonly used commands are:

Command Description
pfctl -e Enables PF
pfctl -d Disables PF
pfctl -f /etc/pf.conf Reloads configuration
pfctl -nf /etc/pf.conf Checks configuration without loading it
pfctl -nvf /etc/pf.conf Checks configuration without loading it, also shows the parsing of the rules
pfctl -F all -f /etc/pf.conf Flushes all NAT, filter, state, table rules and reloads the configuration
pfctl -s rules/nat/states Shows the rules of the filtering/NAT/state tables
pfctl -sa Shows all possible information

Important aspects

Some important aspects to keep in mind when using PF are:

  • When a rule matches, the processing of rules does not stop unless quick is used.
  • The rule that will be applied will always be the last to match.
  • This is equivalent to reading the rules from bottom to top.
  • All rules are stateful.

Basic rules

The syntax of the rules is as follows:

action [direction] [log] [quick] [on interface] [af] [proto protocol]
       [from src_addr [port src_port]] [to dst_addr [port dst_port]]
       [flags tcp_flags] [state]

As a basic example, we are going to configure some rules that deny incoming traffic except for port 22 and allow outgoing connections.

vi /etc/pf.conf

block in all
pass in proto tcp to any port 22
pass out all

Reload the configuration:

pfctl -f /etc/pf.conf


Lists

PF supports the use of lists which will make writing rules much easier:

block all
pass out proto tcp to any port { 80, 443 }
pass in proto tcp to any port 22

Macros

PF also supports the use of macros, which are really useful:

ext_if = "em0"
tcp_services = "{ ssh, smtp, domain, http, pop3, auth, pop3s }"
udp_services = "{ domain }"
block all
pass out proto tcp to any port $tcp_services
pass proto udp to any port $udp_services
pass in proto tcp to any port 22

We can see how HTTP traffic is allowed:

HTTP/1.1 302 Found
content-length: 0
location: https://alfaexploit.com/
cache-control: no-cache

But not HTTPS:

curl: (7) Couldn't connect to server

Quick

As we have already mentioned, by default the rule that is always applied is the last to match. To avoid this behavior and make the processing of rules stop when a match is made, we must use the quick function.
Let’s put this example of rules with an outgoing ssh connection from the computer in question:

ext_if = "em0"
tcp_services = "{ ssh, smtp, domain, http, pop3, auth, pop3s }"
udp_services = "{ domain }"
block all
pass out proto tcp to any port $tcp_services
pass out proto tcp to any
pass proto udp to any port $udp_services
pass in proto tcp to any port 22

The rules that match the traffic are:

block all
pass out proto tcp to any port $tcp_services
pass out proto tcp to any

But only the third one will be applied since it is the last to match:

pass out proto tcp to any

This behavior can be avoided if we add the quick function, so that when a rule matches, the processing of rules will stop and the traffic will be treated according to that rule.

Let’s put the same previous example but this time using quick:

vi /etc/pf.conf

ext_if = "em0"
tcp_services = "{ ssh, smtp, domain, http, pop3, auth, pop3s }"
udp_services = "{ domain }"
block all
pass out quick proto tcp to any port $tcp_services
pass out proto tcp to any
pass proto udp to any port $udp_services
pass in proto tcp to any port 22

They continue to enforce three rules:

block all
pass out quick proto tcp to any port $tcp_services
pass out proto tcp to any

But when they reach the second one, the rule processing will stop and that rule will be applied:

pass out quick proto tcp to any port $tcp_services

Response to discarded traffic

It is possible to define how to respond to discarded traffic, there are two options:

  • Drop(default): Discard the traffic without further action.
  • Return: Discard the traffic and send a status code message to the source.
set block-policy return

Scrub

There are certain attacks that take advantage of packet fragmentation to evade filtering rules or attack detection systems. With PF, we can block such attacks using the scrub function.
Scrub will reassemble the fragments, ensuring that all TCP packet information is correct.
The easiest way to use scrub is this:

scrub in all

But scrub allows for more adjustments, such as changing TCP flags. In this case, it removes the “do not fragment” bit and imposes a maximum segment size of 1440 bytes:

scrub in all fragment reassemble no-df max-mss 1440

Antispoof

A good practice is to deny traffic whose origin does not match the network interface connected to that network:

martians = "{ 127.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8, 169.254.0.0/16, 192.0.2.0/24, 0.0.0.0/8, 240.0.0.0/4 }"
block drop in quick on $ext_if from $martians to any
block drop out quick on $ext_if from any to $martians

One way to do this automatically is through the antispoof function, but first we must skip loopback traffic since it is affected by antispoof:

set skip on lo
antispoof for $ext_if inet

Tables

Tables are lists that can be modified without the need to reload the set of rules, and they can be queried very quickly.

Dynamic tables:
Their content can be modified.

table <spammers> { 192.168.2.0/24, !192.168.2.5 }

block all
pass in proto tcp to any port 25
block in proto tcp from <spammers> to any port 25
pass in proto tcp to any port 22

We can query the table:

pfctl -t spammers -T show

   192.168.2.0/24
  !192.168.2.5

A peculiarity of tables is that if they are not referenced by any rule, they are not created:

table <spammers> { 192.168.2.0/24, !192.168.2.5 }

block all
pass in proto tcp to any port 25
#block in proto tcp from <spammers> to any port 25
pass in proto tcp to any port 22

We delete the tables and reload the configuration:

pfctl -F Tables && pfctl -f /etc/pf.conf

We try to query the table:

pfctl -t spammers -T show

pfctl: Table does not exist.

If we want the table to be created even without references to it, we must indicate it:

table <spammers> persist { 192.168.2.0/24, !192.168.2.5 }

block all
pass in proto tcp to any port 25
#block in proto tcp from <spammers> to any port 25
pass in proto tcp to any port 22

We delete the tables and reload the configuration:

pfctl -F Tables && pfctl -f /etc/pf.conf

We can query the table:

pfctl -t spammers -T show

   192.168.2.0/24
  !192.168.2.5

A dynamic table can be manipulated in real-time using pfctl:

pfctl -t spammers -T add 192.168.3.4

1/1 addresses added.
pfctl -t spammers -T show
   192.168.2.0/24
  !192.168.2.5
   192.168.3.4

Modifications made to a table do not persist after restarting the computer. One way to make them persist is to load them from a text file and schedule a regular dump of them to the file in question.

vi /etc/spammers

192.168.2.0/24
!192.168.2.5

We modify the rules script as follows:

table <spammers> file "/etc/spammers"

block all
pass in proto tcp to any port 25
block in proto tcp from <spammers> to any port 25
pass in proto tcp to any port 22

We delete the tables and reload the configuration:

pfctl -F Tables && pfctl -f /etc/pf.conf

We add an entry to the table:

pfctl -t spammers -T add 192.168.3.4

We manually dump the contents of the table to the file. This command is the one we should schedule with crontab:

pfctl -t spammers -T show >/etc/spammers

Finally, we reload the configuration:

pfctl -F Tables && pfctl -f /etc/pf.conf

And indeed the contents of the table are correct after restarting:

pfctl -t spammers -T show

   192.168.2.0/24
  !192.168.2.5
   192.168.3.4

Static tables:
Their contents cannot be modified.

table <spammers> const { 192.168.2.0/24, !192.168.2.5 }

block all
pass in proto tcp to any port 25
block in proto tcp from <spammers> to any port 25
pass in proto tcp to any port 22

If we try to add an entry, it will show the following error:

pfctl -t spammers -T add 192.168.3.4

pfctl: Operation not permitted.

Table manipulation
Adding entries:

pfctl -t spammers -T add 192.168.1.0/16

Show table content:

pfctl -t spammers -T show

Delete table content:

pfctl -t spammers -T delete 192.168.0.0/16

Delete all entries from a table:

pfctl -t spammers -T flush

Delete the table itself:

pfctl -t spammers -T kill

Reload content on the fly:

pfctl -t spammers -T replace -f /etc/spammers

Tables can be cleaned by removing entries that have not been referenced in the last X seconds.

pfctl -t spammers -T expire 86400


Anchors

Anchors are a set of rules, tables, or other anchors that have been assigned a name. They could be considered the equivalent of a code include. The advantage of anchors is that they can be manipulated in real-time using pfctl, and they can be nested, providing great flexibility.

Anchors can be included in a way that only includes certain types of rules:

  • anchor name: Includes the rules of the anchor.
  • binat-anchor name: Includes only the bitnat rules of the anchor.
  • nat-anchor name: Includes only the nat rules of the anchor.
  • rdr-anchor name: Includes only the rdr rules of the anchor.

Anchors can be populated in different ways.

From a text file

vi /etc/anchor-goodguys

pass in proto tcp from 192.168.69.0/24 to any port 25

In this script, all traffic is blocked by default, but traffic to port 25 is allowed through an anchor:

block all
anchor goodguys
load anchor goodguys from "/etc/anchor-goodguys"
pass in proto tcp to any port 22

We can see the contents of the anchors:

pfctl -a goodguys -s rules

pass in inet proto tcp from 192.168.69.0/24 to any port = smtp flags S/SA keep state

It is possible to edit the file and reload the content again:

vi /etc/anchor-goodguys

pass in proto tcp from 192.168.69.0/24 to any port 25
pass in proto tcp from 192.168.2.0/24 to any port 25

Reload the content:

pfctl -a goodguys -f /etc/anchor-goodguys

We can see the content of the anchors:

pfctl -a goodguys -s rules

pass in inet proto tcp from 192.168.69.0/24 to any port = smtp flags S/SA keep state
pass in inet proto tcp from 192.168.2.0/24 to any port = smtp flags S/SA keep state

Using pfctl
We simply define the anchor in the filtering script:

block all
anchor goodguys
pass in proto tcp to any port 22

We delete the previous rules:

pfctl -a goodguys -F rules

We populate it with the following command:

echo "pass in proto tcp from 192.168.69.0/24 to any port 25" | pfctl -a goodguys -f -

We can see the content of the anchors:

pfctl -a goodguys -s rules

pass in inet proto tcp from 192.168.69.0/24 to any port = smtp flags S/SA keep state

Inline within the rules script
We just have to write the rules inside {}, the anchor name is optional:

block all
anchor "goodguys" {
        pass in proto tcp from 192.168.69.0/24 to port 25
}
pass in proto tcp to any port 22

We can see the content of the anchors:

pfctl -a goodguys -s rules

pass in inet proto tcp from 192.168.69.0/24 to any port = smtp flags S/SA keep state

Anchors can be nested:

vi /etc/anchor-goodguys

anchor "goodguys" {
    anchor "client1" {
        pass in proto tcp from 192.168.69.4 to port 25
    }
    anchor "client2" {
        pass in proto tcp from 192.168.69.5 to port 25
    }
}

We load the content of the anchor:

pfctl -a goodguys -f /etc/anchor-goodguys

We can see the content of the anchors:

pfctl -a "goodguys/*" -s rules

anchor "goodguys" all {
  anchor "client1" all {
    pass in inet proto tcp from 192.168.69.4 to any port = smtp flags S/SA keep state
  }
  anchor "client2" all {
    pass in inet proto tcp from 192.168.69.5 to any port = smtp flags S/SA keep state
  }
}

Now we include all second-level anchors of goodguys, which will be evaluated in alphabetical order:

block all
anchor "goodguys/*"
pass in proto tcp to any port 22

NOTE: Keep in mind that it will not descend recursively, only second-level anchors will be evaluated, if there were third-level anchors, they would be ignored.

Show the rules of an anchor:

pfctl -a goodguys -s rules

Delete the rules of an anchor:

pfctl -a goodguys -F rules


LOG

Through the log option we can generate entries in the log file or view them in real time in the log0 interface.
The rules script will be this, we generate logs of rejected traffic and traffic destined for port 25:

block log all
pass in log proto tcp to any port 25
pass in proto tcp to any port 22

We can check the logs by accessing the file, but we must keep in mind that the dump is not real time, if we want to see it in real time we will have to directly check the log0 interface:

tcpdump -n -e -ttt -r /var/log/pflog

 00:00:00.000000 rule 1/0(match): pass in on em0: 192.168.69.4.64484 > 192.168.69.55.25: Flags [S], seq 4073334594, win 65535, options [mss 1460,[|tcp]>

Or directly on the network interface:

tcpdump -n -e -ttt -i pflog0

 00:00:00.000000 rule 1/0(match): pass in on em0: 192.168.69.4.19198 > 192.168.69.55.25: Flags [S], seq 494470215, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 4053058779 ecr 0], length 0

Log supports several interesting options:

  • all: By default only the first packet is logged, with all we will log the subsequent packets of that connection.
  • to pflogN: Changes the interface on which to log the entries.
  • user: The UID/GID of the socket that generated or received the connection is also logged.

In this case we are going to save the blocked traffic and the packets related to the connection to port 25 and the UID/GID of the socket:

block log all
pass in log (all, user) proto tcp to any port 25
pass in proto tcp to any port 22
tcpdump -n -e -ttt -i pflog0
 00:00:00.000000 rule 1/0(match): pass in on em0: 192.168.69.4.35085 > 192.168.69.55.25: Flags [S], seq 702812990, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 1667456513 ecr 0], length 0
 00:00:02.057478 rule 1/0(match): pass in on em0: 192.168.69.4.35096 > 192.168.69.55.25: Flags [P.], seq 1:5, ack 1, win 1027, options [nop,nop,TS val 1978886000 ecr 1634216891], length 4: SMTP: asd

We can also filter the log file entries:

tcpdump -n -e -ttt -r /var/log/pflog port 25

Tcpdump supports pflogd output so we can perform more advanced filtering:

ip - address family is IPv4.
ip6 - address family is IPv6.
on int - packet passed through the interface int.
ifname int - same as on int.
ruleset name - the ruleset/anchor that the packet was matched in.
rulenum num - the filter rule that the packet matched was rule number num.
action act - the action taken on the packet. Possible actions are pass and block.
reason res - the reason that action was taken. Possible reasons are match, bad-offset, fragment, short, normalize, memory, bad-timestamp, congestion, ip-option, proto-cksum, state-mismatch, state-insert, state-limit, src-limit and synproxy.
inbound - packet was inbound.
outbound - packet was outbound.

In this example, we will see denied outbound traffic:

tcpdump -n -e -ttt -i pflog0 outbound and action block

 00:00:00.000000 rule 0/0(match) [uid 0]: block out on em0: 192.168.69.55.33512 > 192.168.69.4.443: Flags [S], seq 26421961, win 65535, options [mss 1460,nop,wscale 6,sackOK,TS val 1046077323 ecr 0], length 0

PFTop

A very useful tool for viewing connections and the bandwidth consumed by each of them is pftop.

pkg install pftop

pfTop: Up State 1-33/33, View: default, Order: none, Cache: 10000                                                                                                                                                                                                                  10:40:51

PR        DIR SRC                                           DEST                                                   STATE                AGE       EXP     PKTS    BYTES
udp       Out 192.168.69.4:29155                            142.250.200.138:443                              MULTIPLE:MULTIPLE     00:52:04  00:00:59     1078   108109
tcp       Out 192.168.69.4:37191                            192.168.69.2:22                               ESTABLISHED:ESTABLISHED  00:52:07  23:59:56       67    11968
tcp       Out 192.168.69.4:41701                            192.168.69.203:8009                           ESTABLISHED:ESTABLISHED  00:52:05  23:59:55     1888   242722
tcp       Out 192.168.69.4:60886                            192.168.69.198:8009                           ESTABLISHED:ESTABLISHED  00:52:07  23:59:59     1892   244049
tcp       Out 192.168.69.4:55729                            149.154.167.92:443                            ESTABLISHED:ESTABLISHED  00:51:34  23:59:25      307    52375
tcp       Out 192.168.69.4:19092                            157.90.179.245:443                            ESTABLISHED:ESTABLISHED  00:44:00  23:59:31      183    29954
tcp       Out 192.168.69.4:32448                            198.252.206.25:443                            ESTABLISHED:ESTABLISHED  00:41:37  23:59:15      146    15433
tcp       Out 192.168.69.4:31612                            140.82.114.26:443                             ESTABLISHED:ESTABLISHED  00:11:28  23:59:32       71    10235
tcp       Out 192.168.69.4:30968                            140.82.114.26:443                             ESTABLISHED:ESTABLISHED  00:10:32  23:59:29       68    13000
tcp       Out 192.168.69.4:48598                            192.168.69.19:443                              FIN_WAIT_2:FIN_WAIT_2   00:01:16  00:00:50       15     4171
udp       Out 192.168.69.4:65471                            142.250.184.4:443                                MULTIPLE:MULTIPLE     00:00:43  00:00:19      115    56281
tcp       Out 192.168.69.4:48582                            216.58.209.74:443                             ESTABLISHED:ESTABLISHED  00:02:42  23:59:33       26     5924
tcp       Out 192.168.69.4:65216                            199.71.0.46:43                                 FIN_WAIT_2:FIN_WAIT_2   00:00:54  00:00:36       12     2893
tcp       Out 192.168.69.4:65226                            88.26.176.27:443                               FIN_WAIT_2:FIN_WAIT_2   00:00:11  00:01:24       22     9094
tcp       Out 192.168.69.4:65225                            192.168.69.19:443                             ESTABLISHED:ESTABLISHED  00:00:16  23:59:44       16     9265
tcp       Out 192.168.69.4:65220                            88.26.176.27:443                               FIN_WAIT_2:FIN_WAIT_2   00:00:41  00:00:54       22     9094
tcp       Out 192.168.69.4:48586                            192.168.69.19:443                              FIN_WAIT_2:FIN_WAIT_2   00:02:16  00:00:14       15     3788
tcp       Out 192.168.69.4:48587                            192.168.69.19:443                              FIN_WAIT_2:FIN_WAIT_2   00:02:16  00:00:14       17     5757
udp       Out 192.168.69.4:54755                            142.250.201.78:443                               MULTIPLE:MULTIPLE     00:00:54  00:00:06       18     6855
tcp       Out 192.168.69.4:65221                            142.250.200.138:443                             TIME_WAIT:TIME_WAIT    00:00:40  00:00:51       11     1038
tcp       Out 192.168.69.4:41841                            162.159.138.60:443                            ESTABLISHED:ESTABLISHED  00:01:02  23:59:43       17     6580
tcp       Out 192.168.69.4:65222                            142.250.200.138:443                             TIME_WAIT:TIME_WAIT    00:00:40  00:00:50        9      920
tcp       Out 192.168.69.4:48599                            88.26.176.27:443                               FIN_WAIT_2:FIN_WAIT_2   00:01:11  00:00:24       22     9094
tcp       Out 192.168.69.4:65224                            142.250.200.138:443                             TIME_WAIT:TIME_WAIT    00:00:39  00:00:51        9      946
tcp       Out 192.168.69.4:65218                            88.26.176.27:443                               FIN_WAIT_2:FIN_WAIT_2   00:00:41  00:00:54       24     8964
tcp       Out 192.168.69.4:65219                            88.26.176.27:443                               FIN_WAIT_2:FIN_WAIT_2   00:00:41  00:00:54       21     8156
tcp       Out 192.168.69.4:65511                            192.0.47.59:43                                 FIN_WAIT_2:FIN_WAIT_2   00:00:55  00:00:36       10      864
tcp       Out 192.168.69.4:65223                            142.250.200.138:443                             TIME_WAIT:TIME_WAIT    00:00:39  00:00:51        9      946
udp       Out 192.168.69.4:36386                            8.8.8.8:53                                       MULTIPLE:SINGLE       00:00:01  00:00:29        2      170
tcp       Out 192.168.69.4:65217                            172.217.168.170:443                            FIN_WAIT_2:FIN_WAIT_2   00:00:52  00:00:39       28    15504
udp       Out 192.168.69.4:61727                            142.250.200.74:443                               MULTIPLE:MULTIPLE     00:00:01  00:00:59       18     5518
tcp       Out 192.168.69.4:48589                            162.159.130.234:443                           ESTABLISHED:ESTABLISHED  00:01:56  23:59:56       46    31599
udp       Out 192.168.69.4:64629                            142.250.200.74:443                               MULTIPLE:MULTIPLE     00:00:01  00:01:00       17     6942

My Bastille server script

As a final note, I leave my filtering script on a Bastille server:

ext_if = "nfe0"

set block-policy return
scrub in on $ext_if all fragment reassemble
set skip on lo

table <badguys> persist
table <jails> persist

nat on $ext_if from <jails> to any -> ($ext_if:0)
rdr-anchor "rdr/*"

antispoof for $ext_if inet

block log all
pass out quick
block in quick from <badguys>

pass in proto tcp to 192.168.69.2 port 7777

# SMTP -> HellStorm
pass in proto tcp to 192.168.69.17 port 25

# HTTP/HTTPS -> Atlas
pass in proto tcp to 192.168.69.19 port 80
pass in proto tcp to 192.168.69.19 port 443

# Xbox -> Paradox
pass in proto tcp from 192.168.69.196 to 192.168.69.18 port 80
# TARS -> Paradox
pass in proto tcp from 192.168.69.198 to 192.168.69.18 port 80

pass in proto tcp to any port 22

I have scheduled the cleaning command that removes entries that have not been referenced in the last 24 hours:

# 24h or reboot: Banned ips
00 11 * * * pfctl -t badguys -T expire 86400
If you liked the article, you can treat me to a RedBull here