[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)

From: <bugzilla-noreply_at_freebsd.org>
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.