# 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_unlock`s 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.
