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

epic_stop() calls synchronous callout_stop() while holding ifp->if_serializer while the 1s mii tick epic_stats_update() must first enter that serializer β€” AB-BA deadlock (DF-3715 family instance; wedge holds the MP lock and, via the watchdog path, netisr0)

Summary

The tick begins with lwkt_serialize_enter and re-arms every second. Every path that stops the interface holds that same serializer across the synchronous callout_stop: SIOCSIFMTU, SIOCSIFFLAGS-down, the watchdog (which wedges netisr0), the PCI-fatal interrupt, and detach. When the tick is parked on the contended serializer the stopper sleeps forever waiting for a callback that waits forever for the serializer the stopper holds. Plain callout_init means the wedged softclock also holds the MP lock - system-wide degradation. MECHANISM REPRODUCED on the guest with a protocol-replica KLD (stopper wchan=costp, softclock wchan=slize, permanent). Lock-aided-callout fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4275 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.1 KB ↓ raw
VERDICT.md β€” 5.7 KB ↓ raw
df4275_rep.c β€” 5.1 KB view raw
Makefile β€” 58 B ↓ download
build.sh β€” 190 B view raw
run.sh β€” 216 B view raw
run.log β€” 1.5 KB view raw
env.txt β€” 822 B view raw
fix.diff β€” 1.1 KB view raw
verdict.json β€” 4.3 KB view raw

DF-4275 β€” epic_stop() callout_stop AB-BA deadlock (if_tx.c)

Finding: sys/dev/netif/tx/if_tx.c β€” SMC EtherPower II (EPIC/100) driver. DF-3715 family instance (same mechanism as the em(4) finding, this file was missed in pass 1).

epic_stats_update()                epic_ifioctl()/epic_ifwatchdog()/epic_intr()/epic_detach()
(if_tx.c:796-811)                  ------------------------------------------
lwkt_serialize_enter(              lwkt_serialize_enter(ifp->if_serializer);
    ifp->if_serializer);   <--+    epic_stop(sc);                 (if_tx.c:1341)
    mii_tick(mii);             |   callout_stop(&sc->tx_stat_timer);
    callout_reset(...);        |       |
lwkt_serialize_exit();       blocked  |  waits 'costp' for the callback
                                   <--+  to finish (kern_timeout.c:888-921)

In this kernel callout_stop() is fully synchronous (sys/kern/kern_timeout.c:1091-1094 -> _callout_cancel_or_stop(..., sync=1) -> ssleep(c, &c->spin, 0, "costp", 0) at kern_timeout.c:920). If the 1-second mii tick has been dequeued by softclock (CALLOUT_INPROG set, kern_timeout.c:567) and is parked in lwkt_serialize_enter() on the interface serializer β€” which is its normal state whenever the serializer is contended, e.g. under RX interrupt load, inside epic_stop_activity()'s millisecond DELAY loops, or during a concurrent ifconfig β€” then any thread that enters epic_stop() while holding ifp->if_serializer blocks forever waiting for that callback.

Entry points holding ifp->if_serializer across epic_stop()->callout_stop(): - epic_ifioctl SIOCSIFMTU (if_tx.c:370-373: epic_stop+epic_init) - epic_ifioctl SIOCSIFFLAGS down (if_tx.c:389-391) - epic_ifwatchdog (if_tx.c:781-782: epic_stop+epic_init; watchdog itself runs under ifnet_tryserialize_all from if_slowtimo_dispatch, net/if.c β€” wedging it also wedges netisr0) - epic_intr PCI-fatal (if_tx.c:702-703) - epic_detach (if_tx.c:300-304)

Because if_tx.c uses plain callout_init() (if_tx.c:200, non-MPSAFE), the stuck softclock thread also holds the MP lock, so the wedge degrades the whole system, not just one interface.

Reproduction

The NIC is hardware-gated (no SMC83C170 in the QEMU guest), but the deadlock is a callout-core/serializer-discipline mechanism: a protocol replica module reproduces the exact lock discipline (tick callback that first enters the serializer; stopper holding the serializer calling synchronous callout_stop) and demonstrates the permanent 'costp' sleep.

./build.sh     # df4275rep.ko
./run.sh       # kldload; expect stopper parked forever in "costp"

Expected output (console/dmesg):

df4275: tick() dequeued; entering serializer (blocks: stopper holds it)
df4275: stopper HOLDS serializer; tick() INPROG+blocked; calling callout_stop() ...
df4275: stopper_td=... wchan=... wmesg="costp"
df4275: RESULT: DEADLOCK REPRODUCED -- stopper permanently asleep in "costp"

Artifacts

  • df4275_rep.c β€” replica module source
  • build.log β€” module build output
  • run.log β€” console capture of decisive run
  • verdict.json β€” machine verdict
VERDICT.md
↓ download raw

DF-4275 β€” epic_stop()/epic_stats_update() AB-BA deadlock (sys/dev/netif/tx/if_tx.c)

VERDICT: REPRODUCED (mechanism), impact = permanent kernel deadlock / DoS.

What the finding claims

if_tx.c re-arms a 1-second callout in epic_stats_update() (if_tx.c:808) whose callback FIRST does lwkt_serialize_enter(ifp->if_serializer) (if_tx.c:803), while every path that stops the interface holds that same serializer across epic_stop() -> callout_stop(&sc->tx_stat_timer) (if_tx.c:1341):

  • epic_ifioctl SIOCSIFMTU if_tx.c:370-373 (epic_stop + epic_init)
  • epic_ifioctl SIOCSIFFLAGS if_tx.c:389-391 (interface down)
  • epic_ifwatchdog if_tx.c:781-782 (runs under ifnet_tryserialize_all from if_slowtimo_dispatch, net/if.c β€” a wedge here also wedges netisr0)
  • epic_intr PCI-fatal if_tx.c:702-703
  • epic_detach if_tx.c:300-304

In this kernel callout_stop() is fully synchronous (kern_timeout.c:1091-1094 -> _callout_cancel_or_stop(..., sync=1) -> ssleep(c, &c->spin, 0, "costp", 0), kern_timeout.c:920) and waits for a callback that is CALLOUT_INPROG (dequeued, kern_timeout.c:567). If the tick has been dequeued and is parked in lwkt_serialize_enter() on the interface serializer β€” its normal state whenever the serializer is contended (RX interrupt load, epic_stop_activity()'s multi-millisecond DELAY loops at if_tx.c:1238-1243, any concurrent ioctl) β€” then the stopper waits forever for a callback that is waiting forever for the serializer the stopper holds. Because if_tx.c uses plain callout_init() (if_tx.c:200, non-MPSAFE), the wedged softclock thread also holds the MP lock, degrading the whole system.

How it was verified

The SMC83C170 is not emulated by QEMU, so the driver-level trigger cannot run on the guest. The deadlock, however, is a pure callout-core/serializer discipline; a protocol-replica KLD (df4275_rep.c) reproduces the exact lock order: a stopper kthread takes the serializer, waits until the 1-tick callback has been dequeued and parked on it, then calls callout_stop() β€” precisely what any serialized ioctl path does when it calls epic_stop() while the mii tick is blocked.

Result on DragonFly 6.5-DEVELOPMENT #1 (kernel rebuilt from the audit tree, 2026-09-19), guest pid 3641097 cycle (run.log):

df4275: tick() dequeued; entering serializer (blocks: stopper holds it) df4275: stopper HOLDS serializer; tick() INPROG+blocked; calling callout_stop() ... df4275: stopper_td=0xfffff8008ed96600 wchan=... wmesg="costp" (x5, 1s apart) df4275: RESULT: DEADLOCK REPRODUCED ...

ps -axo ...,wchan,comm: -1 B5 costp df4275stop <- stopper parked in callout_stop's wait -1 B5 slize softclock 5 <- softclock blocked in lwkt_serialize_sleep INSIDE the callback (holding MP lock)

Both sides of the AB-BA are directly observable in the process table; the state persists indefinitely (5+ observation seconds, and remains after the observing shell exits). This is the identical mechanism proven for DF-3715 (em(4)); if_tx.c was missed in pass 1.

Two earlier replica iterations (kept for honesty): 1. stopper waited for tick-inprogress via tsleep(...,1) β€” the deadlock manifested one step earlier: the stopper's sleep timeout is delivered by the very softclock wheel the tick wedges, so the stopper parked forever in "df4275w" (run captured 03:0x). Valid, but not the canonical signature. 2. stopper busy-waited with DELAY() β€” LWKT kernel threads of equal priority are not preempted, so the spin starved softclock on the same CPU and the callback never ran (no deadlock; replica bug, fixed with lwkt_yield()).

The final replica (yield-wait) produces the canonical 'costp' wedge.

Environment note (not a finding): modules built from the CURRENT /usr/src against the SHIPPED guest kernel (#0, Jul 2 2026) GPF in module_register β€” DECLARE_MODULE() in current module.h takes a moduledata_t variable, and the shipped kernel/module pair predates the toolchain state; the guest kernel was rebuilt from the current tree (nativekernel) before verification. Stock /boot/kernel modules load fine before and after.

Reachability of the driver-level trigger (source-traced)

  • ifconfig tx0 down / mtu change / media change: root on a machine with the NIC; the tick fires 1/s and parks on the serializer whenever it is contended, so repeated down/up churn under load hits the window.
  • epic_ifwatchdog: fires by itself (if_timer=8 set in epic_ifstart, if_tx.c:540) on TX stall, then calls epic_stop+epic_init under ifnet_tryserialize_all β€” no root needed, purely hardware timing; also wedges netisr0.
  • epic_intr PCI-fatal: hardware-timing.

Fix (fix.diff, authored, not applied)

Convert the tick to a lock-aided callout (callout_init_lk with ifp->if_serializer after ether_ifattach) so the callback no longer serialize_enters (the callout core provides the interlock and its stop path breaks the AB-BA via LK_CANCEL), or alternatively stop the callout outside the serializer in every epic_stop() caller. fix.diff implements the former.

Bottom line: REPRODUCED β€” permanent kernel deadlock (DoS); the in-driver trigger is hardware-gated (tx(4) NIC) but the mechanism is proven on the guest kernel with the driver's exact discipline, matching the verified DF-3715 family.

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

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored against sys/dev/netif/tx/if_tx.c (never applied to sys/). Not built/booted: the tx(4) NIC is absent from the guest so a patched kernel cannot be retriggered at driver level; a replica-level re-run would only re-test kern_timeout.c, not this diff. Baseline (unfixed) deadlock is reproduced and documented in run.log.

findings/poc/DF-4275/fix.diff (unified diff); baseline reproduction in findings/poc/DF-4275/run.log
↓ fix.diffper-fix-DF-4275

Confirmed kernel references

Detail

Exploit chain

ifconfig tx0 down/mtu (root) or a TX-stall watchdog timeout (no root) while the 1s mii tick is parked on ifp->if_serializer -> epic_stop() under the serializer -> callout_stop() waits 'costp' for the parked callback -> two threads (plus the non-MPSAFE softclock holding the MP lock) wedge permanently; watchdog path additionally wedges netisr0.

Evidence (decisive lines)

run.log: lines 'wmesg="costp"' x5 + RESULT DEADLOCK; ps lines 'df4275stop wchan=costp' and 'softclock 5 wchan=slize'; VERDICT.md full narrative incl. two earlier honest iterations; env.txt kernel + toolchain.

PoC changes

Protocol-replica authored from scratch (no seed). Three iterations: (1) tsleep-based wait demonstrated the wedge one step early (timeout delivered by the wedged wheel); (2) DELAY busy-wait starved softclock on the same CPU (LWKT no-preempt among equals) β€” replaced with lwkt_yield(); (3) final replica yields, then parks in callout_stop -> canonical 'costp'. Also: DECLARE_MODULE on current DFly requires a moduledata_t variable (passing the handler makes module_register cast machine code as moduledata_t* and GPFs) β€” first build GPF'd until fixed; guest kernel rebuilt from the audit tree with nativekernel because the shipped #0 kernel predates the source snapshot.

Verified recommended fix

Make the tick lock-aided: callout_init_lk(&sc->tx_stat_timer, ifp->if_serializer) after ether_ifattach and drop the lwkt_serialize_enter/exit from epic_stats_update (see fix.diff).

Verdict

REPRODUCED (mechanism): epic_stop() (if_tx.c:1341) calls the synchronous callout_stop() while holding ifp->if_serializer; epic_stats_update() (if_tx.c:803) must first enter that same serializer. A dequeued (CALLOUT_INPROG) tick parked on the serializer makes callout_stop() sleep forever in 'costp' while the callback waits forever for the serializer the stopper holds. Protocol-replica KLD on the guest (kernel rebuilt from the audit tree) shows both sides stuck in ps: df4275stop wchan=costp, softclock 5 wchan=slize (callback inside lwkt_serialize_enter), permanent. Same proven mechanism as DF-3715 (em(4)); if_tx.c was missed in pass 1. Driver-level triggers are source-traced (SIOCSIFMTU/SIOCSIFFLAGS if_tx.c:370-391, watchdog if_tx.c:781-782 under ifnet_tryserialize_all -> also wedges netisr0, PCI-fatal if_tx.c:702-703, detach if_tx.c:300-304); the SMC83C170 NIC itself is not emulated by QEMU, so the trigger could not be exercised with the real device.