My Ultimate FreeBSD Packet Filter (pf.conf)

12 min

language: ja bn en es hi pt ru zh-cn zh-tw

Hello, I'm Munou.

This time, I'd like to introduce the packet filter settings I've been tinkering with recently.

Current Settings

It looks like this.
In the case of Packet Filter:
Translation rules like NAT and rdr apply to the first match.
Filter rules like pass and block apply to the last match.
This is how the specification works.

In this case, for readability, I've set all filter rules to quick so that the rule matched at that point is applied.

I'll leave comments where possible.

set skip on lo
set block-policy drop
set optimization conservative
set state-policy if-bound
set ruleset-optimization basic
wanint="vtnet0"
# WireGuard Configuration
wg_ifs  = "{ wg0, wg1 }"
wg_net = "10.1.0.0/24"
wg_ports="{51820}"
table <wg_clients> const { 10.1.0.2, 10.1.0.4, 10.1.0.22 }
#### -- Scrub Rules -- ####
scrub in on $wanint all random-id max-mss 1360
scrub out on $wanint all random-id max-mss 1360
scrub in all
scrub out all
#### -- NAT Rules -- ####
# WireGuard Clients NAT
nat on $wanint inet from <wg_clients> to any -> ($wanint)
block all
#### -- Fail2Ban Rules -- ####
anchor "f2b/*"
pass out quick keep state
#### -- UDP Rules -- ####
pass in quick on $wg_ifs from $wg_net to any keep state
pass in quick on $wanint proto udp from any to ($wanint) port $wg_ports keep state
# HTTP/3 Protocol
pass in quick on $wanint proto udp to ($wanint) port 443 keep state
#### -- TCP Rules -- ####
# Mail Protocols
pass in quick proto tcp from any to ($wanint) port {25, 465, 993, 995} synproxy state
# HTTP
pass in quick on $wanint proto tcp to ($wanint) port {80, 443} modulate state

set block-policy drop

Any packets that don't match a rule are dropped.
In the case of return, for TCP, it returns RST to notify the other party and close the connection.
For UDP, since there is no TCP connection to begin with, it just returns ICMP port unreachable.

set optimization conservative

This is an optimization rule described as a conservative setting in the documentation.
In the case of aggressive, connections are expired preferentially, but in an environment like WireGuard where you want to maintain a constant connection state, the connection would be cut just because no new packets have arrived.

Also, there's even a satellite setting, which sounds cool.

       high-latency
             A high-latency environment (such  as  a  satellite  connec-
             tion).
       satellite
             Alias for high-latency.

set state-policy if-bound

It maintains and remembers the state to pass packets, preventing inconsistencies.
The result of pfctl -ss is the state. For example, in the following configuration:

Client ←→ wg ←→ Server

Since information about "which interface it was created on" is linked to the state, inconsistencies are prevented by only allowing communication that passes through the same interface.

In the case of normal floating:
Inbound: wg0
Outbound: vtnet0
Return: Either vtnet0 or wg0 is OK

However, in the case of state-policy if-bound:
Inbound: wg0
Outbound: vtnet0
Return: Must be wg0, otherwise it's NG

set ruleset-optimization basic

It handles rule optimization nicely.
From the official documentation:

  1. remove duplicate rules

  2. remove rules that are a subset of another rule

  3. combine multiple rules into a table when advanta-geous

  4. re-order the rules to improve evaluation perfor-mance

In Japanese:

  1. Remove duplicate rules

  2. Remove rules contained within other rules

  3. Combine multiple rules into tables as needed

  4. Reorder rules to improve evaluation performance

In other words, it cleans up even roughly written pf rules. By simply enabling this, it's a god-tier feature that organizes rules nicely even if you write pf rules in a way that's easy for you to read.

Variable Definitions

Specifying the WAN interface name

wanint="vtnet0"

Defined as the assigned IPv4 address and an explicit outbound IP

exsrv1 = "163.44.113.145"

Variable and table definitions for WireGuard

wg_ifs  = "{ wg0, wg1 }"
wg_net = "10.1.0.0/24"
wg_ports="{51820}"
table <wg_clients> const { 10.1.0.2, 10.1.0.4, 10.1.0.22 }

Scrub Rules

In this case, the MSS value is specified for both incoming and outgoing packets from the WAN.
Regarding all random-id, it seems to be for preventing the OS itself from being identified from the packets, but as far as I've checked with sudo tcpdump -n -v -i $interface on GNU/Linux, it seems to be enabled by default.

04:15:18.538048 IP (tos 0x0, ttl 52, id 61686, offset 0, flags [DF], proto TCP (6), length 52)
04:15:18.538049 IP (tos 0x0, ttl 52, id 61687, offset 0, flags [DF], proto TCP (6), length 131)
04:15:18.538179 IP (tos 0x0, ttl 64, id 65430, offset 0, flags [DF], proto TCP (6), length 52)
04:15:18.538223 IP (tos 0x0, ttl 52, id 61688, offset 0, flags [DF], proto TCP (6), length 131)

Also, set in all and out all to scrub interfaces other than the WAN interface.

scrub in on $wanint all random-id max-mss 1360
scrub out on $wanint all random-id max-mss 1360
scrub in all
scrub out all

NAT Rules

Specific WireGuard clients exit through the NAT of the WireGuard server's WAN interface.
However, in this case, they only exit via IPv4. This is because the WireGuard server does not distribute IPv6 addresses, so only inet from is used.

nat on $wanint inet from <wg_clients> to any -> ($wanint)

Fail2Ban Rules

Lastly, here are the rules for Fail2Ban to add attacker source IPs to a pf table and block them. Packets from IPs added to the pf table are directed to Fail2Ban rules via anchor "f2b/*".
It is placed above the filter rules set to quick because it needs to match before them.
Since the default rule block all is just before this, any packets that don't match here will be blocked.

/usr/local/etc/fail2ban/action.d/pf.conf:

# Option: block
#
# The action you want pf to take.
# Probably, you want "block quick", but adjust as needed.
# If you want to log all blocked use "blog log quick"
block = block quick

Since this setting adds block quick Fail2Ban rules, it is placed higher up.

In other words, first, packets from IPs added to the pf table are directed to Fail2Ban rules via anchor "f2b/*", and then:

anchor "f2b/*"

As a filter rule, the very first rule allows outgoing packets.

pass out quick keep state

UDP Rules

Allow rules for the WireGuard server.
Allow 51820/udp and allow all virtual NICs on WireGuard in $wg_ifs.
Perhaps this should be stricter just in case?

pass in quick on $wg_ifs from $wg_net to any keep state
pass in quick on $wanint proto udp from any to ($wanint) port $wg_ports keep state

In this case, since state is already maintained, all packets coming through the WireGuard tunnel will be allowed. If this order were reversed, the pass in quick on $wg_ifs from $wg_net to any keep state rule would not work because it is based on the premise of being a WireGuard tunnel.

Allow 443/udp to enable HTTP/3.

# HTTP/3 Protocol
pass in quick on $wanint proto udp to ($wanint) port 443 keep state

TCP Rules

As anyone who has used nmap knows, TCP port scans are performed very quickly, so attackers will find them immediately just by changing the port. Since nmap scans take a long time for UDP ports, I've made it more secure by only allowing SSH access via the WireGuard tunnel. In other words, I think a configuration that effectively wraps TCP in UDP is the most secure.
Since Fail2Ban is installed, if there is an SSH attack, the source IP is added to the pf table and blocked. This is essentially like a honeypot.

For mail protocols, synproxy is enabled to prevent SYN Flood attacks.
Regarding HTTP in this case, the destination IP is specified with ($wanint) to dynamically match the IP address assigned to the WAN interface. inet is not explicitly specified in order to allow both IPv4 and IPv6.

Packets arriving via IPv6 will be dynamically matched against the IPv6 address assigned to the WAN interface because the destination IP is specified with ($wanint).

# Mail Protocols
pass in quick proto tcp from any to ($wanint) port {25, 465, 993, 995} synproxy state
# HTTP
pass in quick on $wanint proto tcp to ($wanint) port {80, 443} modulate state

Well, that turned out to be quite long...

Related Posts