# DF-2788 VERDICT — reproduced (correctness bug, Low); fix validated in-guest

## Verdict

REPRODUCED, deterministic, root-only (KLD trigger). On the stock INVARIANTS
guest kernel (#0, Thu Jul 2 06:02:54 UTC 2026), for each of
`callout_stop()`, `callout_stop_async()`, `callout_cancel()`,
`callout_drain()`: arming an mpsafe callout `hz` ticks out, observing
`callout_pending()==1`, invoking the API, and observing
`callout_pending()==0` — proving *this* call dequeued the callout — the API
returned **0** every time, where the documented contract requires **1**
("responsible for removing a queued or pending callout"). The control case
(callout fires, then stop) correctly returns 0, so the return value cannot
distinguish "I removed it" from "nothing to remove". Impact is
correctness/leak-side only (e.g. ng_pptpgre.c:786/889 free-arm is dead
code); no memory-safety primitive — a return of 1 can never coincide with a
queued or executing callback because PREVENTED-on-backend is set only at
dequeue time under c->spin + wheel->spin with INPROG clear
(kern_timeout.c:218-231, :266-278), so neither half of the broken-return
family (this + DF-0048) yields firing-after-free.

## Root cause (path:line)

- `_callout_cancel_or_stop()` clears PREVENTED, sets the request flag, and
  processes it synchronously: sys/kern/kern_timeout.c:877-882.
- The update dequeues a queued callout and sets CALLOUT_PREVENTED **on the
  backend `c`**: sys/kern/kern_timeout.c:225-231 (CANCEL), :273-278 (STOP).
- The fast path at sys/kern/kern_timeout.c:888-891 sees
  `(c->flags & (CALLOUT_INPROG|CALLOUT_SET)) == 0` (SET was just cleared by
  our own dequeue) and `return 0;` unconditionally — the PREVENTED bit is
  never read.
- The intended read exists but is ~dead code: sys/kern/kern_timeout.c:895-901
  (`return ((c->flags & CALLOUT_PREVENTED) != 0);`) is reachable only if the
  flags changed in the few-instruction window between :888 and :895.
- Distinct root cause from DF-0048 (known, not re-reported): DF-0048 is the
  INPROG-completion path setting PREVENTED on `c->verifier->flags`
  (frontend) at :600-601/:623-624 while the reader reads the backend.
  This finding is the dequeue path setting PREVENTED correctly on the
  backend but the reader returning early. Fixing DF-0048 alone leaves the
  bug (this PoC's cases A-D do not involve INPROG at all); fixing this
  finding alone leaves DF-0048's INPROG-race case wrong. Both need fixing
  for a truthful return value.

## Evidence

- run.log / run.2.log (stock kernel #0, two consecutive runs):
  `DF2788 RESULT: stop=0 async=0 cancel=0 drain=0 ctl_fired=1 ctl_ret=0 BUG=1`
  with all four `pending 1 -> 0` transitions (full marker lines captured
  via dmesg in run.2.log).
- run_patched.log (kernel #1 with only fix.diff applied):
  `DF2788 RESULT: stop=1 async=1 cancel=1 drain=1 ctl_fired=1 ctl_ret=0 BUG=0`
  — the identical PoC returns the documented contract values.
- fix_build.log: `make -j6 nativekernel KERNCONF=X86_64_GENERIC` +
  `make installkernel`, KERNEL_RC=0 / INSTALL_RC=0.
- build.log: full KLD build, -Werror clean.

## Exploit chain

None (impact=none). Not memory corruption: the wrong return is
"0 instead of 1" — callers that free on 1 never free on the wrong path
(leak, not UAF), and no path returns 1 while the callback is queued or
running (proven by the flag-state analysis above). Escalation to uid=0 is
not applicable to this class.

## Why the rest of the audit is a negative result (pass-2 depth)

Beyond this finding and the known DF-0048, every candidate traced during
the pass-2 audit was killed on the source; the notable ones:

- Lost-cancel / fire-after-stop: impossible — the sync wait loop
  (kern_timeout.c:911-921) sleeps under c->spin (ssleep releases/reacquires
  atomically, kern_synch.c:814-828) and the request flags are cleared only
  inside `_callout_update_spinlocked` terminal branches under c->spin with
  wakeup (kern_timeout.c:225-231, :273-278, :300-304, :341-343); the
  handler skips dispatch when STOP|CANCEL is observed after INPROG is set
  (:590-602).
- Cross-cpu drain hang: drain sleeps (no spinning); a hang requires a
  callback that blocks forever — caller defect, not this file.
- Lock-order: global order is c->spin -> wheel->spin everywhere
  (:205, :243-265, :519-570, :643-649); the handler never holds both.
- Reset-while-executing (self-reinit, RFC pattern) and reset-migration to
  another cpu: serialized by c->spin; INPROG blocks third-party updates
  (:184-192) and the recursive same-thread case is explicitly allowed;
  insertion into the target slot is race-free against softtick advance
  because the inserter holds the target wheel->spin across the
  clamp-check + TAILQ_INSERT (:257-265, :322-333) and softticks can only
  reach qtick by scanning that slot under the same lock.
- hardclock_softtick vs softclock_handler (isrunning/softticks handoff):
  isrunning is set/cleared only in crit-serialized same-cpu contexts
  (:455-479, :682-688); the handler's softticks++ at :652 is covered by
  isrunning==1 for its whole run. No lost-wakeup interleaving exists.
- tsleep quick-callout protocol (kern_synch.c:690-760, endtsleep :935-982):
  TDF_TIMEOUT_RUNNING is set under crit before any blocking, the tsleep
  side spins until it clears, and `_callout_cancel_quick`'s
  KKASSERT(INPROG==0) cannot fire because same-cpu crit excludes the
  softclock thread. Airtight.
- Freelist/EXIS discipline: `_callout_free` (:356-368) only runs after
  callout_terminate's synchronous cancel flushed all spin-holders; the
  kfree at :664 is gated on exis_freeable (refs taken by `_callout_gettoc`
  :719 before reading cc->toc freeze pseudo_ticks, kern_clock.c:797-813),
  so no use-after-free of a backend _callout is constructible without a
  caller racing callout_reset against callout_terminate (illegal caller
  discipline; e.g. the verifier check at :547/:539 catches most such
  misuse with a panic).
- Tick arithmetic: curticks/softticks/rtick are consistently mod-2^32;
  extreme to_ticks (near INT_MAX) wraps the clamp subtraction into a
  1-tick clamp (early fire, bounded) — same family as the DF-2688
  negative-to-1-tick clamp already on record as consumer context; not a
  defect here.
- callout_reset_bycpu(cpuid) has no bounds check on the array index, but
  every in-tree caller passes kernel-chosen cpuids (constants, mycpuid,
  driver-computed); KLD-only misuse. Hardening note only.
