PF Simple Ruleset: bug or expected behavior?

As a beginner, I would like to understand PF configuration behavior so that I can write rules that match my security policies. I intend to post later about what I'm learning. Right now, I'm banging on edge cases to suss out what's going on inside the evaluation engine without having to dig into the code. It's been very instructive. However, I stumbled upon a sequence that's confusing me. I'm having a hard time understanding how it's not a bug.

Although no one would ever write rules like this, my expectation is that packets should pass. Instead they are getting blocked. The set up is simple—two machines on two separate networks (10.10.100.1/24, 10.10.200.1/24) joined by a gateway. Here's the config:

Code:
# First configuration
pass  in  # first pass
block in
pass  in  # second pass

ping from either machine to the other fails with this configuration!!!

Code:
ping -c1 -t2 10.10.200.1
PING 10.10.200.1 (10.10.200.1): 56 data bytes

--- 10.10.200.1 ping statistics ---
1 packets transmitted, 0 packets received, 100.0% packet loss

If I comment out the first "pass in", pings go through. If I remove the second "pass in" (leaving the first), it fails as expected.
Code:
# Second configuration
#pass  in  # first pass
block in
pass  in  # second pass

Pings in the second configuration go through.

Code:
ping -c1 -t2 10.10.200.1
PING 10.10.200.1 (10.10.200.1): 56 data bytes
64 bytes from 10.10.200.1: icmp_seq=0 ttl=63 time=0.154 ms

--- 10.10.200.1 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.154/0.154/0.000 ms

Pinging the gateway's subnet address for each machine also fails with the first configuration. I'd expect that to be the case as it's consistent.

Replacing every "in" with "out" or "all" behaves the same way: fail with the first configuration, succeed with the second. I also tested the optimization switch ( -o basic vs -o none) with no difference in odd behavior (again, first fails, second works).

If this is not a bug, what do I not understand? If this is a bug, I'm happy to file it.
 
You can add `log` to each statement in the ruleset and then `tcpdump -vvnetti pflog0` — it will show you what rule matched and what the action was.
 
So pf is basically "last match wins" but "quick short circuits".
In your first example of of "pass/block/pass" it appears as if the pass after the block is not getting evaluated.
Is this correct behavior? I don't know. I can make arguments either way.

But I've always started with my security policies and a whiteboard that has a box "this machine" with it's interfaces.
Now you look at your sec policies "tcp to dst port XXX in on network AAA" lets you start drawing arrows on your whiteboard, which for me has led to the rules.

But with pf, you need to keep in mind "last match wins, quick shortcuts and does not evaluate any subsequent rules" That typically lends to order of "generic block rules followed by specific pass rules"
 
You can add `log` to each statement in the ruleset and then `tcpdump -vvnetti pflog0` — it will show you what rule matched and what the action was.
Thanks. I actually had logging on when I noticed the problem with a slightly more complex configuration and boiled it down to this. Although, I didn't use that switch sequence. Only one rule is fired: rule 1/0(match): block in on e5b_gateway.

As expected, logging agrees with the behavior observed. Also, I ran pfctl -sr -vv to show the ruleset and it only shows two rules for the first configuration! That is, rules for "pass" and then "block". The second pass is not listed as a rule!

Code:
pfctl -sr -vv
No ALTQ support in kernel
ALTQ related functions disabled
@0 pass in log all flags S/SA keep state
  [ Evaluations: 107       Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 73612 State Creations: 0     ]
  [ Last Active Time: N/A ]
@1 block drop in log all
  [ Evaluations: 56        Packets: 56        Bytes: 4360        States: 0     ]
  [ Inserted: uid 0 pid 73612 State Creations: 0     ]
  [ Last Active Time: Tue Mar 11 13:13:04 2025 ]
 
I made one slight adjustment based upon folks' comments, I added a "from any" to the second rule hoping to force the engine to recognize it as a rule, which it did not.

Code:
pass  in log
block in log
pass  in log from any

Code:
# jexec gateway pfctl -sr -vv
No ALTQ support in kernel
ALTQ related functions disabled
@0 pass in log all flags S/SA keep state
  [ Evaluations: 42        Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 74555 State Creations: 0     ]
  [ Last Active Time: N/A ]
@1 block drop in log all
  [ Evaluations: 24        Packets: 24        Bytes: 2364        States: 0     ]
  [ Inserted: uid 0 pid 74555 State Creations: 0     ]
  [ Last Active Time: Tue Mar 11 13:41:32 2025 ]
 
Is behavior diffenent with set block-policy return ?
It's an interesting thought. Just tried it and no, same behavior.

However, I'm not surprised. Take a look at the rule dumps I've been showing. There's no second rule for "pass in" when the first "pass in" is present, even when it's a slightly different rule from a configuration perspective. Perhaps, pf doesn't consider it a second rule from an evaluation perspective? And, when that happens, it drops the rule for some reason?
 
pfctl -vf /etc/pf.conf
Ooooh. That was a good one! Also, I tried "-o none" just in case the optimizer is playing tricks (maybe the switch doesn't work?). Check it out:

Code:
# pfctl -o none -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
ext_if = "igb0"
netA_if = "{ e0b_gateway }"
netB_if = "{ e5b_gateway }"
int_if = "{ e0b_gateway, e5b_gateway }"
set block-policy return
pass in log all flags S/SA keep state
block return in log all
pass in log all flags S/SA keep state -- rule was already present

See that last line? Why does it do that? I don't recall reading anywhere about pfctl dumping rules that match or are exactly the same as other rules. Seems like order of evaluation could be important, however. I wonder if I can write a rule sequence that looks like the same rule, but in evaluation order it could be different. What happened to last match wins?!

Regardless, is this a published behavior?
 
What does `pfctl -vf /etc/pf.conf` say as it loads the ruleset?
OK, how is this not a bug?
Code:
pass  in log tagged FOO
block in log
match in log tag FOO
pass  in log tagged FOO
The ping fails with this.
Code:
# pfctl -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
pass in log all flags S/SA keep state tagged FOO
block return in log all
match in log all tag FOO
pass in log all flags S/SA keep state tagged FOO -- rule was already present
And, succeeds when the first line is commented out, which is matched by the verbose rule load.
Code:
# pfctl -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
block return in log all
match in log all tag FOO
pass in log all flags S/SA keep state tagged FOO
 
pf.conf(5)
set ruleset-optimization
...
basic
Enable basic ruleset optimization. This is the default
behaviour. Basic ruleset optimization does four things
to improve the performance of ruleset evaluations:

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
 
Thank you. I'm attaching the result of "-o none", which also removes the rule. FWIW, I don't see how this isn't a bug in either case. Either -o none doesn't work correctly because it removed a duplicate, or -o basic is overly aggressive as it does not recognize the intervening match tag (see this comment).
Code:
# pfctl -o none -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
ext_if = "igb0"
netA_if = "{ e0b_gateway }"
netB_if = "{ e5b_gateway }"
int_if = "{ e0b_gateway, e5b_gateway }"
set block-policy return
pass in log all flags S/SA keep state tagged FOO
block return in log all
match in log all tag FOO
pass in log all flags S/SA keep state tagged FOO -- rule was already present
 
OK, how is this a proper optimization to make? Note, I've got the "-o none" switch set. And, this let's pings through even though the very last line is a block! It is consistent with the prior odd behavior.

Code:
# /etc/pf.conf
block in log
pass  in log
block in log

# pfctl -o none -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
block return in log all
pass in log all flags S/SA keep state
block return in log all -- rule was already present

# pfctl -sr -vv
No ALTQ support in kernel
ALTQ related functions disabled
@0 block return in log all
  [ Evaluations: 10        Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 77029 State Creations: 0     ]
  [ Last Active Time: N/A ]
@1 pass in log all flags S/SA keep state
  [ Evaluations: 2         Packets: 2         Bytes: 312         States: 2     ]
  [ Inserted: uid 0 pid 77029 State Creations: 2     ]
  [ Last Active Time: Tue Mar 11 14:58:12 2025 ]

Now, check out this one, which is the inversion of the earlier tag configuration in that comment. Ping succeeds here, too.
Code:
# /etc/pf.conf
block in log tagged FOO
pass  in log
match in log tag FOO
block in log tagged FOO

# pfctl -o none -vf /etc/pf.conf
No ALTQ support in kernel
ALTQ related functions disabled
block drop in log all tagged FOO
pass in log all flags S/SA keep state
match in log all tag FOO
block drop in log all tagged FOO -- rule was already present

# pfctl -sr -vv
No ALTQ support in kernel
ALTQ related functions disabled
@0 block drop in log all tagged FOO
  [ Evaluations: 0         Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 77308 State Creations: 0     ]
  [ Last Active Time: N/A ]
@1 pass in log all flags S/SA keep state
  [ Evaluations: 0         Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 77308 State Creations: 0     ]
  [ Last Active Time: N/A ]
@2 match in log all tag FOO
  [ Evaluations: 0         Packets: 0         Bytes: 0           States: 0     ]
  [ Inserted: uid 0 pid 77308 State Creations: 0     ]
  [ Last Active Time: N/A ]

Finally, I also tested commenting out the first block line, and the ping was blocked because the second block (as the last line) blocked it.
 
Back
Top