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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3380 Β· 18 files
FileTypeDescriptionSize
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 of callout_stop() in pfsync_clone_destroy()
  • lwkt_gettoken(&pf_token) / lwkt_reltoken(&pf_token) around the bodies of pfsync_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.
VERDICT.md
↓ download raw

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)

  1. kldload pf (loads the pf/pfsync code β€” if_pfsync.c is optional pf, conf/files:1574)
  2. ifconfig pfsync0 create (clone exists; engine irrelevant)
  3. 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)
  4. pfsyncioctl (if_pfsync.c:1064-1181) sets sc_sync_ifp, joins the multicast group on lo0, then arms sc->sc_bulkfail_tmo with 5*hz and handler pfsync_bulkfail (if_pfsync.c:1167-1170).
  5. Five seconds later the per-CPU softclock_handler thread (kern_timeout.c:491) runs pfsync_bulkfail, whose first statement is ASSERT_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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Sep 11 21:24:50 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.