pfsync callout handlers execute in the softclock thread without pf_token: deterministic INVARIANTS panic ~5s after SIOCSETPFSYNC syncdev configuration (ASSERT_LWKT_TOKEN_HELD at pfsync_bulkfail entry)
Summary
pfsyncioctl(SIOCSETPFSYNC) arms sc_bulkfail_tmo with 5*hz and handler pfsync_bulkfail. DragonFly callouts run in the per-CPU softclock_handler thread holding no lwkt tokens; pfsync_bulkfail opens with ASSERT_LWKT_TOKEN_HELD(&pf_token) - a guaranteed KKASSERT panic on INVARIANTS kernels (which X86_64_GENERIC ships). Same family: pfsync_timeout -> pfsync_sendout assert, pfsync_bulk_update entry assert. On non-INVARIANTS kernels the handlers run unserialized against pf_token holders - currently benign only because the engine is dead (DF-3379). Also: pfsync_clone_destroy stops callouts with callout_stop() (does not interlock against in-progress callouts) then kfree(sc) - a post-free UAF window. VERIFIED on stock guest: SIOCSETPFSYNC(syncdev=lo0) -> 5s later 'panic: assertion LWKT_TOKEN_HELD_ANY(&pf_token) failed in pfsync_bulkfail', backtrace pfsync_bulkfail <- softclock_handler, guest dead in DDB. Privileged config trigger; assert-panic primitive; the concrete if_pfsync member of the wrong-context-callout family DF-3299. Fix validated (acquire pf_token in the callout handlers + callout_drain at destroy): no panic.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3380 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.6 KB | β raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| trigger_df3380.c | β | 2.0 KB | view raw | |
| build.sh | β | 104 B | view raw | |
| run.sh | β | 152 B | view raw | |
| build.log | β | 1.6 KB | view raw | |
| run.log | β | 1.4 KB | view raw | |
| run.baseline.log | β | 1.4 KB | view raw | |
| run.fixed.log | β | 1.4 KB | view raw | |
| fix_run.log | β | 1.4 KB | view raw | |
| fix_build.log | β | 1.6 KB | view raw | |
| build.log.final | β | 1.6 KB | β download | |
| boot_panic_full.log | β | 2.4 KB | view raw | |
| panic.txt | β | 768 B | view raw | |
| fix.diff | β | 1.4 KB | view raw | |
| env.txt | β | 1.1 KB | view raw | |
| verdict.json | β | 5.2 KB | view raw | |
| manifest.json | β | 1.2 KB | view raw |
DF-3380 β pfsync callout handlers run in softclock thread without pf_token: deterministic INVARIANTS panic after SIOCSETPFSYNC
Impact
pfsyncioctl(SIOCSETPFSYNC) (if_pfsync.c:1167-1170) arms sc->sc_bulkfail_tmo with
handler pfsync_bulkfail and a 5hz timeout. DragonFly callouts execute in the per-CPU
softclock_handler thread (sys/kern/kern_timeout.c:491), which holds no lwkt
tokens. pfsync_bulkfail() begins with ASSERT_LWKT_TOKEN_HELD(&pf_token)
(if_pfsync.c:1621), so on any kernel built with options INVARIANTS the callout
panics the kernel ~5 seconds after any successful pfsync syncdev configuration* β
deterministically, no race required:
panic: assertion "LWKT_TOKEN_HELD_ANY(&pf_token)" failed in pfsync_bulkfail at /usr/src/sys/net/pf/if_pfsync.c:1621
cpuid = 2
pfsync_bulkfail() at pfsync_bulkfail+0x71
softclock_handler() at softclock_handler+0x1b8
Debugger("panic")
The same defect pattern exists in:
- pfsync_timeout() (if_pfsync.c:1514-1522) β calls pfsync_sendout() which asserts
the token at if_pfsync.c:1673; armed by pfsync_get_mbuf() (if_pfsync.c:1268);
- pfsync_bulk_update() (if_pfsync.c:1552) β asserts the token at entry
(currently unreachable: see DF-3379 β nothing schedules it, and the only
sc_bulk_tmo arming passes pfsync_timeout instead).
On non-INVARIANTS (production) kernels the KKASSERT compiles out and the handlers run
unserialized against pf_token holders instead β a locking-discipline violation that
is currently benign only because the sync engine is dead (DF-3379: pfsyncif == NULL
makes the handlers no-ops past the assert).
Secondary defect in the same lifecycle: pfsync_clone_destroy()
(if_pfsync.c:203-206) stops the callouts with callout_stop(), which per
sys/kern/kern_timeout.c "does not interlock against a callout that is in-progress"
for non-lock-aided callouts, and then kfree(sc)s the softc (if_pfsync.c:222). A
pfsync_bulkfail firing concurrently on another CPU would dereference/write the freed
softc (sc->sc_bulk_tries++). callout_drain() is the correct destroy-time API.
Reachability / privilege
SIOCSETPFSYNC is privileged (caps_priv_check(SYSCAP_RESTRICTEDROOT),
if_pfsync.c:1065-1066) β trigger requires root (as any pfsync configuration does).
Hence: privileged local DoS. Severity Low; confidence certain (deterministic,
reproduced on the stock INVARIANTS guest).
Note: DF's base ifconfig has no pfsync subcommands at all (no SIOCSETPFSYNC caller
in sbin/ifconfig), so the trigger is a 60-line C program issuing the ioctl directly β
byte-for-byte what a ported ifconfig would do.
Reproduce (exact, on stock INVARIANTS kernel)
cc -o /root/trigger_df3380 /root/trigger_df3380.c kldload pf; ifconfig pfsync0 create # pfsync0 may already exist /root/trigger_df3380 lo0 # syncdev=lo0 (multicast join on lo0 works) # ~5 seconds later: panic (assertion above), guest drops into DDB
syncdev must be an interface whose multicast join succeeds; with tap0/vtnet0 on this
guest in_addmulti failed and the ioctl returned ENOBUFS before arming the callout
(if_pfsync.c:1143-1148) β lo0 works.
Fix validation (fix.diff in this pack)
callout_drain()instead ofcallout_stop()inpfsync_clone_destroy()lwkt_gettoken(&pf_token)/lwkt_reltoken(&pf_token)around the bodies ofpfsync_timeout,pfsync_bulk_update,pfsync_bulkfail(replacing the asserts)- validated: kernel rebuilt in-guest (
make nativekernel KERNCONF=X86_64_GENERIC), rebooted, same trigger re-run β no panic; callout fires harmlessly (12 retries then gives up), guest stays up; pfsync0 destroy with an armed callout also clean.
DF-3380 VERDICT
Status: reproduced β deterministic kernel panic, single privileged ioctl + 5 s wait.
Impact: panic (privileged local DoS; any root-equivalent pfsync configuration step
triggers it β on INVARIANTS/debug kernels, which is every kernel built from this tree's
default GENERIC config: options INVARIANTS is in X86_64_GENERIC line for line).
How it reproduces (baseline, stock kernel #0)
kldload pf(loads the pf/pfsync code β if_pfsync.c isoptional pf, conf/files:1574)ifconfig pfsync0 create(clone exists; engine irrelevant)- SIOCSETPFSYNC with
syncdev=lo0(60-line C trigger β DF's ifconfig has no pfsync subcommands; the ioctl is exactly what a ported ifconfig would issue) pfsyncioctl(if_pfsync.c:1064-1181) sets sc_sync_ifp, joins the multicast group on lo0, then armssc->sc_bulkfail_tmowith 5*hz and handlerpfsync_bulkfail(if_pfsync.c:1167-1170).- Five seconds later the per-CPU
softclock_handlerthread (kern_timeout.c:491) runspfsync_bulkfail, whose first statement isASSERT_LWKT_TOKEN_HELD(&pf_token)(if_pfsync.c:1621). The callout thread holds no tokens β
panic: assertion "LWKT_TOKEN_HELD_ANY(&pf_token)" failed in pfsync_bulkfail at /usr/src/sys/net/pf/if_pfsync.c:1621 cpuid = 2 pfsync_bulkfail() at pfsync_bulkfail+0x71 softclock_handler() at softclock_handler+0x1b8
Guest dead in DDB; ssh sessions die; vm.sh status => down. Observed 2026-09-11 21:14:25Z (panic.txt, boot_panic_full.log, run.baseline.log).
Precondition detail verified live: syncdev must be an interface whose 224.0.0.240
membership join succeeds β with tap0 (no carrier) and vtnet0 the ioctl returned ENOBUFS
from pfsync_in_addmulti (if_pfsync.c:1143-1148) before arming the callout; lo0 works.
That ENOBUFS path is itself notable: on vtnet0 (UP, RUNNING, MULTICAST, carrier on)
in_addmulti(224.0.0.240, vtnet0) still failed β the pfsync multicast join is fragile,
another symptom of the vestigial interface.
Why the assert is a real defect and not just debug noise
The handlers mutated by this bug family also run on production (non-INVARIANTS) kernels β
there the KKASSERT compiles away and pfsync_bulkfail runs unserialized against
pf_token holders (sc fields, pf state via request_update/sendout). It is benign today
only because the engine is dead (DF-3379: pfsyncif == NULL forces the no-op paths);
the same callout wiring defect would be a data race the moment the engine is revived.
Related members of the family, same fix:
- pfsync_timeout() (if_pfsync.c:1514-1522) β pfsync_sendout() asserts the token at
if_pfsync.c:1673; armed from pfsync_get_mbuf() (if_pfsync.c:1268) β currently
unreachable with the engine dead, but identically broken.
- pfsync_bulk_update() asserts at entry (if_pfsync.c:1552).
- pfsync_clone_destroy() (if_pfsync.c:203-206) uses callout_stop() on all three
callouts and then kfree(sc) (if_pfsync.c:222); per kern_timeout.c, callout_stop
"does not interlock against a callout that is in-progress", so an in-flight
pfsync_bulkfail on another CPU would touch freed memory (sc->sc_bulk_tries++).
callout_drain() is the destroy-time API.
Exploit chain
Not applicable beyond DoS: the primitive is a guaranteed wrong-context execution +
assert; with INVARIANTS compiled out there is no memory-safety consequence while the
engine stays dead (all touched paths no-op on pfsyncif == NULL). If the engine is
ever revived without this fix, the same callouts become unserialized writers racing
pf_token holders.
Fix validation (fix.diff β applied in-guest only, sys/ tree untouched here)
make nativekernel KERNCONF=X86_64_GENERIC + installkernel + reboot (kernel #1,
2026-09-11 21:24:50Z, build.log.final):
- baseline #0: panic at if_pfsync.c:1621, guest dead (run.baseline.log)
- patched #1: identical trigger β callout armed β SURVIVED_CALLOUT, guest up, and
dmesg shows pfsync0 demoted group carp to 1 β proof pfsync_bulkfail executed to
completion under the newly-acquired pf_token instead of asserting (run.fixed.log).
ifconfig pfsync0 destroy returned EINVAL (softc still holds the lo0 membership /
ifp busy) β unrelated to the fix; the drain path is exercised at module unload.
fix_status: fixed (baseline reproduced 1, patched reproduced 0).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedBaseline stock kernel #0: deterministic panic at if_pfsync.c:1621 (guest dead in DDB). Patched kernel #1 (fix.diff applied in-guest, make nativekernel + installkernel + reboot): identical trigger arms the callout, pfsync_bulkfail executes fully (dmesg demote side-effect visible), no panic, guest stays up across the 12s window. Panic gone; fix validated.
run.baseline.log vs run.fixed.log; build.log.final (if_pfsync.o in build, install completed); dmesg line 'pfsync0 demoted group carp to 1' on patched run proves handler execution rather than silent skip
Confirmed kernel references
Detail
Exploit chain
root: kldload pf; ifconfig pfsync0 create; SIOCSETPFSYNC(syncdev=lo0) -> sc_bulkfail_tmo armed 5*hz -> softclock thread fires pfsync_bulkfail without pf_token -> KKASSERT panic (INVARIANTS kernels; GENERIC in this tree ships options INVARIANTS). No memory-corruption primitive: post-assert code paths no-op on pfsyncif==NULL (DF-3379).
Evidence (decisive lines)
["panic.txt: 'panic: assertion LWKT_TOKEN_HELD_ANY(&pf_token) failed in pfsync_bulkfail at /usr/src/sys/net/pf/if_pfsync.c:1621' with softclock_handler backtrace, cpuid=2, ~5s after trigger printed 'callout armed'", 'run.baseline.log (=run.log): full baseline session incl. timestamps and the ENOBUFS negative variants (tap0/vtnet0 fail earlier at in_addmulti, if_pfsync.c:1143-1148)', "run.fixed.log (=fix_run.log): patched kernel #1 same trigger -> SURVIVED_CALLOUT + dmesg 'pfsync0 demoted group carp to 1' proving the handler ran to completion under the acquired token", 'boot_panic_full.log: serial console capture of the panic boot', 'fix.diff: callout_drain in clone_destroy + lwkt_gettoken/reltoken in pfsync_timeout/pfsync_bulk_update/pfsync_bulkfail replacing the asserts', 'build.log/build.log.final/fix_build.log: if_pfsync.o compiled into the in-guest nativekernel build, install completed 2026-09-11 21:30:40Z, kernel #1 21:24:50Z booted for validation']
PoC changes
No PoC seed existed (new finding). Trigger written from scratch: direct SIOCSETPFSYNC ioctl (base ifconfig has no pfsync support at all in DF); syncdev had to be lo0 because tap0/vtnet0 make pfsync_in_addmulti fail (ENOBUFS) before the callout is armed.
Verified recommended fix
Acquire pf_token in the pfsync callout handlers (pfsync_timeout/pfsync_bulk_update/pfsync_bulkfail) instead of asserting it, and use callout_drain() in pfsync_clone_destroy (see fix.diff).
Verdict
Deterministic kernel panic on INVARIANTS kernels ~5s after any successful pfsync syncdev configuration: pfsyncioctl(SIOCSETPFSYNC) arms sc_bulkfail_tmo (if_pfsync.c:1168) with handler pfsync_bulkfail, which executes in the per-CPU softclock_handler thread (kern_timeout.c:491) holding no lwkt tokens and immediately trips ASSERT_LWKT_TOKEN_HELD(&pf_token) (if_pfsync.c:1621). Reproduced on the stock INVARIANTS guest (kernel #0) with a 60-line SIOCSETPFSYNC trigger and syncdev=lo0: 'panic: assertion LWKT_TOKEN_HELD_ANY(&pf_token) failed in pfsync_bulkfail at if_pfsync.c:1621', guest dead in DDB. Same family: pfsync_timeout->pfsync_sendout assert (1673), pfsync_bulk_update entry assert (1552), and clone_destroy uses non-draining callout_stop (203-206) before kfree(sc) (222). Privileged (SIOCSETPFSYNC is caps-gated) => local root-config DoS of debug/INVARIANTS kernels; on production kernels the handlers run unserialized against pf_token holders instead (benign only while the sync engine stays dead per DF-3379). Fix validated: nativekernel rebuild with fix.diff (token acquire/release in the three callout handlers + callout_drain in destroy) -> same trigger, callout fires and completes (dmesg shows its CARP demotion side-effect), no panic, guest stays up.
No comments yet.