callout_stop/stop_async/cancel/drain return 0 after dequeuing a pending callout: sync fast path never reads CALLOUT_PREVENTED it just caused to be set
| Field | Value |
|---|---|
| ID | DF-2788 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:L/A:N |
| CWE | CWE-252 / CWE-1075 |
| File | sys/kern/kern_timeout.c |
| Lines | 888-891 (sets at :229/:276, intended read :895-901) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-31 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
When _callout_cancel_or_stop() processes a STOP/CANCEL against a queued callout, _callout_update_spinlocked() dequeues it and sets CALLOUT_PREVENTED on the backend. The immediately following fast path sees (INPROG|SET)==0 β SET was just cleared by our own dequeue β and returns 0 unconditionally, never reading that PREVENTED bit (the intended read is effectively dead code behind a few-instruction race). callout_stop/stop_async/cancel/drain thus return 0 in the common case where this very call removed a pending callout, violating the documented contract. Distinct root cause from DF-0048; both halves must be fixed.
Threat model & preconditions
Correctness/API-contract with leak-side impact: return-checking callers
take the wrong branch (e.g. ng_pptpgre's if (callout_stop(&rackTimer))
{ kfree(...); ng_unref(node); } β the free/unref arm is dead code on a
stock kernel). No memory-safety primitive (a return of 1 can never
coincide with a queued or executing callback); requires root to observe
directly (KLD), no unprivileged surface.
Proof of contest
VERIFIED deterministically (findings/poc/DF-2788/, no race): arm β
pending==1 β stop β pending==0 β returns 0 (contract: 1), same for
async/cancel/drain; control (fired then stopped) correctly 0 β proving
the return cannot distinguish "removed it" from "nothing pending".
Stock INVARIANTS kernel #0 (two runs): stop=0 async=0 cancel=0
drain=0 ... BUG=1; fix-validated kernel #1: stop=1 async=1 cancel=1
drain=1 ... BUG=0. No uid0 route for this class.
Recommended fix
Return the PREVENTED state on the already-processed fast path instead of 0 (PREVENTED is cleared at function entry under c->spin, so it cannot be stale) β deploy together with DF-0048's verifier/backend PREVENTED fix. Validated diff in findings/poc/DF-2788/fix.diff.
Timeline
- 2026-08-31 Discovered during pass-2 audit of kern_timeout.c (GLM 5.3); deterministic KLD repro + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2788 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2788.c | β | 5.1 KB | view raw | |
| Makefile | β | 83 B | β download | |
| build.sh | β | 191 B | view raw | |
| run.sh | β | 228 B | view raw | |
| build.log | β | 6.0 KB | view raw | |
| run.log | β | 16 B | view raw | |
| run.2.log | β | 489 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| fix_build.log | β | 5.7 MB | β download | |
| run_patched.log | β | 383 B | view raw | |
| env.txt | β | 497 B | view raw | |
| VERDICT.md | β | 6.3 KB | β raw | |
| README.md | β | 4.5 KB | β raw |
DF-2788 β callout_stop/cancel/drain/stop_async return 0 after dequeuing a pending callout
What
_callout_cancel_or_stop() (sys/kern/kern_timeout.c:859-930) processes a
STOP/CANCEL request synchronously via _callout_update_spinlocked()
(:881). When the callout was queued (CALLOUT_SET) and not executing, the
update dequeues it from the callwheel and sets CALLOUT_PREVENTED on the
backend _callout (kern_timeout.c:229 for CANCEL, :276 for STOP).
Immediately after, the fast path at kern_timeout.c:888-891
if (sync == 0 || (c->flags & (CALLOUT_INPROG | CALLOUT_SET)) == 0) {
exis_drop_gd(gd);
return 0; /* <-- unconditionally */
}
sees (INPROG|SET) == 0 (we just cleared SET by dequeuing) and returns
0, never reading the PREVENTED bit it just caused to be set. The
near-identical recheck a few lines later (:895-901) shows the intended
semantics β return ((c->flags & CALLOUT_PREVENTED) != 0) β but is only
reachable if the flags changed between :888 and :895 (a race window a few
instructions wide), i.e. it is effectively dead code.
Consequently, on a stock kernel callout_stop(), callout_stop_async(),
callout_cancel() and callout_drain() return 0 for the common case
"this call removed a pending callout from the wheel", violating the
documented contract at kern_timeout.c:1052-1058, :1027-1030, :1080-1094
("Returns whether the STOP operation was responsible for removing a queued
or pending callout" / "Returns 1 if the cancel/drain is responsible for
stopping the callout").
This is the second half of the broken-return family; DF-0048 (known,
not re-reported here) is the other half: on the INPROG-completion path the
handler records PREVENTED on c->verifier->flags (the frontend
struct callout, :600-601, :623-624) while the reader looks at the
backend c->flags. Fixing DF-0048 alone does not restore correct
returns β the :888 fast path still swallows the dequeue case fixed by
this finding's patch (proven by the patched-kernel run below, which fixes
only this half and flips all four returns to 1).
Impact
Correctness / API-contract, Low. Return-value-checking callers take the
wrong branch. In-tree example: sys/netgraph/pptpgre/ng_pptpgre.c:786/889
if (callout_stop(&a->rackTimer)) { kfree(a->rackTimerPtr, ...);
ng_unref(node); } β the free/unref arm is dead code on a stock kernel
(reference + memory leak while timers were armed; the timeout callback
still frees its own arg, so this direction is a leak, not a UAF).
No false "1" is possible: PREVENTED-on-backend is only set at dequeue
time under both c->spin and wheel->spin with INPROG clear, so a
return of 1 can never coincide with a queued or executing callback β no
firing-after-free primitive exists in either half of the family.
Reproduce (guest: DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC, INVARIANTS)
scp df2788.c Makefile build.sh run.sh dfbsd:/root/df2788/ ssh dfbsd: cd /root/df2788 && sh build.sh # KLD, -Werror clean ssh dfbsd: cd /root/df2788 && sh run.sh # kldload + sysctl -n debug.df2788=1
Expected (stock kernel, bug): dmesg
DF2788 A stop: pending 1 -> 0, ret=0 (contract: 1) DF2788 B stop_async: pending 1 -> 0, ret=0 (contract: 1) DF2788 C cancel: pending 1 -> 0, ret=0 (contract: 1) DF2788 D drain: pending 1 -> 0, ret=0 (contract: 1) DF2788 E control: fired=1, ret=0 (contract: 0) DF2788 RESULT: stop=0 async=0 cancel=0 drain=0 ctl_fired=1 ctl_ret=0 BUG=1
(pending flips 1 -> 0 proves this call dequeued the callout; ret=0 is the contract violation; control case E shows 0 is the correct answer only when nothing was pending.)
Deterministic β no race, no special setup, root only (KLD).
Fix validation
fix.diff (git-apply clean against sys/): the :888 fast path returns
PREVENTED from the backend for both sync and async when the request was
already processed (PREVENTED was cleared at :879, so it cannot be stale);
the async still-pending case keeps returning 0.
- Baseline (kernel #0, stock): BUG=1 (run.log, run.2.log)
- Patched (kernel #1,
make -j6 nativekernel KERNCONF=X86_64_GENERIC+ installkernel + reboot, fix_build.log): BUG=0, all four ret=1 with pending 1->0, control ret=0 (run_patched.log)
Files
df2788.c, Makefile β trigger module; build.sh / run.sh β exact commands; build.log β full clean KLD build (-Werror, RC=0); run.log / run.2.log β stock kernel runs; fix.diff β the fix; fix_build.log β patched kernel build; run_patched.log β patched kernel re-run of the identical PoC.
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) andreturn 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=1with all fourpending 1 -> 0transitions (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_spinlockedterminal 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.
Fix verification
fixedfix.diff (git-apply clean against sys/) applied to the guest /usr/src copy; make -j6 nativekernel + installkernel + reboot (kernel #1). Re-running the identical PoC: all four dequeue cases return 1 with pending 1->0, control still returns 0, BUG=0 - the contract violation is gone with no regressions in the control case.
['fix.diff', 'fix_build.log (KERNEL_RC=0, INSTALL_RC=0)', 'run_patched.log (DF2788 RESULT: stop=1 async=1 cancel=1 drain=1 ctl_fired=1 ctl_ret=0 BUG=0)']
Confirmed kernel references
Detail
Evidence (decisive lines)
["run.2.log: 'DF2788 A stop: pending 1 -> 0, ret=0 (contract: 1)' and same for async/cancel/drain; 'E control: fired=1, ret=0 (contract: 0)'; 'DF2788 RESULT: ... BUG=1' (two consecutive stock runs)", "run_patched.log: same PoC on 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'", 'fix_build.log: make -j6 nativekernel KERNCONF=X86_64_GENERIC + make installkernel, KERNEL_RC=0/INSTALL_RC=0', 'build.log: KLD build -Werror clean', 'VERDICT.md: flag-state proof that return-1 cannot coincide with queued/executing state, plus the pass-2 negative-result trace (lost-cancel, lock order, reset-while-executing, softtick handoff, tsleep quick protocol, freelist/EXIS)']
PoC changes
Trigger authored from scratch (no seed PoC): KLD exposes debug.df2788 sysctl; write 1 arms/stops four pending callouts and one fired-control, printing pending-flip + return for each via kprintf (kprintf goes to console, so the decisive lines are captured with dmesg).
Verified recommended fix
In _callout_cancel_or_stop(), make the already-processed fast path return ((c->flags & CALLOUT_PREVENTED) != 0) for both sync and async instead of unconditionally returning 0 (kern_timeout.c:888-891); PREVENTED is cleared at :879 so it cannot be stale.
Verdict
Reproduced deterministically on the stock INVARIANTS guest (X86_64_GENERIC, 6 cpus): for each of callout_stop(), callout_stop_async(), callout_cancel() and callout_drain(), arming an mpsafe callout hz ticks out, confirming callout_pending()==1, invoking the API and confirming callout_pending()==0 (this call dequeued it from the wheel) yields return 0 every time, where the documented contract (kern_timeout.c:1052-1094) requires 1. Root cause is the sync fast path at kern_timeout.c:888-891 returning 0 unconditionally once (INPROG|SET)==0 - i.e. exactly when the caller's own _callout_update_spinlocked() at :881 dequeued the callout and set CALLOUT_PREVENTED on the backend _callout (:225-231/:273-278); the intended PREVENTED read at :895-901 is reachable only through a few-instruction race window and is effectively dead code. This is the second half of the broken-return family: DF-0048 (known) covers the INPROG-completion path setting PREVENTED on the frontend verifier; fixing DF-0048 alone does not fix these cases (no INPROG involved) and vice versa. Impact is correctness/leak-side (ng_pptpgre.c:786/889 'if (callout_stop()) { kfree; ng_unref; }' is dead code => reference+memory leak). No memory-safety primitive: a return of 1 can never coincide with a queued or executing callback (PREVENTED-on-backend is set only at dequeue under c->spin+wheel->spin with INPROG clear), so no firing-after-free/UAF exists in either half.
No comments yet.