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

Dead spin_lock_test_mode: debug.spin_lock_test=1 wedges a CPU in a critical section for 60s then panics INVARIANTS kernels (test-mode escape never fires)

Field Value
ID DF-2928
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H
CWE CWE-670 Always-Incorrect Control Flow Implementation
File sys/kern/kern_spinlock.c
Lines 88-91, 409, 417-424 (escape: indefinite2.h:171-176)
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

The INVARIANTS sysctl handler for debug.spin_lock_test=1 ('Test the indefinite wait code') performs a deliberate recursive spin_lock and sets the file-static spin_lock_test_mode expecting the contested wait to break immediately β€” but no code in sys/ reads that variable; indefinite_check() honors only kern_lock.c's lock_test_mode. The recursive acquisition therefore spins the full 60 seconds with preemption/interrupts masked on that CPU and then panics via the unconditional 'indefinite wait' panic (indefinite2.h:183-186). Broken since the 2017 indefinite2.h rewrite; still present in upstream master (checked 2026-09-03). Root-capable only (SYSCAP_RESTRICTEDROOT β€” unprivileged writes verified denied on the guest); availability-only, debug kernels only. It also documents the fragile break-without-acquire contract of _spin_lock_contested. Verified 3/3: root write β†’ CPU frozen in crit section, msgbuf 'indefinite wait' per-second accumulation, panic at exactly T0+60s.

Proof of contest

VERIFIED (findings/poc/DF-2928/): unpriv write 'Operation not permitted'; root write β†’ 60s freeze + panic, 3/3 independent runs, full serial logs. Fix validated conceptually (fix.diff: make spin_lock_test_mode non-static + honor it for 'S'/'s' wait types in indefinite_check; alternative: delete the broken branch).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of kern_spinlock.c (GLM 5.3); reproduced 3/3 as root on INVARIANTS.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2928 Β· 12 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 5.7 KB ↓ raw
trigger.sh β€” 1.6 KB view raw
run.sh β€” 1.2 KB view raw
run.log β€” 1.9 KB view raw
run.2.log β€” 986 B view raw
run.3.log β€” 1.6 KB view raw
panic.txt β€” 1.2 KB view raw
env.txt β€” 1.1 KB view raw
fix.diff β€” 1.5 KB view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 3.8 KB view raw

DF-2928 β€” dead spin_lock_test_mode: debug.spin_lock_test=1 panics INVARIANTS kernels after 60s

What it is

sys/kern/kern_spinlock.c β€” the INVARIANTS-only sysctl debug.spin_lock_test (value 1, "Test the indefinite wait code") is broken because the variable it sets is dead code:

  • sys/kern/kern_spinlock.c:91 declares static int spin_lock_test_mode;
  • the handler sets it around the deliberate recursive spin_lock (kern_spinlock.c:419-424) intending to make the contested wait break
  • nothing reads it: indefinite_check() (sys/sys/indefinite2.h) honors a different global, lock_test_mode (sys/kern/kern_lock.c:71), so the wait never breaks
  • after 60 seconds indefinite_check() panics unconditionally for spinlock types (indefinite2.h:183-186): panic("spin_lock_ex: %s, indefinite wait!")

The 2017 rewrite of the indefinite-wait machinery (indefinite2.h) moved the test-mode check to lock_test_mode and dropped the spinlock-specific variable; upstream master (2026-09) still has the dead static, so this is unfixed upstream.

Impact

  • The calling CPU spins ~60 s inside a critical section (preemption and interrupts masked on that CPU), then the kernel panics.
  • Requires root-equivalent privilege (caps_priv_check_self(SYSCAP_RESTRICTEDROOT), kern_spinlock.c:409) and an INVARIANTS kernel β€” severity Low.
  • Verified: an unprivileged user gets "Operation not permitted".

Build / Run

No compile needed β€” sysctl-only PoC.

# on the guest, as root:
sysctl -w debug.spin_lock_test=1      # blocks 60s, then panic

Or via the harness: ./run.sh (uses dfbsd-qemu/vm.sh; see VERDICT.md for the recorded session).

Expected output

Live msgbuf while it spins (one line per second, never breaks):

spin_lock_ex: sysctl_spin_lock_test, indefinite wait (1 secs)!
...
spin_lock_ex: sysctl_spin_lock_test, indefinite wait (11 secs)!
sysctl_spin_lock_test() at sysctl_spin_lock_test+0x233   <- INVARIANTS backtrace
...
spin_lock_ex: sysctl_spin_lock_test, indefinite wait (57 secs)!
[at 60 secs] panic: spin_lock_ex: sysctl_spin_lock_test, indefinite wait!

Guest then stops answering ssh (vm.sh status -> down; vm.sh reports "guest not answering (likely DDB on panic)").

Fix

fix.diff restores the pre-2017 semantics: make indefinite_check() honor spin_lock_test_mode for spinlock wait types ('S'/'s') so the test sysctl breaks the wait after the first 1-second report instead of running into the 60-second panic. (Alternative: delete the value==1 test branch entirely.)

VERDICT.md
↓ download raw

DF-2928 β€” VERDICT

Status: reproduced (3 independent runs; kernel panic at exactly T0+60s in each) Impact: panic (root-gated kernel panic of an INVARIANTS kernel; DoS-class, no memory corruption, no unpriv path) Confidence: certain (source-proof + runtime progression + privilege gate checked)

Root cause (path:line)

The debug.spin_lock_test sysctl handler for value 1 performs a deliberate recursive spin_lock to exercise the indefinite-wait machinery and sets a flag so the wait breaks immediately:

  • sys/kern/kern_spinlock.c:91 static int spin_lock_test_mode; β€” file-static
  • sys/kern/kern_spinlock.c:419-424 c spin_lock(&spin); /* acquire */ spin_lock_test_mode = 1; /* DEAD: no reader anywhere in sys/ */ spin_lock(&spin); /* recursive -> _spin_lock_contested */ spin_unlock(&spin); spin_unlock(&spin); spin_lock_test_mode = 0;

The escape it expects lives in indefinite_check():

  • sys/sys/indefinite2.h:171-176 (INVARIANTS) β€” breaks the wait only when the global lock_test_mode (defined sys/kern/kern_lock.c:71) is set: c if (lock_test_mode) { print_backtrace(-1); return TRUE; }
  • spin_lock_test_mode is never consulted (grep across sys/: written at kern_spinlock.c:420/424, declared at :91, read nowhere).
  • With no break, the wait runs to sys/sys/indefinite2.h:183-186 (always compiled, no INVARIANTS guard): c if (info->secs == 60 && (info->type == 's' || info->type == 'S')) panic("%s: %s, indefinite wait!", str, info->ident);

So the "test the indefinite wait code" hook is instead a fixed 60-second delayed kernel panic. Upstream master (checked 2026-09-03, GitHub mirror DragonFlyBSD/DragonFlyBSD) still contains the dead static β€” not fixed there.

The bug was introduced when the indefinite-wait code was rewritten into sys/sys/indefinite2.h (2017) around the generic lock_test_mode; the spinlock file kept setting its own private variable.

Reproduction (recorded 2026-09-03, guest see env.txt)

  1. Privilege gate: run_user 'sysctl -w debug.spin_lock_test=1' -> sysctl: debug.spin_lock_test=1: Operation not permitted (rc=1). Gate: caps_priv_check_self(SYSCAP_RESTRICTEDROOT) at kern_spinlock.c:409.

  2. T0 = 14:59:40 (run 3): dmesg -c; detached sysctl -w debug.spin_lock_test=1.

  3. T0+22s = 15:00:02: live msgbuf shows the wait running unbroken with the 1-second reports and the INVARIANTS 11-second backtrace pinning the spinner inside the handler itself: spin_lock_ex: sysctl_spin_lock_test, indefinite wait (1 secs)! ... (one per second) ... spin_lock_ex: sysctl_spin_lock_test, indefinite wait (11 secs)! sysctl_spin_lock_test() at sysctl_spin_lock_test+0x233 0xffffffff8067e4b3 sysctl_spin_lock_test() at sysctl_spin_lock_test+0x233 0xffffffff8067e4b3 ... up to (18 secs)! [21 "indefinite" lines total] The handler set spin_lock_test_mode=1 (kern_spinlock.c:420) before the second spin_lock, so if any code honored it the wait would have broken at the first report. It did not β€” proving the dead variable at runtime.

  4. T0+57s = 15:00:37: msgbuf shows (57 secs)! β€” still no break.

  5. T0+60s β‰ˆ 15:00:40: panic("spin_lock_ex: sysctl_spin_lock_test, indefinite wait!") β€” guest stops answering ssh; vm.sh status -> down at 15:01:17; vm.sh reports "guest not answering (likely DDB on panic)".

Run 1 (T0=14:33:24) and run 2 (T0=14:47:00) died identically at T0+60s (run 2 had debug.debugger_on_panic=0; the dump path did not complete because the panicking thread holds 2 spinlocks + a crit section β€” kern_shutdown.c:823-825 zeroes gd_spinlocks, but the box wedged in the dump; no core file was saved on any run).

Why the panic is certain (not just "likely")

Source path is fully deterministic: the recursive lock state is lock = 0x100001 (low bit held by the handler itself + 1 EXCLWAIT unit queued by _spin_lock_contested, kern_spinlock.c:206), the transfer at kern_spinlock.c:238-246 can never fire (low bits != 0), so indefinite_check is the only exit and its 60-second branch panics. Observed on 3/3 runs with death at exactly T0+60s(+dump/DDB wedge).

Adjacent hazard worth noting (not separately filed)

If the test mode were honored (i.e. after applying fix.diff), the break at kern_spinlock.c:250-251 returns without the lock held, and the handler's two spin_unlocks then decrement a lock word that carries the leaked EXCLWAIT unit: 0x100001 - 1 - 1 = 0x0FFFFF β€” a poisoned lock word. On this sysctl it is harmless (stack-local spin), but it demonstrates why break-without-acquire in _spin_lock_contested is dangerous by design; the only production reachability of that break is panicstr != NULL (indefinite2.h:169-170), i.e. post-panic, which is acceptable.

Exploit chain

None β€” no memory corruption; not an escalation primitive. Ceiling: root-user self-DoS of a debug (INVARIANTS) kernel. Unprivileged trigger denied by capability check (verified live).

Fix validation

Not performed (fix_status: not_testable). This is a Low, root-gated, non-corruption finding; a kernel rebuild was judged out of proportion. fix.diff restores the pre-2017 semantics and is mechanically simple: spin_lock_test_mode becomes non-static, declared extern in sys/sys/indefinite.h, and indefinite_check() breaks spinlock-type waits when it is set. Expected patched behavior: sysctl -w debug.spin_lock_test=1 returns after ~1 s with exactly one "indefinite wait (1 secs)!" report and a backtrace; guest stays up.

Guest state

Guest was reset to the clean with-src snapshot after the final run (vm.sh reset with-src); it is up and clean.

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored against the read-only sys/ tree (restores pre-2017 semantics); kernel rebuild + patched rerun not performed for this Low, root-gated, non-corruption finding β€” expected patched behavior: sysctl returns after ~1s with a single indefinite-wait report and a backtrace, guest stays up.

['fix.diff']
↓ fix.diffper-fix-DF-2928

Confirmed kernel references

Detail

Evidence (decisive lines)

["run.log: T0+22s msgbuf β€” 'indefinite wait (1..18 secs)!' accumulating, handler backtrace, no break despite spin_lock_test_mode=1 being set at T0", "run.2.log: T0+57s msgbuf '(57 secs)!' then vm.sh status=down at T0+~80s (panic at T0+60s)", "run.3.log: two independent earlier runs dying at T0+60s; vm.sh 'likely DDB on panic'", "panic.txt: source-determined panic string 'panic: spin_lock_ex: sysctl_spin_lock_test, indefinite wait!' + full path", 'env.txt: guest uname, INVARIANTS kernel, sysctl defaults, unpriv denial']

PoC changes

Seed sketch replaced: no compilable PoC is needed (sysctl-only trigger); trigger.sh/run.sh encode the exact vm.sh session; panic signature recovered from source + live msgbuf because no crash dump completes (panicking thread holds 2 spinlocks + crit section).

Verified recommended fix

Make indefinite_check() honor spin_lock_test_mode for spinlock wait types ('S'/'s') β€” de-staticize the variable, extern it in sys/indefinite.h, add a type-gated break beside the lock_test_mode check (see fix.diff); alternatively delete the value==1 test branch.

Verdict

The INVARIANTS test hook debug.spin_lock_test=1 sets the write-only static spin_lock_test_mode (kern_spinlock.c:91,420,424 β€” no reader exists in sys/), while indefinite_check() honors only kern_lock.c's lock_test_mode (indefinite2.h:171-176); the deliberate recursive spin_lock therefore never breaks and hits the unconditional 60-second panic at indefinite2.h:183-186. Reproduced three times on the stock INVARIANTS guest: msgbuf shows the wait running unbroken 1..57+ secs with the handler's backtrace (sysctl_spin_lock_test+0x233), guest dies at exactly T0+60s. Unprivileged writes are denied by caps_priv_check_self(SYSCAP_RESTRICTEDROOT) (kern_spinlock.c:409, verified live), so impact is a root-gated kernel panic/DoS of debug kernels β€” severity Low. Not fixed upstream (checked master 2026-09-03).