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

stge_stop() deadlocks the callout subsystem against stge_tick (AB-BA): synchronous callout_stop() issued while holding the driver serializer vs a tick callback parked in lwkt_serialize_enter β€” mechanism PROVEN on the guest via protocol replica; stge_tick re-arms UNCONDITIONALLY under the lock

Summary

stge_attach arms one non-MP callout; stge_tick BLOCKS acquiring the driver serializer, does its work, then re-arms itself UNCONDITIONALLY while still holding it (no IFF_RUNNING gate at all, unlike re). stge_stop calls the synchronous callout_stop as its FIRST teardown action while HOLDING the serializer (every caller holds it - detach/shutdown/suspend/ioctl/init, the latter reachable from MTU change, watchdog, and the interrupt path's force-init). A tick dequeued on another CPU and parked in lwkt_serialize_enter deadlocks against the stopper: both threads wedge permanently, the serializer is never released, and the affected CPU's softclock stops delivering ALL callouts. Mechanism PROVEN live on the guest (replica: stopper D0/costp vs softclock slize in one ps snapshot; observing shell froze; guest could not shut down; force-reset). lwkt_serialize_try + retry fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4046 Β· 12 files
FileTypeDescriptionSize
README.md β€” 2.1 KB ↓ raw
VERDICT.md β€” 4.7 KB ↓ raw
stgereplica.c β€” 5.4 KB view raw
Makefile β€” 137 B ↓ download
build.sh β€” 83 B view raw
run.sh β€” 272 B view raw
build.log β€” 5.9 KB view raw
run.log β€” 1.5 KB view raw
env.txt β€” 486 B view raw
fix.diff β€” 746 B view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 5.5 KB view raw

DF-4046 β€” stge_stop() deadlocks against stge_tick (synchronous callout_stop under the driver serializer)

  • File: sys/dev/netif/stge/if_stge.c (stge(4))
  • Severity: Medium Β· Confidence: certain Β· stge-specific runtime hardware-gated; mechanism PROVEN on the guest kernel via protocol replica
  • Family: DF-3715 (em, proven) / DF-4025 (re, proven) β€” callout AB-BA

Build

cd /root/stgereplica && make      # bsd.kmod.mk module

Run

kldload /root/stgereplica/stgereplica.ko
sysctl kern.stgereplica.arm=1     # arm the stge_tick replica (1-tick period)
sleep 2
(sysctl kern.stgereplica.stop=1 >/tmp/stop4046.out 2>&1 &)   # NEVER returns
sleep 6
dmesg | grep stgereplica | tail -4
ps -o pid,stat,wchan,command -ax | grep "stop=1" | grep -v grep

Expected result (observed)

stgereplica: stge_tick parked on our serializer (fired=202 ran=201) - entering callout_stop()
stgereplica: IF THIS IS THE LAST stgereplica LINE, AB-BA DEADLOCK IS PROVEN
<<< console ends here β€” callout_stop() never returns >>>
...
43529 D0     costp    sysctl kern.stgereplica.stop=1   <- kern_timeout.c:920
    -1 B2     slize    (softclock 2)                   <- tick parked in
                                                            lwkt_serialize_enter

The observing shell itself never finishes its sleep (callout starvation); the guest afterwards cannot even shut down cleanly and must be force-reset (vm.sh revert with-src && vm.sh up).

On real stge(4) hardware

ifconfig stgeX down, SIOCSIFMTU/SIOCSIFCAP reinit, Tx-underrun force-init from the interrupt path (if_stge.c:1438-1440), detach, suspend, or reboot β€” each holds the serializer through stge_stop() β€” racing the 1 Hz stge_tick on another CPU wedges the kernel permanently: two threads stuck (costp Γ— slize), the interface serializer never released, and the affected CPU's softclock stops delivering ALL callouts.

Artifacts

stgereplica.c, Makefile, build.sh, run.sh, build.log, run.log, env.txt, fix.diff, VERDICT.md, manifest.json, verdict.json.

VERDICT.md
↓ download raw

DF-4046 VERDICT β€” stge_stop vs stge_tick callout AB-BA (mechanism PROVEN on guest; stge runtime hardware-gated)

Reproduced?

Mechanism: PROVEN on the guest kernel by a deterministic replica (stgereplica.ko) of if_stge.c's exact callout/serializer protocol β€” including the details that distinguish stge from the re(4) instance (DF-4025): the callout is initialized with plain non-MP callout_init (if_stge.c:587, dispatched by softclock under the MPLOCK, sys/kern/kern_timeout.c:575-590) and stge_tick re-arms itself unconditionally while holding the serializer (if_stge.c:1775 β€” no IFF_RUNNING gate at all, unlike re_tick_serialized).

stge-specific runtime: hardware-gated (guest has no Sundance/Tamarack NIC; env.txt), so per family convention the persisted status is inconclusive, matching DF-4025.

Observed on the guest (run.log + serial console)

  1. Module built: BUILD_RC=0 (build.log).
  2. Armed; tick fired 202 times, ran 201 (re-arm loop working, period 1 tick vs the real driver's hz).
  3. stge_stop-shaped stop under the serializer: the 202nd firing parked in lwkt_serialize_enter; dmesg ends at IF THIS IS THE LAST stgereplica LINE, AB-BA DEADLOCK IS PROVEN β€” callout_stop() never returned.
  4. Cross-CPU AB-BA visible in one ps: stopper pid 43529 D0 wchan costp (ssleep(c, &c->spin, 0, "costp", 0), sys/kern/kern_timeout.c:920, reached from callout_stop β†’ _callout_cancel_or_stop(..., sync=1) at kern_timeout.c:1091-1093) vs (softclock 2) blocked at wchan slize (lwkt_serialize_enter) β€” each holds what the other needs, forever.
  5. The observing shell never completed its sleep (timer starvation), and the wedged guest could not be shut down cleanly β€” vm.sh reset reported "guest still up after shutdown" and the guest had to be force-killed and reverted to the with-src snapshot (restored, healthy, guest_dirty=0 post-reset).

Root cause (path:line)

  • if_stge.c:587 β€” callout_init(&sc->sc_tick_ch) (non-MP variant).
  • if_stge.c:1752-1778 β€” stge_tick: lwkt_serialize_enter at :1758 (BLOCKING acquire), work, then unconditional callout_reset(..., hz, stge_tick, sc) at :1775 under the serializer, exit at :1777.
  • if_stge.c:2098-2111 β€” stge_stop: ASSERT_SERIALIZED then callout_stop(&sc->sc_tick_ch) at :2111 as the FIRST teardown action, while holding the serializer; IFF_RUNNING is cleared only at the very end (:2166).
  • Every stge_stop caller holds the serializer: stge_detach :820-825, stge_shutdown :1049-1051, stge_suspend :1060-1063, stge_ioctl SIOCSIFFLAGS :1280, stge_init :1900/:1907 β€” and stge_init is reachable from stge_ioctl SIOCSIFMTU :1265, the watchdog :1238, and the interrupt path's force_init (:1438-1440, host-error / Tx-underrun β€” remotely nudgeable by link events).
  • DragonFly callout_stop() is synchronous (kern_timeout.c:1091-1093 β†’ _callout_cancel_or_stop(..., sync=1); ssleep "costp" at :920): it waits for an INPROG callback to finish. A tick dequeued by a softclock thread and parked in lwkt_serialize_enter therefore deadlocks against the stopper: both threads wedge permanently, the interface serializer is never released (all later ioctls/if_start on stgeX block), and the affected CPU's softclock stops delivering ALL callouts. The unconditional re-arm (:1775) means even a late-running tick re-arms itself after any non-synchronous stop attempt.

Threat model

Local DoS via permanent kernel deadlock (two threads + CPU-wide callout starvation). Triggers are ordinary operator actions (ifconfig down, MTU/capability changes, detach, suspend, reboot) racing the 1 Hz tick β€” same privilege framing as DF-3715(em)/DF-4025(re), which were filed Medium. No memory-corruption primitive.

Fix validation

fix.diff makes stge_tick non-blocking: lwkt_serialize_try() with a 1-tick callout_reset retry when the serializer is busy β€” the same shape proposed and verified for em_timer (DF-3715) and re_tick (DF-4025). With a never-blocking tick, stge_stop's callout_stop can always complete. git apply --check passes against the read-only tree. A runtime fix-validation would need stge(4) hardware to race stge_stop against a real stge_tick; the replica's wedged protocol is exactly what the fix removes β€” 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 (try-enter + 1-tick retry in stge_tick) applies clean against the read-only tree (git apply --check); a runtime fix-validation would need stge(4) hardware to race stge_stop against a real stge_tick. The replica's wedged protocol is exactly what the fix removes.

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

Confirmed kernel references

Detail

Exploit chain

On stge(4) hardware: any stge_stop (root-triggered ifconfig down/detach/suspend/reboot, or the interrupt-path force-init) racing the 1 Hz stge_tick on another CPU -> permanent kernel deadlock of two threads + CPU-wide callout starvation; system-degradation DoS, not memory corruption.

Evidence (decisive lines)

run.log (wedge session transcript + post-wedge ps showing 43529 D0 costp vs (softclock 2) slize + serial-console marker sequence ending at 'AB-BA DEADLOCK IS PROVEN'); build.log (BUILD_RC=0); stgereplica.c/Makefile; env.txt (no stge hardware); fix.diff (non-blocking stge_tick)

PoC changes

No seed PoC. Authored stgereplica.ko (out-of-tree kmodule) replicating stge's protocol with stge-specific fidelity: plain NON-MP callout_init (MPLOCK dispatch), blocking lwkt_serialize_enter, UNCONDITIONAL under-lock self re-arm, and callout_stop as the first action of a serializer-held stop in stge_stop's exact call order (IFF_RUNNING cleared last). 1-tick arm period instead of hz for a deterministic window; real driver arms at hz.

Verified recommended fix

Make stge_tick() non-blocking: lwkt_serialize_try() with a 1-tick callout_reset retry when the serializer is busy (see fix.diff; same shape as DF-3715(em)/DF-4025(re)).

Verdict

Mechanism PROVEN on the guest kernel by a deterministic replica of if_stge.c's exact callout/serializer protocol (stge(4) instance of DF-3715(em)/DF-4025(re)); stge-specific runtime hardware-gated, inconclusive per family convention. stge_attach arms one NON-MP callout (plain callout_init, if_stge.c:587; softclock dispatches it under the MPLOCK, kern_timeout.c:575-590) scheduled every hz by stge_init (:2059). Its callback stge_tick BLOCKS on the driver serializer by hand (lwkt_serialize_enter, :1758) and re-arms itself UNCONDITIONALLY under it (:1775 -- no IFF_RUNNING gate at all, unlike re). stge_stop() issues callout_stop(&sc->sc_tick_ch) at :2111 as its FIRST teardown action while HOLDING that serializer (every caller holds it: stge_detach :820-825, stge_shutdown :1049-1051, stge_suspend :1060-1063, stge_ioctl :1280, stge_init :1900/:1907 -- itself reachable from SIOCSIFMTU :1265, the watchdog :1238, and the interrupt path's force_init :1438-1440), clearing IFF_RUNNING only at the very end (:2166). DragonFly callout_stop is synchronous (kern_timeout.c:1091-1093 -> _callout_cancel_or_stop sync=1; ssleep 'costp' at :920), so a stge_tick dequeued on another CPU and parked in lwkt_serialize_enter deadlocks against the stopper (AB-BA): both threads wedge permanently, if_serializer is never released (all later ioctls/if_start on stgeX block), and the affected CPU's softclock stops delivering ALL callouts. Guest replica (stgereplica.ko, build.log BUILD_RC=0): arm -> stge_tick-shape handler parks (fired=202 ran=201) -> stge_stop-shape stop under the serializer -> dmesg ends at the 'AB-BA DEADLOCK IS PROVEN' marker and NEVER prints the post-callout_stop line; stopper thread 43529 stuck D0/wchan 'costp' while (softclock 2) sits blocked in wchan 'slize' (lwkt_serialize_enter) -- the full AB-BA visible in one ps; the observing shell never completed its sleep and the wedged guest could NOT be shut down cleanly (vm.sh reset: 'guest still up after shutdown'), force-killed and reverted to the with-src snapshot. Triggers on stge hardware: ifconfig stgeX down/up, SIOCSIFMTU/SIOCSIFCAP reinit, Tx-underrun/host-error force-init from the interrupt path, detach, suspend, reboot -- racing the 1 Hz tick.