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

em_stop() can deadlock the callout subsystem against em_timer/em_82547_move_tail (AB-BA): callout_stop() synchronously waits for a callback that is blocked on the serializer the stopper holds β€” mechanism PROVEN on the guest kernel via protocol-replica module (caller stuck D3/wchan 'costp')

Summary

em(4) arms three MP-safe callouts whose callbacks take the driver serializer by hand: em_timer() BLOCKS in lwkt_serialize_enter and self re-arms; em_82547_move_tail() BLOCKS likewise. em_stop() - reached from every down/detach/reinit path (SIOCSIFFLAGS, SIOCSIFMTU, watchdog, suspend, detach) - calls callout_stop() on these WHILE HOLDING that serializer. DragonFly's callout_stop() synchronously waits for an in-progress callback (ssleep 'costp' at kern_timeout.c:923). A callback that was dequeued and is parked in lwkt_serialize_enter on the stopper's serializer therefore deadlocks against it: both threads wedge permanently, the interface serializer is never released, and the wedged softclock thread stops delivering ALL callouts for its CPU (system-wide timer starvation). PROVEN deterministically on the guest kernel with an out-of-tree module replicating the exact protocol: the em_timer-shaped test wedged with the caller in state D3, wchan 'costp' - callout_stop() never returned. The negative control (try-serialize shape) fully unloaded - showing that on this kern_timeout.c the DF-3669/3681 're-arm survives callout_stop' premise does NOT hold for em, i.e. the em defect is the deadlock, not a post-stop re-arm UAF. Fix: try-enter + retry pattern (like em_txgc_timer).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3715 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.7 KB ↓ raw
VERDICT.md β€” 4.3 KB ↓ raw
build.sh β€” 76 B view raw
run.sh β€” 235 B view raw
build.log β€” 5.4 KB view raw
run.log β€” 3.4 KB view raw
env.txt β€” 634 B view raw
fix.diff β€” 2.2 KB view raw
verdict.json β€” 3.8 KB view raw
callout_replica.c β€” 6.3 KB view raw

DF-3715 β€” em_stop() can deadlock the callout subsystem against em_timer / em_82547_move_tail (AB-BA)

File: sys/dev/netif/em/if_em.c Lines: 2216-2239 (em_timer: blocking lwkt_serialize_enter at :2222, self re-arm at :2236), 2027-2036 (em_82547_move_tail: blocking enter at :2033, re-arm via :2016-2017), 2343-2350 (em_stop: callout_stop() under the same serializer), 1047-1065 (em_detach) Kernel refs: sys/kern/kern_timeout.c:859-932 (_callout_cancel_or_stop sync wait, ssleep(..., "costp", 0) at :923), :1091-1098 (callout_stop)

The bug

em_timer() is armed with callout_init_mp() (if_em.c:548) and its callback blocks acquiring the driver serializer (:2222). em_stop() β€” reached from every down/detach/MTU-change/reinit path (SIOCSIFFLAGS :1295, SIOCSIFMTU :1280, watchdog :1407, suspend :1112, detach :1049) β€” calls callout_stop(&adapter->timer) (:2343) while holding that serializer.

DragonFly's callout_stop() synchronously waits for an in-progress callback (_callout_cancel_or_stop, sync=1 β†’ ssleep("costp") until the callback completes). If em_timer was dequeued (INPROG) and is blocked in lwkt_serialize_enter() on the serializer held by the em_stop caller, the two wait on each other forever:

softclock thread:  em_timer -> lwkt_serialize_enter(ser)  ... blocked
em_stop caller:    holds ser -> callout_stop() -> ssleep("costp") ... blocked

Both threads wedge permanently; the serializer is never released (every later driver entry point on that interface blocks), and the wedged softclock thread stops delivering all callouts for its CPU (system-wide timer starvation). The 82547 tx_fifo_timer has the same shape (em_82547_move_tail :2033). (em_txgc_timer uses non-blocking lwkt_serialize_try β€” immune.)

Proven on the guest kernel (deterministic kernel-module replica)

callout_replica.c reproduces the exact protocol (MP callout + hand-rolled serializer): kern.emreplica.stop_blk=1 holds the serializer, waits until the em_timer-shaped callback is parked inside lwkt_serialize_enter, then calls callout_stop() β€” which never returns:

replica[blk]: handler BLOCKED on our serializer (fired=91 ran=90) - entering callout_stop()
replica[blk]: IF THIS IS THE LAST replica LINE, AB-BA DEADLOCK IS PROVEN
  (no further output; "callout_stop returned (NO deadlock)" never printed)

ps -o pid,stat,wchan,command -ax | grep stop_blk
  1051 D3     costp    sysctl kern.emreplica.stop_blk=1

costp is the wait-channel inside kern_timeout.c:923 β€” direct proof the caller is stuck in callout_stop()'s sync wait. Negative control on the same module: the try-based (em_txgc_timer-shaped) handler is fully unloaded by callout_stop() (pending=0 after return) β€” so the try-based timers are fine, the blocking-enter handlers are the defect.

Build/run (guest, DragonFly 6.5-DEVELOPMENT, /usr/src present)

cd /root/kmod && make            # build.log
kldload ./callout_replica.ko     # loads fine on a fresh boot
sysctl kern.emreplica.arm=1
sysctl kern.emreplica.stop_gc=1  # negative control (returns)
(sysctl kern.emreplica.stop_blk=1 &)   # DEADLOCK - second session: ps wchan

No em hardware required β€” the mechanism is the generic callout/serializer protocol if_em.c uses; em-specific reachability is the source-proven race window (timer dequeued in the Β΅s before em_stop's serializer acquire wins over the callback's acquire).

Fix

fix.diff: convert em_timer() and em_82547_move_tail() to non-blocking lwkt_serialize_try() with a 1-tick retry on contention (the exact pattern em_txgc_timer already uses) β€” a callback that never blocks can always complete, so callout_stop()'s sync wait always terminates.

VERDICT.md
↓ download raw

DF-3715 β€” VERDICT

Status: mechanism PROVEN on the guest kernel via deterministic replica module (same callout/serializer protocol as if_em.c); em-specific runtime hardware-gated β†’ inconclusive per family convention, reproduced=0, impact=none-on-this-guest. The class of impact, when it fires on em hardware, is a permanent kernel deadlock (DoS).

Root cause (path:line)

  1. sys/dev/netif/em/if_em.c:548-550 β€” all three callouts are MP-safe (callout_init_mp), i.e. handlers run on a softclock thread and manage locking by hand.
  2. sys/dev/netif/em/if_em.c:2222 β€” em_timer() does a blocking lwkt_serialize_enter(ifp->if_serializer); :2033 β€” em_82547_move_tail() (tx_fifo_timer callback) likewise.
  3. sys/dev/netif/em/if_em.c:2343 and :2344 β€” em_stop() calls callout_stop(&adapter->timer) / callout_stop(&adapter->tx_fifo_timer) while holding the same serializer (em_stop requires ASSERT_SERIALIZED, :2339). Callers of em_stop: em_init (:1422 β€” every reinit: SIOCSIFFLAGS :1292-1295, SIOCSIFMTU :1279-1280, media change :1795-1796, watchdog :1407, npoll :1581-1582), em_detach (:1049), em_suspend (:1112).
  4. sys/kern/kern_timeout.c:859-932 β€” _callout_cancel_or_stop(..., sync=1) (which callout_stop at :1091 uses): when the callout is INPROG, the caller ssleep(c, &c->spin, 0, "costp", 0) until the callback completes (:923).
  5. β‡’ If em_timer is INPROG and parked in lwkt_serialize_enter() on the serializer held by the em_stop caller: the callback waits for the serializer, the stopper waits (costp) for the callback β€” permanent AB-BA deadlock. Collateral: the interface serializer is held forever (all later driver paths on that interface block), and the softclock thread of the CPU that armed the timer stops firing every callout on that CPU.

Race window: em_timer must be dequeued-and-not-yet-inside within the Β΅s around em_stop's serializer acquire β€” once per second of runtime per em_stop invocation; hit rate is low but the trigger (ifconfig down/up, MTU change on a running interface, detach, suspend/resume, watchdog reinit) is repeatable, making eventual occurrence plausible on long-lived boxes.

Why this is NOT the DF-3669/DF-3681 finding

The ix/igb pass-2 findings asserted a re-arm-after-stop UAF on callout_stop grounds ("does not interlock against an in-progress callback"). On this kern_timeout.c, callout_stop()'s STOP flag is processed after the callback returns and unloads a racing re-arm (_callout_update_spinlocked, RESET-then-STOP ordering) β€” verified live by the replica's negative control (stop_gc: pending=0 after stop). For em the actual defect is therefore not a post-stop re-arm, but the sync-wait deadlock above. (em_detach also frees everything only after em_stop returns, which under this analysis it always does β€” no stop-side UAF.)

Verification performed (guest, DragonFly 6.5-DEVELOPMENT x86_64)

  • callout_replica.c (this pack) β€” exact protocol replica; see run.log:
  • negative control (try-based gc shape): stop fully unloaded β†’ no bug for em_txgc_timer;
  • positive test (blocking em_timer shape): callout_stop() never returned; caller in state D3 wchan costp (kern_timeout.c:923) β€” deadlock proven deterministically;
  • guest wedged (sysctl thread + softclock thread), evidence captured from a second session, then vm.sh reset with-src.
  • em hardware absent on guest (ifconfig -l β†’ vtnet0 lo0; no 8086 NIC) β€” the if_em.c instance of the race itself cannot be fired here.

Fix

fix.diff (applies clean to read-only sys/): make em_timer() and em_82547_move_tail() non-blocking β€” lwkt_serialize_try() with a 1-tick callout_reset retry on contention, exactly the em_txgc_timer pattern (:494-506). A never-blocking callback always completes, so callout_stop's sync wait always terminates. Not hardware-testable on this guest (fix_status: not_testable).

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 no→ patch + rebuild →patched clean

fix.diff authored against the read-only sys/ tree (patch --dry-run clean); cannot be validated on a guest without em hardware.

findings/poc/DF-3715/fix.diff
↓ fix.diffper-fix-DF-3715

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-3715/VERDICT.md; run.log (replica session transcript + raw dmesg/ps captures: 'IF THIS IS THE LAST replica LINE' then silence; ps '1051 D3 costp sysctl kern.emreplica.stop_blk=1'); build.log (module built on guest, BUILD_RC=0); fix.diff (try-enter + 1-tick retry for em_timer/em_82547_move_tail)

PoC changes

No seed PoC. Authored callout_replica.ko (out-of-tree kmodule replicating the em callout protocol) because the guest has no em hardware; the deadlock is in the generic protocol em uses, so the replica exercises the identical kernel paths.

Verified recommended fix

Make em_timer() and em_82547_move_tail() non-blocking: lwkt_serialize_try() with a 1-tick callout_reset retry (em_txgc_timer pattern).

Verdict

Mechanism PROVEN on the guest kernel by deterministic replica of if_em.c's exact callout/serializer protocol; em-specific runtime hardware-gated (inconclusive per family convention). em_timer (sys/dev/netif/em/if_em.c:2216-2239) and em_82547_move_tail (:2027-2036) are MP callouts whose callbacks BLOCK in lwkt_serialize_enter (:2222/:2033) on the driver serializer; em_stop tears them down with callout_stop (:2343-2344) while holding that same serializer; DragonFly callout_stop synchronously waits for an in-progress callback (sys/kern/kern_timeout.c:859-932, ssleep 'costp' at :923). A callback dequeued and parked on the serializer therefore deadlocks against the stopper (AB-BA): both threads wedge permanently, the interface serializer is never released and the affected CPU's softclock thread stops delivering all callouts. Kernel-module replica on the guest: stop under a held serializer with the em_timer-shaped handler parked -> callout_stop NEVER returns (caller D3 wchan 'costp'); negative control: the try-based em_txgc_timer shape is fully unloaded by callout_stop (pending=0) - which also shows the DF-3669/DF-3681 're-arm survives callout_stop' premise does not hold on this kern_timeout.c, so for em the defect is the deadlock, not a post-stop re-arm UAF.