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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2712 Β· 30 filesDF-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)
./build.shβ boots guest into a rig kernel withkern.user_scheduler="bsd4".- Confirm:
sysctl debug.df2712.armed= 1,kern.usched_bsd4.kicksmoves under load (bsd4 active; stock boots dfly so the loader switch is required). - Smoke (the primitive, 8 s, unprivileged):
vm.sh run_root 'su -m maxx -c "/tmp/storm2 5 60 30 8"'thensysctl 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 (notgt not drainingconsole lines). - Wedge attempt (did NOT wedge):
su -m maxx -c "/tmp/storm5 5 180 12 3 15". - 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), zerosend_ipiq ... tgt not draininglines.
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)
bsd4_kick_helper(sys/kern/usched_bsd4.c:1590-1615) executeslwkt_send_ipiq(:1611) /wakeup(:1613) while the caller holdsbsd4_spin(its own comment :1586 admits it). Call sites insidebsd4_chooseproc_locked_cache_coherentat :1473/:1512/:1519/:1561, which runs under the spin taken at :533 (bsd4_select_curproc).- That path is LIVE under bsd4 by default:
usched_bsd4_cache_coherentis 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). lwkt_send_ipiq3(sys/kern/lwkt_ipiq.c:181): withgd_processing_ipiq == 0the liveloop threshold is MAXCPUFIFO/2 = 128 (:225), not 253 (253 is the nested case, :228). On> 128outstanding it doescpu_enable_intr()(:240) and liveloops (:244-277) until the target drains to <= 64 β still holdingbsd4_spin.- Reproduced:
lwkt.ipiq_fifofullrose 0 -> 48 across the unpriv storms (each entry = one spin-held liveloop). All exited < 1 s: zerosend_ipiq %d->%d tgt not drainingconsole lines across every run (that kprintf fires after 1 s stuck, lwkt_ipiq.c:258-275). - 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_helperchecks and clears the target'sbsd4_rdyprocmaskbit (: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 behindtd_critcountinstead of executing βbsd4_schedulerclock's ownKKASSERT(gd->gd_spinlocks == 0)(usched_bsd4.c:943) documents exactly this invariant. A schedclock can never fire underbsd4_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=bsd4boot 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
-Werrorin 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_fifofullgrowth 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_testableConfirmed kernel references
- sys/kern/usched_bsd4.c:1590-1615
- sys/kern/usched_bsd4.c:1601-1609
- sys/kern/usched_bsd4.c:1473
- sys/kern/usched_bsd4.c:1519
- sys/kern/usched_bsd4.c:1561
- sys/kern/usched_bsd4.c:2030
- sys/kern/usched_bsd4.c:943
- sys/kern/lwkt_ipiq.c:224-230
- sys/kern/lwkt_ipiq.c:240
- sys/kern/lwkt_ipiq.c:244-277
- sys/kern/kern_spinlock.c:167
- sys/kern/kern_usched.c:423
- sys/kern/kern_clock.c:1118
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.
No comments yet.