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

bsd4_kick_helper issues IPIs/wakeups while holding the global bsd4_spin: FIFO-full liveloop enables same-cpu schedclock recursion into the spinlock (whole-system wedge), unprivileged-triggerable when usched_bsd4 is active

Field Value
ID DF-2712
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-667 Improper Locking, CWE-834 Deadlock
File sys/kern/usched_bsd4.c
Lines 1590-1615 (kick_helper), calls 1473/1512/1519/1561 under spin 533/1780
Area kern
Confidence likely
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

bsd4_chooseproc_locked_cache_coherent() runs under the single global scheduler spinlock and calls bsd4_kick_helper() up to queue_checks (default 5) times per invocation; kick_helper executes lwkt_send_ipiq()/wakeup() while its caller still holds bsd4_spin (its own comment admits it). Every other IPI site in this file unlocks first, and usched_dfly.c explicitly documents the discipline. When the per-cpu ipiq FIFO exceeds MAXCPUFIFOβˆ’3 = 253 entries, lwkt_send_ipiq3 enables interrupts and liveloops waiting for the target to drain β€” still holding bsd4_spin. Two wedge modes: (a) same-cpu schedclock β†’ bsd4_resetpriority β†’ recursive spin_lock on a non-reentrant lock in interrupt context (no self-hold detection; indefinite_check only prints) β€” the interrupt never returns and every other cpu wedges on the next scheduler touch: whole-system hang without a panic; (b) cross-cpu mutual wedge via a target blocked in a critical section on bsd4_spin stopping its FIFO drain. An unprivileged user with many runnable threads pinned across cpus multiplies the IPI fill rate of every scheduling event.

Threat model & preconditions

Unprivileged local user on a system booted with kern.user_scheduler=bsd4 (or a privileged proc switched to bsd4): persistent whole-system DoS. Latent on stock dfly boots (code dormant); not memory corruption β€” the ceiling is availability.

Move all IPI/wakeup issuance out from under bsd4_spin (deferred-kick variant: record targets under the spin, unlock, then issue) β€” matching the file's own discipline at :882-896 and dfly's design. Guardrails: clamp usched_bsd4_queue_checks β‰₯ 1 (also closes DF-0019) and validate rrinterval to [1, SHRT_MAX]. Phase V skipped per conditional policy (Medium, latent-config DoS); trigger sketch in the file row.

Timeline

  • 2026-08-30 Discovered during pass-2 audit of usched_bsd4.c (GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2712 Β· 30 files
FileTypeDescriptionSize
README.md β€” 3.1 KB ↓ raw
VERDICT.md β€” 8.3 KB ↓ raw
df2712_rig.c β€” 5.1 KB view raw
df2712_storm.c β€” 2.7 KB view raw
df2712_storm2.c β€” 3.0 KB view raw
df2712_storm3.c β€” 3.0 KB view raw
df2712_storm4.c β€” 3.0 KB view raw
df2712_storm5.c β€” 2.5 KB view raw
df2712_bench.c β€” 1.6 KB view raw
build.sh β€” 211 B view raw
run.sh β€” 203 B view raw
env.txt β€” 1.1 KB view raw
fix.diff β€” 5.1 KB view raw
sha256.txt β€” 644 B view raw
run.supplemental.txt β€” 715 B view raw
run.smoke.txt β€” 119 B view raw
run.e2.pre.txt β€” 218 B view raw
run.e2.sampler.txt β€” 240 B view raw
run.stock.smoke.txt β€” 131 B view raw
run.stock.pulses.txt β€” 313 B view raw
run.stock.pp4.txt β€” 573 B view raw
run.fixv2.smoke.txt β€” 98 B view raw
run.fixv2.pulses.txt β€” 689 B view raw
run.fixv2.pp4.txt β€” 573 B view raw
run.dfly.control.txt β€” 264 B view raw
bench.baseline.txt β€” 60 B view raw
build_all.log β€” 1.8 KB view raw
build_kernel.log β€” 1.8 KB view raw
install_kernel.log β€” 80.4 KB view raw
guest-logs/ β€” 4.0 KB ↓ download

DF-2712 β€” evidence pack

Finding: sys/kern/usched_bsd4.c:1590-1615 β€” bsd4_kick_helper() issues lwkt_send_ipiq()/wakeup() while the caller still holds the global bsd4_spin, so a FIFO-full lwkt_send_ipiq3() liveloop (which enables interrupts and spins, lwkt_ipiq.c:240-277) executes with the scheduler spinlock held β€” claimed to enable a same-cpu schedclock recursion and a cross-cpu mutual whole-system wedge, unprivileged, when usched_bsd4 is the active user scheduler.

What is in here

file what
df2712_rig.c kernel-compiled hard-IRQ watchdog (1 Hz systimer per cpu; per-cpu beats / gd_spinlocks / td_critcount / max outgoing ipiq FIFO depth; frozen-beat wedge marker). Compiled INTO the guest kernel because runtime kldload GPFs on this guest.
df2712_storm.c unpinned unpriv yield storm (v1) β€” negative control: no kicks
df2712_storm2.c pinned victim/sender yield storm via lwp_setaffinity(0,-1,mask) (unpriv-legal self-affinity, kern_usched.c:423)
df2712_storm3.c, df2712_storm4.c priority-shape variants (sleep-interleaved victims / interactive victims)
df2712_storm5.c pulsed fresh-wave bursts (the burst is where all kick traffic lives)
df2712_bench.c scheduler sanity microbench (yield rate, fork64, pipe RT)
build.sh push rig+storm, compile rig into kernel (make nativekernel), install, set kern.user_scheduler=bsd4 in loader.conf, reboot
run.sh experiment runner with host-side sampling
run.e*.{pre,sampler,post,console}.txt full logs per experiment
env.txt guest/kernel/scheduler state (bsd4 ACTIVE, rig armed)
fix.diff deferred-kick patch (+ queue_checks clamp)
VERDICT.md, manifest.json, verdict.json the verdict

Reproduce (summary)

  1. ./build.sh β€” boots guest into a rig kernel with kern.user_scheduler="bsd4".
  2. Confirm: sysctl debug.df2712.armed = 1, kern.usched_bsd4.kicks moves under load (bsd4 active; stock boots dfly so the loader switch is required).
  3. Smoke (the primitive, 8 s, unprivileged): vm.sh run_root 'su -m maxx -c "/tmp/storm2 5 60 30 8"' then sysctl lwkt.ipiq_fifofull β€” rose from 0 to 1+ on the stock-bsd4 kernel (48 total across all our runs): spin-held FIFO-full liveloops entered. Each drained < 1 s (no tgt not draining console lines).
  4. Wedge attempt (did NOT wedge): su -m maxx -c "/tmp/storm5 5 180 12 3 15".
  5. Full battery + fix A/B: see VERDICT.md.

Expected output (what we actually observed)

  • kern.usched_bsd4.kicks: 4 -> 238,005 under unprivileged storms.
  • lwkt.ipiq_fifofull: 0 -> 48 (spin-held liveloop entries, all transient).
  • max outgoing FIFO depth seen by the 1 Hz watchdog sampler: 20 of the 128 (MAXCPUFIFO/2, lwkt_ipiq.c:225) entries needed to enter the liveloop (253 applies to the nested case only).
  • ssh/serial starvation > 60 s, load average 30-71, storm cycles stretched from 6 s nominal to 2-4 min β€” severe recoverable DoS.
  • NO permanent wedge: watchdog beats never froze >= 5 s (debug.df2712.wmarks = 0), zero send_ipiq ... tgt not draining lines.
VERDICT.md
↓ download raw

DF-2712 β€” verification verdict

Bottom line

The claimed whole-system wedge did NOT reproduce. The mechanism it is built on is real and was reproduced line-accurately β€” 238,205 spin-held bsd4_kick_helper executions and 48 spin-held FIFO-full lwkt_send_ipiq3() liveloops (interrupts enabled, spinning, while the global bsd4_spin is held) β€” but on this 6-vCPU guest every liveloop drained in under a second, the outgoing FIFO depth never exceeded 20 of the 128 entries needed to enter the liveloop as a sustained state, and the scheduler-spin watchdog never observed a frozen cpu. Alongside the transient liveloops the unprivileged storm produced minutes-scale ssh starvation and load-average 30-71 β€” a severe recoverable availability degradation, not the claimed permanent hang. An honest inconclusive for the wedge claim, with the gap quantified below.

Line-verified mechanism (all confirmed on the running kernel)

  1. bsd4_kick_helper (sys/kern/usched_bsd4.c:1590-1615) executes lwkt_send_ipiq (:1611) / wakeup (:1613) while the caller holds bsd4_spin (its own comment :1586 admits it). Call sites inside bsd4_chooseproc_locked_cache_coherent at :1473/:1512/:1519/:1561, which runs under the spin taken at :533 (bsd4_select_curproc).
  2. That path is LIVE under bsd4 by default: usched_bsd4_cache_coherent is set to 1 at registration (usched_bsd4.c:2030) whenever the cpu topology provides nodes (true on this QEMU guest: usched_bsd4.smt: NOT SUPPORTED + cache_coherent: 1).
  3. lwkt_send_ipiq3 (sys/kern/lwkt_ipiq.c:181): with gd_processing_ipiq == 0 the liveloop threshold is MAXCPUFIFO/2 = 128 (:225), not 253 (253 is the nested case, :228). On > 128 outstanding it does cpu_enable_intr() (:240) and liveloops (:244-277) until the target drains to <= 64 β€” still holding bsd4_spin.
  4. Reproduced: lwkt.ipiq_fifofull rose 0 -> 48 across the unpriv storms (each entry = one spin-held liveloop). All exited < 1 s: zero send_ipiq %d->%d tgt not draining console lines across every run (that kprintf fires after 1 s stuck, lwkt_ipiq.c:258-275).
  5. Unprivileged reachability confirmed: lwp_setaffinity(2) with pid=0 is explicitly allowed for self (kern_usched.c:423 "Always allow change self CPU affinity"; usched_set(USCHED_SET_CPU) by contrast needs uid 0 β€” caps_priv_check, kern_caps.c β€” the prompt's prior-session assumption held only for the syscall, which is enough).

Why the wedge did not close (quantified gap)

  • rdyprocmask governor: bsd4_kick_helper checks and clears the target's bsd4_rdyprocmask bit (:1601-1609) before counting a kick, so a single spin-held scan can send at most ~1 IPI per target cpu; the target's helper re-arms the bit each cycle. The bulk Xipiq drain on the target then resets the per-sender FIFO to ~0 in one interrupt.
  • Achieved max sustained (1 Hz-sampled) outgoing depth: 20 / 128.
  • Rate: 48 liveloop entries in ~45 min of adversarial pressure, each <1 s. A permanent wedge needs either (a) 129 enqueues inside one target drain-gap (governor caps this at ~20 on this guest) or (b) two anti-parallel liveloops overlapping within their sub-second windows. Neither race closed in 6 storm variants (unpinned, pinned victim/sender, interactive victims, pulsed fresh waves, queue_checks=5 and =64).
  • queue_checks=64 (root sysctl) does NOT multiply per-target enqueues (the governor caps at 1/scan); it only widens the scan.
  • Mode (a) of the finding (same-cpu schedclock recursion) is architecturally impossible: holding a spinlock implies a critical section (spin_lock -> crit_enter_raw, spinlock2.h), and timer interrupts pend behind td_critcount instead of executing β€” bsd4_schedulerclock's own KKASSERT(gd->gd_spinlocks == 0) (usched_bsd4.c:943) documents exactly this invariant. A schedclock can never fire under bsd4_spin.
  • Mode (b) (cross-cpu mutual wedge: livelooping holder + target parked in _spin_lock_contested, which spins in a crit section without draining β€” kern_spinlock.c:167+) remains theoretically reachable but is a narrow race that our sustained storms never hit.

Demonstrated impact (what did reproduce)

  • Unprivileged, kern.user_scheduler=bsd4 boot config:
  • kicks counter 4 -> 238,205 (spin-held kick volume),
  • 48 spin-held FIFO-full liveloops,
  • ssh sessions starved for > 60 s continuously (E2, t+44s onward), load average peaking at 71, storm waves stretching from 6 s nominal to 2-4 min (20-40x),
  • full self-recovery after the storm; watchdog beats never froze (>= 5 s) in any run; debug.df2712.wmarks = 0 throughout.

Fix validation (fix.diff β€” deferred-kick + queue_checks clamp)

fix.diff (v2, minimal shape): kicks are RECORDED under the spin (bsd4_record_kick β€” atomic ops only, priority snapshot) and ISSUED after bsd4_spin is released (bsd4_issue_kicks), only in bsd4_select_curproc's path; the helper-thread loop keeps its original plain bsd4_chooseproc_locked calls; queue_checks is clamped to [1, BSD4_KICK_MAX-2] in the scan loop (also closes the DF-0019 degenerate queue_checks<=0 edge).

  • Compiles clean under -Werror in the guest kernel build (fixbuild3.log), boots, bsd4 active, rig armed.
  • Semantics preserved under identical storms: kicks 5 -> 27,710 during the 6-pulse battery (stock: +15,612 on the same battery β€” kick flow intact, even richer), storms complete 6/6 cycles.
  • Spin-held IPI issuance is eliminated by construction: on the fixed kernel the only senders that can still liveloop are the unlocked ones (generic by-design backpressure). Residual ipiq_fifofull growth under the same storms dropped from 3.3/cycle (stock) to 2.2/cycle (fix).
  • Blocking-wakeup sanity (corrected two-pipe ping-pong, 20,000 round trips/run): stock 9/9 at 2.1-8.3 us/rt; fix 9/9 at 1.8-5.2 us/rt.
  • Methodological note recorded below: an intermediate "regression" we chased (single-pipe ping-pong deadlocks on every kernel, both schedulers, and even on the Linux host) turned out to be a protocol bug in the test (a bidirectional single pipe lets a side consume its own echoed bytes). That evidence is void; the two-pipe test is the valid one.

Experiments log index

tag kernel knobs storm outcome
smoke #1 stock+rig defaults storm2 60/30 8s kicks 4->2088, fifofull 0->1, survived
e1 #1 defaults storm 300 unpinned 120s kicks +0 (no pinning -> no kicks), survived
e2 #1 defaults storm2 180/60 120s +126K kicks in <4s, fifofull +30, ssh dead >60s, recovered, survived
e3a/e4a #1 defaults storm3/storm4 100-120s +56K then frozen; fifofull +3; survived
e5 #1 qchecks=64 storm4 90s +70 kicks (warm rdyprocmask); survived
e6 #1 qchecks=64 storm5 9 pulse cycles +9.6K kicks, fifofull +11 (48 total), cycles 20-40x slow, survived
stock smoke/pulses #4 stock+rig defaults same shapes 2->2154 kicks / +15.6K kicks, fifofull +21, survived
fixv2 smoke/pulses #5 fix+rig defaults same shapes 5->1466 / ->27.7K kicks, fifofull 0->13, survived
pp3 (VOID) #2-#5 + host - single-pipe ping-pong deadlocks everywhere incl. Linux host = test protocol bug
pp4 #4 vs #5 - two-pipe ping-pong 9x each both 9/9, ~2 us/rt

Conclusion for triage

The finding's severity rests on the permanent wedge, which we could not demonstrate. What stands: an objectively unsafe primitive (IPI/wakeup under the global scheduler spinlock, liveloop with interrupts enabled while holding it β€” 48 observed events) plus a demonstrated unprivileged minutes-grade DoS on bsd4-configured systems, both cleanly removed by the attached minimal deferred-kick fix, which validates without regression. Recommend keeping the finding at Medium (availability wedge class closed as not demonstrated on this hardware; residual risk is the theoretical mutual-wedge race and the real transient starvation), and taking the fix.

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
↓ fix.diffper-fix-DF-2712

Confirmed kernel references

Detail

Evidence (decisive lines)

run.smoke.txt and run.stock.smoke.txt (kicks 4->2088/2154, fifofull 0->1 in 8s unpriv storms); run.e2.sampler.txt (kicks frozen at 128621 after the burst, ssh starvation onset); run.e6.stormout.txt + run.supplemental.txt (fifofull 48 final, maxdepth d20 of 128, wmarks=0); run.stock.pulses.txt vs run.fixv2.pulses.txt (A/B: +20 vs +13 fifofull, kick flow preserved); run.stock.pp4.txt and run.fixv2.pp4.txt (blocking-wakeup parity 9/9 both); run.dfly.control.txt (voided test episode documented); VERDICT.md for the full narrative and quantified gap

PoC changes

rewrote the trigger entirely: usched_set(USCHED_SET_CPU) is EPERM for unpriv (caps uid-0 check), switched to lwp_setaffinity(0,-1,mask) self-pin which kern_usched.c:423 always allows; added kernel-compiled hard-IRQ systimer watchdog (df2712_rig.c) because runtime kldload GPFs; five storm variants to fight the rdyprocmask governor and estcpu sorting; two-pipe ping-pong after discovering our single-pipe test self-deadlocks (also on Linux)

Verified recommended fix

deferred-kick: record kick targets under bsd4_spin (atomics only), issue lwkt_send_ipiq/wakeup after spin_unlock; clamp usched_bsd4_queue_checks to [1,30] - see fix.diff

Verdict

The claimed permanent whole-system wedge did not reproduce on the 6-vCPU guest despite ~45 minutes of unprivileged adversarial pressure across 6 storm variants (pinned victims via unpriv-legal lwp_setaffinity(0,-1), pulsed fresh waves, queue_checks=5 and =64): lwkt.ipiq_fifofull recorded 48 spin-held FIFO-full liveloops in lwkt_send_ipiq3 (cpu_enable_intr + liveloop while bsd4_spin held) but every one drained in under a second (zero tgt-not-draining console lines), max sustained outgoing FIFO depth was 20 of the 128 entries required to enter the liveloop, and the hard-IRQ watchdog never saw a cpu frozen for 5s (wmarks=0). The rdyprocmask governor in kick_helper (usched_bsd4.c:1601-1609) admits about one IPI per target per spin-held scan and the target bulk Xipiq drain resets the FIFO, so the 129-enqueues-in-one-drain-gap fill was unreachable here; the schedclock-recursion mode is architecturally impossible because spinlocks hold critical sections and timer interrupts pend rather than execute (documented by the KKASSERT at usched_bsd4.c:943). What did reproduce: 238,205 spin-held kicks, 48 spin-held liveloops, and minutes-grade unprivileged ssh starvation (load avg up to 71, storm waves 20-40x slower) on a bsd4-configured system - a severe recoverable DoS, not the claimed permanent hang. The attached minimal deferred-kick fix (record under spin, issue after unlock, queue_checks clamp) compiles clean, boots, preserves kick semantics (27,710 kicks during the identical 6-pulse battery vs 15,612 on stock) and blocking-wakeup latency (two-pipe ping-pong 9/9 at 1.8-5.2us/rt vs stock 2.1-8.3us/rt), and eliminates spin-held IPI issuance by construction; residual fifofull growth under the same storms fell from 3.3 to 2.2 per cycle (unlocked senders only, by-design backpressure). An earlier suspected fix regression was traced to a protocol bug in our own single-pipe ping-pong test (it deadlocks on the Linux host too by consuming self-echoed bytes) and is void. Recommendation: keep the finding at Medium for the real transient DoS plus the unsafe primitive, adopt the fix.