β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0604

Cross-CPU race on global pfi_buffer corrupts pf dynamic-interface address tables (filtering bypass)

Field Value
ID DF-0604
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:H/A:L
CWE CWE-362 Concurrent Execution using Shared Resource with Improper Synchronization (Race Condition)
File sys/net/pf/pf_if.c
Lines 74-76, 505-528, 619-630
Area net/pf (firewall dynamic interface address tables)
Confidence likely
Discovered 2026-07-02
Reported pending

Summary

pfi_buffer, pfi_buffer_cnt, pfi_buffer_max are file-scope globals shared across all CPUs. pfi_table_update() zeroes pfi_buffer_cnt on the caller's CPU (:511), dispatches the actual fill to netisr0 via pfi_instance_add()β†’netisr_domsg() (:514, :518, :629), then on the caller's CPU reads pfi_buffer_cnt back and hands the lot to pfr_set_addrs() (:522). Two concurrent pfi_table_update() invocations on different CPUs (e.g. two concurrent ifaddr_event firings from independent DHCP / IPv6-RA / ifconfig operations) interleave their buffer fills, so each caller's pfr_set_addrs() sees a count and contents contaminated by the other. The dynamic address table for (ifname) rules ends up with wrong addresses, enabling pf filtering bypass or spurious blocks.

Root cause

Globals declared at sys/net/pf/pf_if.c:74-76:

74: static struct pfr_addr  *pfi_buffer;
75: static int       pfi_buffer_cnt;
76: static int       pfi_buffer_max;

pfi_table_update() (pf_if.c:505-528) executes on the caller's CPU without pf_token (the ifaddr_event/ifnet_attach_event/ ifnet_detach_event/group_change_event handlers at pf_if.c:875-916 do not acquire pf_token; only the ioctl path through pfioctl() at pf_ioctl.c:989 does).

At pf_if.c:511 the caller does pfi_buffer_cnt = 0; then pfi_instance_add() at pf_if.c:619-630 synchronously dispatches pfi_instance_add_dispatch() to netisr0, which increments pfi_buffer_cnt via pfi_address_add() at pf_if.c:655. Because both the cnt=0 store (line 511) and the pfr_set_addrs() read (line 522) happen on the caller's CPU while the increments happen on netisr0, two interleaved invocations produce (a) one invocation's pfi_buffer_cnt = 0 clobbering the other's count mid-fill, and (b) one invocation reading a pfi_buffer_cnt that includes addresses the OTHER invocation's netisr0 dispatch appended.

The resulting pfr_set_addrs() call (pf_if.c:522-524) commits a wrong address set to the table. pfi_address_add() also extends the buffer (pf_if.c:638-652) on netisr0 without any per-instance ownership; concurrent grows memcpy the same source into different destinations and reassign pfi_buffer globally, again racing.

Threat model & preconditions

  • Attacker position: any privileged user (root on host, or root in a jail with /dev/pf delegated) can drive one side of the race by issuing concurrent ifconfig / DHCP / IPv6-RA-triggered address changes on multiple interfaces simultaneously, while pf rules using dynamic interface substitution (pass on (em0), block from (em0:network), etc.) are loaded. The remote/unprivileged side is also reachable: an attacker on the wire sending IPv6 Router Advertisements (causing in6.c:803 to fire ifaddr_event on the victim's network) can race against address changes triggered by other control-plane activity.
  • Privileges gained or impact: integrity violation of the firewall's address tables: a block from (em0) table may end up missing em0's real address (filtering bypass) or carrying an unrelated interface's address (spurious matching).
  • Required config or capabilities: pf enabled with dynamic interface substitution rules; 2+ CPU system; concurrent address churn on multiple interfaces.
  • Reachability: concurrent ifconfig <iface> alias/-alias, DHCP lease renewal, link flaps, IPv6-RA-driven autoconfiguration on multiple interfaces.

Proof of concept

PoC: findings/poc/DF-0604/race.sh (shell + ifconfig loops). Set up two interfaces with pf dynamic rules on a 2+ CPU DragonFly system. Load /etc/pf.conf:

ext = "em0"
int = "em1"
block in quick on $ext from ($int:network)
pass in on $ext

Then run two concurrent loops in separate processes/CPUs:

# loop A β€” churn em0 addresses
while :; do ifconfig em0 alias 10.0.0.1/24; ifconfig em0 -alias 10.0.0.1; done &
# loop B β€” churn em1 addresses
while :; do ifconfig em1 alias 192.168.1.1/24; ifconfig em1 -alias 192.168.1.1; done &

Each alias/-alias triggers EVENTHANDLER_INVOKE(ifaddr_event) (netinet/in.c:634,691,726) β†’ pfi_ifaddr_event (pf_if.c:905) β†’ pfi_kif_update β†’ pfi_dynaddr_update β†’ pfi_table_update. With both loops running, pfi_table_update for em0 and em1 interleave on the global pfi_buffer.

Expected outcome

pfctl -T show on the auto-generated :network table reveals addresses belonging to a different interface than the one named in the rule. A packet from em0's real address that should be blocked by block from ($int:network) may pass (filtering bypass).

Impact

  • Blast radius: any SMP DragonFly system running pf with dynamic interface substitution under concurrent address churn (routers, VPN concentrators, hosts on IPv6-RA-capable networks).
  • Severity rationale: Medium. Integrity violation of the firewall's address tables β€” filtering bypass or spurious matching. Privileged attacker (root), high race complexity, but also reachable remotely via IPv6-RA. CVSS 3.1 base β‰ˆ 5.2.
  • Reliability: race fires under sustained address churn; each alias/ -alias cycle is a new attempt.

Serialize pfi_table_update globally, or make the buffer per-call. Simplest correct fix is a dedicated token around the whole buffer-update critical section:

--- a/sys/net/pf/pf_if.c
+++ b/sys/net/pf/pf_if.c
@@ -74,6 +74,7 @@
 static struct pfr_addr *pfi_buffer;
 static int      pfi_buffer_cnt;
 static int      pfi_buffer_max;
+static struct lwkt_token pfi_buffer_token = LWKT_TOKEN_INITIALIZER(pfi_buffer_token);

 static eventhandler_tag     pfi_attach_cookie;
@@ -505,8 +506,10 @@ pfi_table_update(struct pfr_ktable *kt, struct pfi_kif *kif, int net, int flags)
    int          e, size2 = 0;
    struct ifg_member   *ifgm;

+   lwkt_gettoken(&pfi_buffer_token);
    pfi_buffer_cnt = 0;
@@ -521,8 +524,10 @@ pfi_table_update(struct pfr_ktable *kt, struct pfi_kif *kif, int net, int flags)
                   &size2, NULL, NULL, NULL, 0,
                   PFR_TFLAG_ALLMASK))) {
        kprintf("%s: cannot set %d new addresses into table %s: %d\n",
            __func__, pfi_buffer_cnt, kt->pfrkt_name, e);
    }
+   lwkt_reltoken(&pfi_buffer_token);
 }

Long-term the buffer should be made an automatic (stack) buffer in pfi_table_update since netisr0 dispatch is synchronous.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0604 Β· 14 files
FileTypeDescriptionSize
VERDICT.md verdict Full analysis: mechanism, why finding's PoC trigger was wrong, correct race path, fix rationale 6.8 KB ↓ raw
race_churn.c trigger-source C harness for concurrent SIOCAIFADDR churn with CPU pinning 4.8 KB view raw
race_live.sh trigger-source Corrected live race trigger: pfctl churn (caller CPU) + ifaddr churn (netisr0) 2.4 KB view raw
race_proof.c trigger-source Code-level proof: pthreads program replicating the pfi_table_update race pattern 7.0 KB view raw
build.sh build-script Build race_churn and race_proof 360 B view raw
run.sh run-script Run the full race trigger sequence 1.3 KB view raw
fix.diff suggested-fix Dispatch entire pfi_table_update to netisr0 (supersedes finding's lwkt_token which deadlocks) 1.8 KB view raw
panic.txt panic-signature Fatal trap 9 GP fault in rn_walktree_at with 10 race detections 1.4 KB view raw
env.txt environment uname, cc version, CPU count, kernel versions tested 635 B view raw
fix_build.log build-log Full build log for the fixed kernel (#2) 5.6 MB ↓ download
fix_run.log run-log Fixed kernel validation run: 0 races, 0 panics 724 B view raw
README.md readme human reproduce doc 2.9 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human reproduce doc
↓ download raw

DF-0604 β€” PoC: pfi_buffer cross-CPU race (pfctl churn + ifaddr churn)

Privileged local kernel-panic PoC. The global pfi_buffer/pfi_buffer_cnt/ pfi_buffer_max in sys/net/pf/pf_if.c:74-76 are shared across all CPUs with no lock. Two concurrent pfi_table_update() callers on different CPUs race on the shared buffer, corrupting pf's dynamic-interface address tables and causing a kernel panic.

Key correction to the finding

The finding's original PoC (concurrent ifconfig alias on two interfaces) cannot trigger the race because SIOCAIFADDR dispatches ALL work to netisr0 (sys/netinet/in.c:248), so all ifaddr_event firings serialize on netisr0's single thread.

The actual race window is between: - (A) pfioctl path (DIOCADDRULE β†’ pfi_dynaddr_setup β†’ pfi_table_update) which runs on the caller's CPU (pfioctl does NOT dispatch to netisr0) - (B) ifaddr_event path which runs on netisr0

These two paths share the global pfi_buffer with no lock and can overlap.

Files

  • race_churn.c β€” C harness for concurrent SIOCAIFADDR churn with CPU pinning
  • race_live.sh β€” Shell driver: pfctl rule churn (path A) + ifaddr churn (path B)
  • race_proof.c β€” Code-level proof: pthreads program replicating the race pattern
  • fix.diff β€” Correct fix: dispatch pfi_table_update to netisr0 (NOT the finding's lwkt_token proposal which deadlocks)
  • VERDICT.md β€” Full analysis
  • panic.txt β€” Panic signature from the baseline (instrumented #1 kernel)
  • fix_build.log / fix_run.log β€” Build and validation logs for the fix

Setup (as root on DragonFlyBSD guest)

kldload pf
cat > /tmp/pf_0604.conf << 'EOF'
pass quick on vtnet0 inet from (vtnet0) to any
pass quick on vtnet0 inet6 from (vtnet0) to any
block in quick from (vtnet0:network) to any
EOF
pfctl -f /tmp/pf_0604.conf

Build

cc -O2 -o race_churn race_churn.c
cc -O2 -pthread -o race_proof race_proof.c

Run

# Code-level proof (quick β€” shows the race pattern is fundamental)
./race_proof

# Live race trigger (requires root + pf loaded)
sh ./race_live.sh 30

Expected outcome

Unpatched kernel (with race-detection instrumentation): - DF0604_RACE: concurrent pfi_table_update depth=2 cpu=0 messages - pfi_table_update: cannot set N new addresses into table vtnet0: N - Fatal trap 9: general protection fault at rn_walktree_at+0xa8 - Guest panics

Fixed kernel (dispatch-to-netisr0): - 0 race detections, 0 panics, pf rules stay functional - Guest stays up

Fix

The fix (fix.diff) dispatches the entire pfi_table_update to netisr0 when not already on CPU 0. Since netisr0 is single-threaded, all callers serialize inherently. This supersedes the finding's lwkt_token proposal, which would deadlock when the token holder blocks on netisr_domsg and netisr0 tries to acquire the same token for an ifaddr_event handler.

VERDICT.md verdict Full analysis: mechanism, why finding's PoC trigger was wrong, correct race path, fix rationale
↓ download raw

DF-0604 β€” VERDICT

Verdict: REPRODUCED (panic) β€” fix VALIDATED

The cross-CPU race on the global pfi_buffer is real, triggerable, and causes a kernel panic (general protection fault in rn_walktree_at). However, the finding's PoC trigger mechanism was incorrect: concurrent SIOCAIFADDR (ifconfig alias) operations cannot race because DragonFlyBSD dispatches ALL SIOCAIFADDR/SIOCDIFADDR work to netisr0 (sys/netinet/in.c:248: lwkt_domsg(netisr_cpuport(0), ...)), so all ifaddr_event firings serialize on netisr0's single thread.

The actual race window is between the pfioctl path (DIOCADDRULE β†’ pfi_dynaddr_setup β†’ pfi_table_update) which runs on the caller's CPU (not dispatched to netisr0), and the ifaddr_event path which runs on netisr0. These two paths share the global pfi_buffer/pfi_buffer_cnt/pfi_buffer_max with no lock, and can overlap.

Mechanism (confirmed, path:line at each hop)

  1. Globals declared file-scope at sys/net/pf/pf_if.c:74-76: pfi_buffer, pfi_buffer_cnt, pfi_buffer_max β€” shared across all CPUs, no lock.

  2. pfioctl path (caller's CPU): pfioctl() acquires pf_token (pf_ioctl.c:989) but does not dispatch to netisr0. When loading a rule with dynamic interface expansion (vtnet0), pfi_dynaddr_setup() (pf_if.c:392) calls pfi_kif_update() (pf_if.c:448) β†’ pfi_dynaddr_update() (pf_if.c:486) β†’ pfi_table_update() (pf_if.c:499). This runs on whatever CPU the calling process is scheduled on (CPU X).

  3. pfi_table_update() body (pf_if.c:506-528): - Line 511: pfi_buffer_cnt = 0 (store on CPU X) - Line 514: pfi_instance_add(kif->pfik_ifp, net, flags) β†’ dispatches fill to netisr0 via netisr_domsg(&msg.base, 0) (pf_if.c:629) - CPU X blocks waiting for netisr0 reply - Line 522: After reply, reads pfi_buffer_cnt and passes pfi_buffer to pfr_set_addrs() β€” on CPU X

  4. ifaddr_event path (netisr0): SIOCAIFADDR is dispatched to netisr0 (in.c:248). Inside in_control_internal (in.c:421), after processing, EVENTHANDLER_INVOKE(ifaddr_event) fires (in.c:691/726) on netisr0. β†’ pfi_ifaddr_event() (pf_if.c:906) β†’ pfi_kif_update() (pf_if.c:913) β†’ pfi_table_update() β€” runs on netisr0, also using the global buffer.

  5. The overlap: CPU X is inside pfi_table_update (between cnt=0 and readback, blocked on netisr_domsg). Its pfi_instance_add dispatch message is queued on netisr0. Before netisr0 processes that message, it processes a pending SIOCAIFADDR β†’ fires ifaddr_event β†’ enters ITS OWN pfi_table_update β†’ sets cnt=0, fills buffer, reads cnt, calls pfr_set_addrs. Then netisr0 processes CPU X's dispatch message, fills the buffer starting from the ifaddr_event's residual count. CPU X reads a contaminated pfi_buffer_cnt β€” its pfr_set_addrs() sees addresses from BOTH invocations.

  6. Corruption β†’ panic: the contaminated pfr_set_addrs() call corrupts the pfr_table's radix tree. The subsequent table walk hits a bad pointer: Fatal trap 9: general protection fault at rn_walktree_at+0xa8: movl 0x10(%r12),%eax.

Evidence

Baseline (instrumented #1 kernel, no fix):

DF0604_RACE: concurrent pfi_table_update depth=2 cpu=0   (Γ—10)
pfi_table_update: cannot set 3 new addresses into table vtnet0: 3
Fatal trap 9: general protection fault while in kernel mode
cpuid = 0; lapic id = 0
current process = Idle
Stopped at rn_walktree_at+0xa8: movl 0x10(%r12),%eax

β†’ 10 race detections, data corruption, kernel panic (GP fault).

Fixed (#2 kernel, dispatch-to-netisr0 + race detector): β†’ 0 race detections, 0 panics, 0 corruption errors across 3 runs (30s + 30s + 45s). Guest stays up, pf rules functional throughout.

Why the finding's proposed fix (lwkt_token) is WRONG

The finding proposes lwkt_gettoken(&pfi_buffer_token) around the entire pfi_table_update body. This can deadlock. The pfioctl path (CPU X) acquires the token, then blocks on netisr_domsg waiting for netisr0 to fill the buffer. If, before netisr0 processes that fill, it processes a pending SIOCAIFADDR β†’ fires ifaddr_event β†’ calls pfi_table_update β†’ tries lwkt_gettoken(&pfi_buffer_token) β€” it blocks, because CPU X holds it.

DragonFlyBSD's lwkt_token is not released across lwkt_domsg blocks (sys/kern/lwkt_token.c:655-719: token remains in td_toks until explicit lwkt_reltoken). CPU X waits for netisr0 to reply; netisr0 waits for CPU X to release the token. Deadlock. (Contrast: pf_token is also held across netisr_domsg, but netisr0 never tries to acquire pf_token in the dispatch path, so no collision.)

The correct fix (in fix.diff)

Dispatch the entire pfi_table_update to netisr0 when not already on CPU 0. Since netisr0 is single-threaded, all callers (ifaddr_event already on netisr0, pfioctl on caller CPU, group_change events, etc.) serialize inherently. No token needed; no deadlock possible.

The fix: - Extracts the existing body into _pfi_table_update_body() - Adds pfi_table_update_dispatch() netmsg handler - pfi_table_update() checks mycpuid: if 0, calls body directly; otherwise dispatches via netisr_domsg

This supersedes the finding's lwkt_token proposal.

Additional finding (memcpy direction bug)

While tracing the code, I noticed pf_if.c:647:

memcpy(pfi_buffer, p, pfi_buffer_cnt * sizeof(*pfi_buffer));

This copies FROM the newly-allocated buffer p (uninitialized) TO the old buffer pfi_buffer β€” reversed. It should be memcpy(p, pfi_buffer, ...). After growth, all existing addresses in the buffer are garbage. OpenBSD's equivalent uses bcopy(pfi_buffer, p, ...) (correct direction). This is a separate latent bug that compounds the race, but is only triggered when the buffer grows past 64 entries (>64 addresses on a single interface/group).

PoC changes

  1. race_churn.c β€” C harness for concurrent SIOCAIFADDR/SIOCDIFADDR churn with lwp_setaffinity CPU pinning. Corrected to use DragonFlyBSD's lwp_setaffinity(2) and ifra_mask (not ifra_netmask).

  2. race_live.sh β€” The corrected live race trigger. The finding's original PoC (concurrent ifconfig alias on two interfaces) cannot race because SIOCAIFADDR dispatches to netisr0. The corrected trigger races the pfioctl path (pfctl -f churn) against the ifaddr_event path (ifconfig alias churn).

  3. race_proof.c β€” Code-level proof: a userspace pthreads program that replicates the exact pfi_table_update pattern (caller does cnt=0 β†’ dispatches fill to "netisr0" thread β†’ reads cnt back) with shared globals and no lock. Demonstrates massive cross-contamination (10000+ contamination events in seconds).

  4. fix.diff β€” The correct dispatch-to-netisr0 fix (NOT the finding's lwkt_token proposal which deadlocks).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: the corrected race trigger (pfctl churn + ifaddr churn) produced 10 DF0604_RACE detections + pfr_set_addrs corruption + Fatal trap 9 GP fault panic on the instrumented-unpatched baseline (#1 kernel), and does NOT on the single-fix kernel (#2, dispatch-to-netisr0 + instrumentation): 0 races, 0 panics across 3 runs (30s+30s+45s), guest stays up, pf rules functional. The fix closes the bug.

BASELINE (#1 instrumented, unpatched code):
  DF0604_RACE: concurrent pfi_table_update depth=2 cpu=0 (x10)
  pfi_table_update: cannot set 3 new addresses into table vtnet0: 3
  Fatal trap 9: general protection fault at rn_walktree_at+0xa8
  -> guest PANICS

PATCHED (#2 dispatch-to-netisr0):
  new races: 0 (across 3 runs: 30s+30s+45s)
  panics: 0
  pfr_set_addrs errors: 0
  -> guest UP, pf rules functional
↓ fix.diffDragonFly 6.5-DEVELOPMENT #2: Fri Jul 3 06:51:03 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none β€” the race is a data-integrity/corruption bug that produces a kernel panic (CWE-362 race + CWE-787 concurrent buffer growth). No memory-corruption primitive suitable for exploitation beyond the crash. The panic is in rn_walktree_at (radix tree walk) triggered by corrupted pfr_table entries from the raced pfr_set_addrs call. Not a code-execution primitive.

Evidence (decisive lines)

Instrumented baseline (#1) β€” same workload on unpatched code:
DF0604_RACE: concurrent pfi_table_update depth=2 cpu=0 (x10)
pfi_table_update: cannot set 3 new addresses into table vtnet0: 3
Fatal trap 9: general protection fault while in kernel mode
cpuid = 0; lapic id = 0
current process = Idle; current thread = pri 12 (CRIT)
Stopped at rn_walktree_at+0xa8: movl 0x10(%r12),%eax

Fixed kernel (#2) β€” dispatch-to-netisr0:
0 race detections, 0 panics across 3 runs (30s+30s+45s)
Guest stays up, pf rules functional

PoC changes

1) race_churn.c: wrote C harness using DragonFlyBSD lwp_setaffinity(2) for CPU pinning and ifra_mask (not ifra_netmask) for SIOCAIFADDR. 2) race_live.sh: wrote the CORRECTED live trigger β€” the finding's original PoC (concurrent ifconfig alias on two interfaces) cannot race because SIOCAIFADDR dispatches to netisr0. The corrected trigger races pfioctl path (pfctl -f churn, runs on caller CPU) against ifaddr_event path (ifconfig alias churn, runs on netisr0). 3) race_proof.c: wrote code-level proof replicating the pfi_table_update pattern in pthreads. 4) fix.diff: wrote the correct dispatch-to-netisr0 fix, supersedes the finding's lwkt_token proposal which deadlocks.

Verified recommended fix

Dispatch the entire pfi_table_update to netisr0 when not already on CPU 0 (sys/net/pf/pf_if.c). Extract the body into _pfi_table_update_body(), add a netmsg dispatch handler, and have pfi_table_update() check mycpuid==0 to either call inline or dispatch via netisr_domsg. Since netisr0 is single-threaded, all callers serialize inherently β€” no token, no deadlock. This SUPERSEDES the finding's proposed lwkt_token fix (pf_if.c:74+506), which would deadlock when the token holder blocks on netisr_domsg and netisr0 tries to acquire the same token for an ifaddr_event handler. Full git-applyable diff in findings/poc/DF-0604/fix.diff.

Verdict

REPRODUCED. The cross-CPU race on the global pfi_buffer (pf_if.c:74-76) is real and causes a kernel panic (Fatal trap 9 GP fault in rn_walktree_at). However, the finding's PoC trigger was WRONG: concurrent SIOCAIFADDR (ifconfig alias) cannot race because SIOCAIFADDR dispatches ALL work to netisr0 (in.c:248), serializing all ifaddr_events on netisr0's single thread. The ACTUAL race is between the pfioctl path (DIOCADDRULE -> pfi_dynaddr_setup -> pfi_table_update on the CALLER's CPU, NOT dispatched to netisr0) and the ifaddr_event path (on netisr0). These share the global pfi_buffer with no lock. Confirmed by 10 race detections on the instrumented baseline kernel (#1) plus a pfr_set_addrs corruption error and kernel panic. The finding's proposed fix (lwkt_token around pfi_table_update) is INCORRECT β€” it deadlocks when the token holder blocks on netisr_domsg while netisr0 tries to acquire the same token for an ifaddr_event. The correct fix dispatches the entire pfi_table_update to netisr0, serializing on its single thread. Validated: 0 races, 0 panics on the fixed kernel (#2) across 3 runs.