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/pfdelegated) can drive one side of the race by issuing concurrentifconfig/ 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 (causingin6.c:803to fireifaddr_eventon 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.
Recommended fix
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
sys/net/pf/pf_if.c:875-916β the event handlers that drivepfi_table_updatewithout holdingpf_token.sys/net/pf/pf_ioctl.c:989β contrast: the ioctl path that correctly takespf_token.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0604 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 pinningrace_live.shβ Shell driver: pfctl rule churn (path A) + ifaddr churn (path B)race_proof.cβ Code-level proof: pthreads program replicating the race patternfix.diffβ Correct fix: dispatchpfi_table_updateto netisr0 (NOT the finding's lwkt_token proposal which deadlocks)VERDICT.mdβ Full analysispanic.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.
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)
-
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. -
pfioctl path (caller's CPU):
pfioctl()acquirespf_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) callspfi_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). -
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 vianetisr_domsg(&msg.base, 0)(pf_if.c:629) - CPU X blocks waiting for netisr0 reply - Line 522: After reply, readspfi_buffer_cntand passespfi_buffertopfr_set_addrs()β on CPU X -
ifaddr_event path (netisr0):
SIOCAIFADDRis dispatched to netisr0 (in.c:248). Insidein_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. -
The overlap: CPU X is inside
pfi_table_update(betweencnt=0and readback, blocked on netisr_domsg). Itspfi_instance_adddispatch message is queued on netisr0. Before netisr0 processes that message, it processes a pending SIOCAIFADDR β fires ifaddr_event β enters ITS OWNpfi_table_updateβ setscnt=0, fills buffer, readscnt, callspfr_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 contaminatedpfi_buffer_cntβ itspfr_set_addrs()sees addresses from BOTH invocations. -
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
-
race_churn.cβ C harness for concurrentSIOCAIFADDR/SIOCDIFADDRchurn withlwp_setaffinityCPU pinning. Corrected to use DragonFlyBSD'slwp_setaffinity(2)andifra_mask(notifra_netmask). -
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). -
race_proof.cβ Code-level proof: a userspace pthreads program that replicates the exactpfi_table_updatepattern (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). -
fix.diffβ The correct dispatch-to-netisr0 fix (NOT the finding's lwkt_token proposal which deadlocks).
Fix verification
fixedVALIDATED 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
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.
No comments yet.