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
- Useful commands
- Important aspects
- Basic rules
- Lists
- Macros
- Quick
- Response to discarded traffic
- Scrub
- AntiSpoof
- Tables
- Anchors
- Log
- PFTop
- My Bastille server script
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:
chmod 600 /etc/pf.conf
We enable PF at startup:
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_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.inet6.ip6.forwarding=1
To make the configuration persistent, we must modify the following RC parameters:
sysrc ipv6_gateway_enable=YES
We start the two services:
service pflog start
When starting the pflog service, we will see that an additional network interface has been generated:
pflog0: flags=141<UP,RUNNING,PROMISC> metric 0 mtu 33160
groups: pflog
PF comes with many configuration examples:
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.
block in all
pass in proto tcp to any port 22
pass out all
Reload the configuration:
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:
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:
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:
We try to query the table:
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:
We can query the table:
192.168.2.0/24
!192.168.2.5
A dynamic table can be manipulated in real-time using pfctl:
1/1 addresses added.
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.
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:
We add an entry to the table:
We manually dump the contents of the table to the file. This command is the one we should schedule with crontab:
Finally, we reload the configuration:
And indeed the contents of the table are correct after restarting:
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: Operation not permitted.
Table manipulation
Adding entries:
Show table content:
Delete table content:
Delete all entries from a table:
Delete the table itself:
Reload content on the fly:
Tables can be cleaned by removing entries that have not been referenced in the last X seconds.
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
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:
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:
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:
We can see the content of the anchors:
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:
We populate it with the following command:
We can see the content of the anchors:
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:
pass in inet proto tcp from 192.168.69.0/24 to any port = smtp flags S/SA keep state
Anchors can be nested:
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:
We can see the content of the anchors:
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:
Delete the rules of an anchor:
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:
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:
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
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 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:
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.
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