[Bug 299320] pf: regression in 15.x: nat rule matched inside a nat-anchor is overridden by a later main-ruleset nat rule (first-match violated)
- Reply: bugzilla-noreply_a_freebsd.org: "[Bug 299320] pf: regression in 15.x: nat rule matched inside a nat-anchor is overridden by a later main-ruleset nat rule (first-match violated)"
- Reply: bugzilla-noreply_a_freebsd.org: "[Bug 299320] pf: regression in 15.x: nat rule matched inside a nat-anchor is overridden by a later main-ruleset nat rule (first-match violated)"
- Reply: bugzilla-noreply_a_freebsd.org: "[Bug 299320] pf: regression in 15.x: nat rule matched inside a nat-anchor is overridden by a later main-ruleset nat rule (first-match violated)"
- Go to: [ bottom of page ] [ top of archives ] [ this month ]
Date: Mon, 12 Oct 2026 00:32:38 UTC
https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=299320
Bug ID: 299320
Summary: pf: regression in 15.x: nat rule matched inside a
nat-anchor is overridden by a later main-ruleset nat
rule (first-match violated)
Product: Base System
Version: 15.1-RELEASE
Hardware: Any
OS: Any
Status: New
Severity: Affects Many People
Priority: ---
Component: kern
Assignee: bugs@FreeBSD.org
Reporter: matt@tnpi.net
Created attachment 275606
--> https://bugs.freebsd.org/bugzilla/attachment.cgi?id=275606&action=edit
shell script that reproduced the regression
On 15.1-RELEASE-p4, when a nat rule inside an anchor (named or wildcard
nat-anchor) matches, rule evaluation continues after the anchor and a
later nat rule in the main ruleset replaces the translation. Translation
rules are first-match (pf.conf(5)), so the anchored rule should win.
This is a regression: 14.5-RELEASE-p1 passes the same repro.
With no later matching rule, the anchored rule translates correctly
(case 4), so the anchor contents are loaded and evaluated; the match
just isn't final. The same rule placed in the main ruleset works
(case 3).
Counters are also misattributed on 15.1. In case 4 the match comes from
nat-anchor "named" (@0), but the main ruleset credits the packets and
state to nat-anchor "wild/*" (@1), the last anchor rule evaluated. On
14.5 the matching child rule is credited and both anchor rules stay at 0.
rdr rules in rdr-anchors behave correctly on the same 15.1 host
(observed in production; not covered by this repro).
Repro
-----
Attached pf-anchor-nat-repro.sh. Self-contained: a lo1 alias plus
nc -s, TEST-NET addresses, no jails, no replies needed. NAT state is
created by the first outbound SYN, so the translation is visible in
pfctl -ss. Note: it replaces the loaded pf ruleset; run it on a scratch
VM.
Ruleset under test:
ext_if="vtnet0"
nat-anchor "named"
nat-anchor "wild/*"
nat on $ext_if inet from 10.99.0.0/24 to any -> 192.0.2.99
pass all
Anchored rule, loaded into "named" or "wild/test":
nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
Results
15.1-RELEASE-p4 14.5-RELEASE-p1
case 1 named anchor + later catch-all 192.0.2.99 FAIL 192.0.2.10 ok
case 2 wildcard anchor + catch-all 192.0.2.99 FAIL 192.0.2.10 ok
case 3 same rule in main ruleset 192.0.2.10 ok 192.0.2.10 ok
case 4 named anchor, no catch-all 192.0.2.10 ok 192.0.2.10 ok
match credited to wrong anchor rule child rule
15.1, case 1:
all tcp 192.0.2.99:58420 (10.99.0.5:30677) -> 198.51.100.1:8081
SYN_SENT:CLOSED
@0 nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 51 Packets: 0 Bytes: 0 States: 0 ]
15.1, case 4 and main ruleset counters:
all tcp 192.0.2.10:60110 (10.99.0.5:55221) -> 198.51.100.1:8084
SYN_SENT:CLOSED
@0 nat-anchor "named" all
[ Evaluations: 6 Packets: 0 Bytes: 0 States: 0 ]
@1 nat-anchor "wild/*" all
[ Evaluations: 6 Packets: 2 Bytes: 120 States: 1 ]
14.5, case 1:
all tcp 192.0.2.10:50173 (10.99.0.5:60189) -> 198.51.100.1:8081
SYN_SENT:CLOSED
@0 nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 124 Packets: 2 Bytes: 120 States: 1 ]
Full output for both versions attached.
Expected: cases 1 and 2 translate to 192.0.2.10 (the first matching nat
rule, inside the anchor).
Actual (15.1): cases 1 and 2 translate to 192.0.2.99 (the main-ruleset
rule after the anchor).
Impact
------
Per-jail outbound NAT via anchors with a default
"nat ... -> ($ext_if)" fallback (a common pattern, e.g. Mail Toaster 6)
silently uses the fallback. When the external interface has several
aliases, ($ext_if) becomes round-robin, so outbound mail leaves from a
rotating source address, breaking SPF/PTR alignment.
Workaround
----------
Put per-jail nat rules directly in the main ruleset, above the fallback.
Versions
--------
Affected: 15.1-RELEASE-p4 GENERIC arm64 (fresh VM)
15.1-RELEASE-p4 amd64 (production)
Not affected: 14.5-RELEASE-p1 GENERIC amd64
➜ cat pf-anchor-nat-repro-14.5.out
14.5-RELEASE-p1
14.5-RELEASE-p1
14.5-RELEASE-p1
FreeBSD mail.example.ca 14.5-RELEASE-p1 FreeBSD 14.5-RELEASE-p1
releng/14.5-n274884-231eee334c09 GENERIC amd64
ext_if=pub0
== case 1: named anchor, catch-all present (want 192.0.2.10)
nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
all tcp 192.0.2.10:50173 (10.99.0.5:60189) -> 198.51.100.1:8081
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 124 Packets: 2 Bytes: 120 States: 1
]
== case 2: wildcard anchor, catch-all present (want 192.0.2.10)
nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
all tcp 192.0.2.10:52931 (10.99.0.5:62784) -> 198.51.100.1:8082
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 136 Packets: 2 Bytes: 120 States: 1
]
== case 3: control, same rule in main ruleset (want 192.0.2.10)
all tcp 192.0.2.10:52448 (10.99.0.5:17217) -> 198.51.100.1:8083
SYN_SENT:CLOSED
== case 4: named anchor, NO catch-all (want 192.0.2.10)
all tcp 192.0.2.10:57667 (10.99.0.5:60856) -> 198.51.100.1:8084
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on pub0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 54 Packets: 2 Bytes: 120 States: 1
]
== main nat ruleset counters
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat-anchor "named" all
[ Evaluations: 55 Packets: 0 Bytes: 0 States: 0
]
@1 nat-anchor "wild/*" all
[ Evaluations: 54 Packets: 0 Bytes: 0 States: 0
]
root@qemu:~ # cat pf-anchor-nat-repro.out
15.1-RELEASE-p4
15.1-RELEASE-p4
15.1-RELEASE-p4
FreeBSD qemu.m3.home.example.net 15.1-RELEASE-p4 FreeBSD 15.1-RELEASE-p4
releng/15.1-n283627-db6066678b33 GENERIC arm64
ext_if=vtnet0
== case 1: named anchor, catch-all present (want 192.0.2.10)
nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
all tcp 192.0.2.99:58420 (10.99.0.5:30677) -> 198.51.100.1:8081
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 51 Packets: 0 Bytes: 0 States: 0
]
== case 2: wildcard anchor, catch-all present (want 192.0.2.10)
nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
all tcp 192.0.2.99:56356 (10.99.0.5:19113) -> 198.51.100.1:8082
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 4 Packets: 0 Bytes: 0 States: 0
]
== case 3: control, same rule in main ruleset (want 192.0.2.10)
all tcp 192.0.2.10:64845 (10.99.0.5:35970) -> 198.51.100.1:8083
SYN_SENT:CLOSED
== case 4: named anchor, NO catch-all (want 192.0.2.10)
all tcp 192.0.2.10:60110 (10.99.0.5:55221) -> 198.51.100.1:8084
SYN_SENT:CLOSED
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat on vtnet0 inet from 10.99.0.5 to any -> 192.0.2.10
[ Evaluations: 3 Packets: 2 Bytes: 120 States: 1
]
== main nat ruleset counters
No ALTQ support in kernel
ALTQ related functions disabled
@0 nat-anchor "named" all
[ Evaluations: 6 Packets: 0 Bytes: 0 States: 0
]
@1 nat-anchor "wild/*" all
[ Evaluations: 6 Packets: 2 Bytes: 120 States: 1
]
--
You are receiving this mail because:
You are the assignee for the bug.