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

callout_stop(&sc->timer) under main_serialize deadlocks against emx_timer's blocking lwkt_serialize_enter (AB-BA): emx_stop/emx_intr_body/emx_npoll_status vs the 1 Hz emx_timer β€” mechanism PROVEN on the guest kernel via protocol replica; fix validated at protocol level (DF-3715 em(4) twin)

Summary

emx_timer() is an MP callout whose callback blocks on the driver serializer (lwkt_serialize_enter, self re-arm inside it). Three paths call the synchronous callout_stop(&sc->timer) while holding that serializer: emx_stop() (reached from every ifconfig down/up, MTU/media/cap change, detach/kldunload, suspend, watchdog reinit), the emx_intr_body() LSC path (ISR holds main + skipmain serializers), and emx_npoll_status(). callout_stop sleeps on 'costp' until the in-progress callback finishes, while the blocked callback sleeps on 'slize' until the stopper releases the serializer - a permanent AB-BA deadlock that also strands one CPU's softclock thread and starves all its future callouts. PROVEN on the guest kernel with an if_emx-protocol replica module: callout_stop NEVER returns - live ps shows 'D4 costp' AND '(softclock 0) B0 slize'; negative control (try-based shape) fully unloads; fix shape (try + 1-tick retry) completes the same stop cleanly. Triggered by ordinary privileged operations racing the 1 Hz timer (repeatable with a down/up loop); permanent system degradation requiring reboot.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3738 Β· 12 files
FileTypeDescriptionSize
emx_callout_replica.c β€” 7.8 KB view raw
emx_callout_replica_fixed.c β€” 9.6 KB view raw
build.sh β€” 125 B view raw
run.sh β€” 307 B view raw
build.log β€” 879 B view raw
run.log β€” 2.7 KB view raw
env.txt β€” 819 B view raw
fix.diff β€” 1013 B view raw
VERDICT.md β€” 4.3 KB ↓ raw
README.md β€” 2.6 KB ↓ raw
verdict.json β€” 5.0 KB view raw
manifest.json β€” 1.6 KB view raw

DF-3738 β€” emx(4) callout_stop(&sc->timer) AB-BA deadlock (DF-3715 em(4) twin)

What

emx_timer() is an MP callout whose callback blocks on the driver serializer; emx_stop() (and two other paths) call callout_stop() on it while holding that same serializer. DragonFly's callout_stop() is synchronous β€” it sleeps ("costp", sys/kern/kern_timeout.c:920) until the in-progress callback finishes β€” and lwkt_serialize_enter() sleeps ("slize", sys/kern/lwkt_serialize.c:234) until the serializer is free. Callback parked on the serializer + stopper parked in callout_stop() holding the serializer = permanent AB-BA deadlock: the stopping thread AND the softclock thread of that CPU wedge forever.

Driver sites: - emx_timer() β€” sys/dev/netif/emx/if_emx.c:1908-1931 (lwkt_serialize_enter(&sc->main_serialize) at :1914, self re-arm at :1928 inside the serializer). - callout_stop(&sc->timer) under ASSERT_IFNET_SERIALIZED_ALL: - emx_stop() β€” :2030 (callers: SIOCSIFFLAGS :1227, emx_init :1354, emx_detach :989, emx_suspend :1059 β€” every ifconfig down/up, MTU/media/cap change, detach, kldunload, suspend, watchdog reinit); - emx_intr_body() LSC path β€” :1534 (ISR holds main_serialize; then emx_serialize_skipmain adds tx+rx serializers); - emx_npoll_status() β€” :4196 (polling status, main serializer).

The try-based emx_txgc_timer() (:426-449) is not affected (it never blocks); this is exactly the em(4) DF-3715 structure.

Build / run (guest)

# tarball Makefile + emx_callout_replica.c into /root/emxkmod
cd /root/emxkmod && make
kldload /root/emxkmod/emx_callout_replica.ko
sysctl kern.emxreplica.arm=1; sleep 2
sysctl kern.emxreplica.stop_gc=1            # negative control: returns
(sysctl kern.emxreplica.stop_blk=1 &)       # AB-BA: NEVER returns
sleep 6; dmesg | grep emxreplica | tail
ps -o pid,stat,wchan,command -ax | grep stop_blk    # D4 costp
# then reset the guest

Expected (and observed) output

Console ends with:

emxreplica[blk]: handler BLOCKED on our serializer (fired=14 ran=13) - entering callout_stop() [emx_stop shape]
emxreplica[blk]: IF THIS IS THE LAST emxreplica LINE, AB-BA DEADLOCK IS PROVEN

β€” and the "callout_stop returned" line NEVER appears. ps shows the stopper in D4 costp; ps -ax shows softclock 0 in B0 slize (parked inside the callback's lwkt_serialize_enter). Negative control (stop_gc): callout_stop returned, pending=0.

Fixed-shape validation (emx_callout_replica_fixed.c, replicating the fix.diff protocol): sysctl kern.emxreplica.stop_fixed=1 β†’ callout_stop returned, pending=0, STOP_FIXED_RC=0, clean kldunload.

VERDICT.md
↓ download raw

DF-3738 β€” VERDICT

Status: inconclusive per family convention (the emx device path is hardware-gated on this guest), but the deadlock MECHANISM is PROVEN live on the guest kernel via a deterministic protocol replica β€” same standard as the em(4) twin DF-3715.

Mechanism proof (guest, 2026-09-15, full transcript in run.log)

Kernel module emx_callout_replica.ko replicates if_emx.c's exact callout/serializer protocol with two callouts:

  • blk_timer == emx_timer shape (if_emx.c:1908-1931): blocking lwkt_serialize_enter (:1914) + self re-arm INSIDE the serializer (:1928, before exit :1930);
  • gc_timer == emx_txgc_timer shape (:426-449): non-blocking lwkt_serialize_try (:436) + unconditional done: re-arm (:448).

Observed on the guest (serial console + second session): 1. Negative control (sysctl kern.emxreplica.stop_gc=1, emx_stop shape against the try-based handler): callout_stop returned, pending=0 β€” non-blocking callbacks are torn down fine. 2. AB-BA (sysctl kern.emxreplica.stop_blk=1 in background): emxreplica[blk]: handler BLOCKED on our serializer (fired=14 ran=13) - entering callout_stop() [emx_stop shape] β€” then silence forever; the writing ssh session hung and was killed host-side; ps: 1105 D4 costp sysctl kern.emxreplica.stop_blk=1 (the synchronous sleep INSIDE callout_stop(), sys/kern/kern_timeout.c:920); ps -ax: (softclock 0) B0 slize β€” CPU0's softclock thread parked in lwkt_serialize_enter() (sys/kern/lwkt_serialize.c:234), i.e. INSIDE the emx_timer-shaped callback. Both sides of the AB-BA captured. 3. Guest recovered only via vm.sh reset with-src.

Why the real driver deadlocks (path:line)

  • callout_stop() is synchronous: sys/kern/kern_timeout.c:1091 β†’ _callout_cancel_or_stop(..., sync=1) (:859), ssleep(c, &c->spin, 0, "costp", 0) at :920 waits for the INPROG callback to finish.
  • lwkt_serialize_enter() blocks: sys/kern/lwkt_serialize.c:234 (tsleep(s, PINTERLOCKED, "slize", 0)).
  • emx_timer callback blocks on main_serialize (if_emx.c:1914); emx_stop()/emx_intr_body()/emx_npoll_status() call callout_stop(&sc->timer) (if_emx.c:2030/:1534/:4196) while holding it (ASSERT_IFNET_SERIALIZED_ALL at :2026; ISR + emx_serialize_skipmain at :1532; polling status serializer at :4236).
  • Consequence: permanent wedge of the stopping thread (any ifconfig down/up, MTU/media/cap change, detach/kldunload, suspend, watchdog reinit β€” all reach emx_initβ†’emx_stop or emx_stop directly) AND of one CPU's softclock thread (all future callouts on that CPU starve).

Race window: the 1 Hz timer must be INPROG-parked when the stop runs β€” small per call, but a down/up loop races it ~1/s per interface. Same judgment as DF-3715: Medium (system-level deadlock/DoS from ordinary privileged interface operations).

Fix validated at protocol level

fix.diff (git-apply clean against the read-only tree) converts emx_timer() to the non-blocking try-and-retry pattern already used by emx_txgc_timer(): lwkt_serialize_try(&sc->main_serialize); on failure re-arm in 1 tick and return; double-check IFF_RUNNING|IFF_UP both before the try (cheap racy read) and under the serializer.

Protocol validation on the guest (emx_callout_replica_fixed.c, the fixed-shape replica): sysctl kern.emxreplica.stop_fixed=1 β†’ emxreplica[fx]: callout_stop returned, pending=0, emxreplica[fx]: RESULT pending=0 (fix effective), STOP_FIXED_RC=0, clean kldunload. Contrast with the unfixed shape under the identical stop: permanent D4/costp wedge. (fix_status therefore fixed at the protocol-replica level; a full kernel rebuild validation is not possible without emx hardware β€” noted in fix_verdict.)

Note on the gc timer: the try-based emx_txgc_timer re-arm (:448) cannot survive callout_stop post-processing on this kern_timeout.c (negative control, pending=0) β€” the em(4) pass-2 reached the same conclusion, so no separate post-stop re-arm finding is filed.

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

Protocol-level validation on the guest: baseline (current emx_timer shape) = permanent AB-BA wedge (stopper D4 'costp', softclock B0 'slize'); patched shape (exact fix.diff protocol: try + 1-tick retry + double-check under serializer) = the same emx_stop-shaped callout_stop completes, pending=0, module unloads cleanly. Full driver-kernel rebuild validation not possible without emx hardware.

findings/poc/DF-3738/fix.diff; run.log 'fixed-shape validation' section
↓ fix.diffper-fix-DF-3738

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-3738/VERDICT.md; run.log (replica session transcript + serial console + ps captures: stopper D3/D4 wchan 'costp' = kern_timeout.c:920; softclock 0 B0 wchan 'slize' = lwkt_serialize.c:234; fixed-shape validation 'callout_stop returned, pending=0'); build.log (module built on guest, -Werror clean); fix.diff (try-enter + 1-tick retry for emx_timer)

PoC changes

No seed PoC. Authored emx_callout_replica.ko (out-of-tree kmodule replicating the emx callout protocol; adapted from DF-3715's em replica with emx line citations) plus emx_callout_replica_fixed.c (fix.diff shape) because the guest has no emx hardware; the deadlock is in the generic protocol emx uses, so the replica exercises the identical kernel paths. One replica iteration had a module-local bug (fx_c missing callout_init_mp -> KKASSERT panic in MY module); fixed and rerun.

Verified recommended fix

Make emx_timer() non-blocking: lwkt_serialize_try() with a 1-tick callout_reset retry (emx_txgc_timer pattern).

Verdict

Mechanism PROVEN on the guest kernel by deterministic replica of if_emx.c's exact callout/serializer protocol; emx-specific runtime hardware-gated (inconclusive per family convention, same standard as the em(4) twin DF-3715). emx_timer (sys/dev/netif/emx/if_emx.c:1908-1931) is an MP callout whose callback BLOCKS in lwkt_serialize_enter(&sc->main_serialize) (:1914) and self re-arms inside the serializer (:1928); emx_stop tears it down with callout_stop(&sc->timer) (:2030) while holding ALL serializers (ASSERT_IFNET_SERIALIZED_ALL :2026; callers: SIOCSIFFLAGS :1227, emx_init :1354, emx_detach :989, emx_suspend :1059); the same stop-under-serializer exists in emx_intr_body LSC path (:1534, ISR holds main + skipmain serializers) and emx_npoll_status (:4196). DragonFly callout_stop synchronously waits for an in-progress callback (sys/kern/kern_timeout.c:1091 -> _callout_cancel_or_stop sync=1, ssleep 'costp' at :920) while lwkt_serialize_enter blocks (tsleep 'slize', sys/kern/lwkt_serialize.c:234). A callback dequeued and parked on the serializer therefore deadlocks against the stopper (AB-BA): both threads wedge permanently and the affected CPU's softclock thread stops delivering callouts. Kernel-module replica on the guest: stop under a held serializer with the emx_timer-shaped handler parked -> callout_stop NEVER returns (ps: '1105 D4 costp sysctl kern.emxreplica.stop_blk=1'; ps -ax: '(softclock 0) B0 slize'; console output ends at the pre-callout_stop marker line). Negative control: the try-based emx_txgc_timer shape is fully unloaded by callout_stop (pending=0) - so the try-based gc timer's 'done:' re-arm (:448) cannot survive callout_stop post-processing on this kern_timeout.c, and no separate post-stop re-arm finding is filed (same judgment as em(4) pass 2). Fix validated at protocol level: replica of the fix.diff shape (try + 1-tick retry + double-check) completes the same stop cleanly (pending=0, clean kldunload).