usched_bsd4.queue_checks accepts <=0 causing NULL-deref/panic in cache-coherent chooseproc
| Field | Value |
|---|---|
| ID | DF-0019 |
| 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-20 Improper Input Validation |
| File | sys/kern/usched_bsd4.c |
| Lines | 1483, 1544-1548, 1560, 1572, 2043 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
kern.usched_bsd4.queue_checks is registered SYSCTL_ADD_INT(..., CTLFLAG_RW,
...) with the default sysctl_handle_int handler (no lower bound). Setting it
to 0 (or negative) β a root operation β makes
bsd4_chooseproc_locked_cache_coherent()'s while (checks <
usched_bsd4_queue_checks) loop never execute, leaving min_level_lwp == NULL.
The function then assigns lp = min_level_lwp (NULL) and trips
KASSERT(lp) (INVARIANTS) or NULL-dereferences lp (production), panicking
the kernel on the next user reschedule in cache_coherent mode with a
non-empty runqueue.
Root cause
while (checks < usched_bsd4_queue_checks) { /* body never runs if <= 0 */
...
}
...
lp = min_level_lwp; /* :1544 NULL */
q = min_q; which = min_which; pri = min_pri;
KASSERT(lp, ("chooseproc: at least the first lp was good")); /* :1548 */
...
if (chklp) {
if (chklp->lwp_priority < lp->lwp_priority + PPQ) { ... } /* :1560 NULL deref */
}
...
TAILQ_REMOVE(q, lp, lwp_procq); /* :1572 NULL deref */
min_level_lwp is initialized NULL (~:1428) and is only assigned inside
the loop body, which the loop guard (checks < queue_checks, with checks
starting at 0) never enters when usched_bsd4_queue_checks <= 0. The sysctl
registration (:~2043) uses SYSCTL_ADD_INT(..., CTLFLAG_RW,
&usched_bsd4_queue_checks, 5, ...) with no handler-level bound β contrast
sysctl_usched_bsd4_stick_to_level (:1868-1882) which validates its range,
and bsd4_recalculate_estcpu which clamps usched_bsd4_decay to [1..1024]
(:1071-1075). queue_checks has neither protection.
Threat model & preconditions
- Attacker position: privileged β writing
kern.usched_bsd4.*requiresSYSCAP_NOSYSCTL_WR(root). So this is a privileged-user-triggered kernel panic (self-DoS / hardening defect), not a privilege escalation. - Privileges gained or impact: kernel panic (full-system DoS). No integrity/confidentiality impact.
- Required config or capabilities: root; the
bsd4scheduler in use;kern.usched_bsd4.cache_coherent != 0(boot default on multi-node/HT hardware) and a non-empty runqueue. - Reachability:
sysctl kern.usched_bsd4.queue_checks=0, then any context switch that reachesbsd4_chooseproc_locked_cache_coherent.
Proof of concept
PoC source: findings/poc/DF-0019/queue_checks_panic.sh
Run (root, disposable VM)
sh findings/poc/DF-0019/queue_checks_panic.sh
Expected output
panic: chooseproc: at least the first lp was good # INVARIANTS kernel (or) Fatal trap 12 ... in bsd4_chooseproc_locked_cache_coherent # production
Impact
Root-only self-DoS. The kernel should not panic from a sysctl write of an in-range integer β it is a robustness/hardening defect and a violation of the scheduler's own invariants. Rated Low (privileged trigger).
Recommended fix
Validate the sysctl with a custom handler (reject < 1), mirroring
stick_to_level, and/or clamp in the consumer:
--- a/sys/kern/usched_bsd4.c
+++ b/sys/kern/usched_bsd4.c
@@ -1480,7 +1480,7 @@
* minimize the contention (we are in a locked region
*/
- while (checks < usched_bsd4_queue_checks) {
+ while (checks < (usched_bsd4_queue_checks > 0 ? usched_bsd4_queue_checks : 1)) {
plus a sysctl_usched_bsd4_queue_checks handler that returns EINVAL for
new_val < 1 (registered via SYSCTL_ADD_PROC), matching the
sysctl_usched_bsd4_stick_to_level pattern at :1868-1882.
References
sys/kern/usched_bsd4.c:1483β unchecked loop bound.sys/kern/usched_bsd4.c:1544-1572β NULLlpKASSERT / derefs.sys/kern/usched_bsd4.c:2043βSYSCTL_ADD_INTregistration (no bound).sys/kern/usched_bsd4.c:1868βstick_to_level(validation pattern to mirror).- CWE-20 Improper Input Validation.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/usched_bsd4.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0019 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0019.c | trigger-source | working trigger: usched_set(bsd4) + queue_checks=0 β panic | 3.7 KB | view raw |
| load.c | trigger-source | auxiliary heavy-workload launcher (stress-test artifact) | 1.9 KB | view raw |
| queue_checks_panic.sh | trigger-source | original sysctl-only trigger (does NOT switch scheduler) | 780 B | view raw |
| trigger.sh | trigger-source | shell wrapper for load.c (intermediate debug version) | 1014 B | view raw |
| build.sh | build-script | cc -O2 -o df0019 df0019.c | 190 B | view raw |
| run.sh | run-script | runs df0019 as root | 906 B | view raw |
| fix.diff | suggested-fix | git-apply-able: sysctl handler rejects <1 + consumer clamp | 1.7 KB | view raw |
| VERDICT.md | verdict | full analysis: mechanism, reachability, fix validation | 6.4 KB | β raw |
| README.md | readme | human-readable summary + reproduce instructions | 2.1 KB | β raw |
| run.log | run-log | baseline #0 kernel: panic signature | 1.1 KB | view raw |
| fix_run.log | run-log | patched #1 kernel: EINVAL, no panic | 1004 B | view raw |
| panic.txt | panic-signature | panic: chooseproc: at least the first lp was good | 481 B | view raw |
| fix_build.log | build-log | full make -j6 nativekernel output | 5.6 MB | β download |
| env.txt | environment | uname, cc, cpu_topology, sysctl state | 689 B | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0019 β PoC
usched_bsd4.queue_checks=0 β kernel panic (root-only self-DoS).
The bug
kern.usched_bsd4.queue_checks (sys/kern/usched_bsd4.c:2043) is
SYSCTL_ADD_INT(..., CTLFLAG_RW, ...) with the default handler β no lower
bound. Setting it to <=0 makes bsd4_chooseproc_locked_cache_coherent's
while (checks < usched_bsd4_queue_checks) loop (:1483) never run, so
min_level_lwp stays NULL; then :1544 lp = min_level_lwp (NULL) β
KASSERT(lp) (:1548, INVARIANTS) or NULL-deref (:1560/:1572,
production). Kernel panic on the next user reschedule.
Important: default scheduler is dfly, not bsd4
The DragonFlyBSD default user scheduler is dfly
(sys/kern/kern_usched.c:72). The bsd4 code path is dormant unless a
process explicitly switches via usched_set(USCHED_SET_SCHEDULER, "bsd4").
The trigger PoC (df0019.c) does this; the original queue_checks_panic.sh
did not and could not fire the panic.
Build (as root on the guest)
sh build.sh # or: cc -O2 -o df0019 df0019.c
Run (as root on the guest, disposable VM)
sh run.sh # or: ./df0019 24
Expected output
Bug present (unpatched #0 kernel):
panic: chooseproc: at least the first lp was good bsd4_release_curproc() at bsd4_release_curproc+0x89a bsd4_acquire_curproc() at bsd4_acquire_curproc+0x17d syscall2() at syscall2+0x213
Bug fixed (patched #1 kernel):
sysctl: kern.usched_bsd4.queue_checks=0: Invalid argument survived β no panic. exit 0.
Files
df0019.cβ working trigger (switches to bsd4 scheduler, sets queue_checks=0).load.cβ auxiliary heavy-workload launcher (stress-test artifact).queue_checks_panic.shβ original sysctl-only trigger (does NOT switch scheduler; kept for reference).build.sh/run.shβ repro scripts.fix.diffβ the validated fix (git-apply-able).VERDICT.mdβ full analysis.run.log/fix_run.logβ baseline panic / patched clean-exit logs.panic.txtβ panic signature from serial console.fix_build.logβ full kernel build output.env.txtβ guest environment.
DF-0019 β VERDICT
Verdict: REPRODUCED (panic), FIXED (validated)
usched_bsd4.queue_checks accepts <=0, causing a NULL-deref / KASSERT panic in bsd4_chooseproc_locked_cache_coherent(). Root-only self-DoS. No escalation path (DoS only). The fix (reject < 1 in a custom sysctl handler + defensive clamp in the consumer) was built into a single-fix kernel and validated: the patched kernel rejects queue_checks=0 with EINVAL and does not panic; the unpatched #0 kernel panics with the exact signature the finding predicted.
Mechanism (trigger β primitive β effect)
-
Sysctl has no lower bound.
usched_bsd4_queue_checksis registeredSYSCTL_ADD_INT(..., CTLFLAG_RW, ..., 5, ...)atsys/kern/usched_bsd4.c:2043-2047with the defaultsysctl_handle_inthandler. There is no range check β contrastsysctl_usched_bsd4_stick_to_level(:1868-1882) which validates its range. -
Setting
queue_checks=0makes the loop body unreachable. Inbsd4_chooseproc_locked_cache_coherent()(:1416), the localmin_level_lwpis initializedNULL(:1428) and is only assigned inside thewhile (checks < usched_bsd4_queue_checks)loop body (:1483-1538). Withqueue_checks=0, the guard0 < 0is false, so the loop is skipped entirely.min_level_lwpstaysNULL. -
NULL is dereferenced. After the loop,
lp = min_level_lwp(:1544, NULL), thenKASSERT(lp, ("chooseproc: at least the first lp was good"))(:1548) trips on INVARIANTS kernels (whichX86_64_GENERICis βoptions INVARIANTSatsys/config/X86_64_GENERIC:56). On non-INVARIANTS builds, execution continues tolp->lwp_priority(:1560) andTAILQ_REMOVE(q, lp, lwp_procq)(:1572) β both NULL dereferences. -
Call chain confirmed by stack trace:
bsd4_release_curproc+0x89a (inlined bsd4_select_curproc β bsd4_chooseproc_locked_cache_coherent) bsd4_acquire_curproc+0x17d (sys/kern/usched_bsd4.c:354 β user_resched_wanted() path) syscall2+0x213 (returning to userland from the sysctl write)
Critical reachability detail (why the original PoC didn't fire)
The default DragonFlyBSD user scheduler is dfly (sys/kern/kern_usched.c:72: if (defsched == NULL) return(&usched_dfly)). The bsd4 scheduler is compiled in and its sysctls are registered, but its code paths (bsd4_release_curproc, bsd4_select_curproc, bsd4_chooseproc_locked_cache_coherent) are dormant unless a process explicitly switches to it via usched_set(USCHED_SET_SCHEDULER, "bsd4") (syscall 481).
The original PoC (queue_checks_panic.sh) only wrote the sysctl and did not switch to the bsd4 scheduler, so the buggy code path was never reached. The refined PoC (df0019.c) calls usched_set(0, USCHED_SET_SCHEDULER, "bsd4") first, then sets queue_checks=0, which triggers the panic on the very next syscall return.
bsd4_chooseproc_locked_cache_coherent was also inlined by the compiler into bsd4_release_curproc (confirmed via objdump), which is why it doesn't appear as a separate symbol in nm /boot/kernel/kernel β but the code and the KASSERT panic string are present.
Threat model & impact
- Privileges required: root (
SYSCAP_NOSYSCTL_WRfor the sysctl write;SYSCAP_NOSCHEDforusched_set). - Impact: kernel panic β full-system DoS. No integrity/confidentiality impact.
- No escalation path: this is a NULL-deref / KASSERT panic (DoS), not a write-capable corruption primitive. No slab grooming, no pointer forge, no
uid=0chain is possible. - Severity: Low (privileged trigger, DoS only).
Exploit chain
None β this is a pure DoS (NULL-deref panic). No memory-corruption primitive, no escalation possible.
PoC changes
df0019.c(NEW): the working trigger. Callsusched_set(0, USCHED_SET_SCHEDULER, "bsd4")to switch to the bsd4 scheduler, then setskern.usched_bsd4.queue_checks=0via sysctl, then forks CPU-bound children. Panics on the unpatched kernel; getsEINVALon the fixed kernel.queue_checks_panic.sh(original, updated comment): kept for reference. The original only wrote the sysctl and did NOT switch to the bsd4 scheduler, so it could not trigger the panic on a default-configured kernel. Documented this limitation.load.c(auxiliary, from debugging): standalone heavy-workload launcher. Not needed for the final trigger but kept as a stress-test artifact.
Fix validation (Phase 8)
Fix: findings/poc/DF-0019/fix.diff β three changes to sys/kern/usched_bsd4.c:
1. New sysctl handler sysctl_usched_bsd4_queue_checks() (mirrors sysctl_usched_bsd4_stick_to_level at :1868): rejects new_val < 1 with EINVAL.
2. Registration change: SYSCTL_ADD_INT β SYSCTL_ADD_PROC at :2043, routing through the new handler.
3. Defensive consumer clamp: while (checks < (usched_bsd4_queue_checks > 0 ? usched_bsd4_queue_checks : 1)) at :1483 β belt-and-suspenders so even a directly-poked variable can't cause the NULL deref.
Build: make -j6 nativekernel KERNCONF=X86_64_GENERIC from /usr/src on the with-src snapshot. Produced kernel.stripped (15705824 bytes, BuildID 54000c0c..., vs original 15705800 / b18d2eb8...).
Install: cp kernel.stripped /boot/kernel/kernel + sync + clean shutdown + reboot. Booted as #1: Sun Jul 12 15:35:41 UTC 2026.
Before/after:
| Kernel | PoC result | Panic? |
|---|---|---|
#0 unpatched baseline |
queue_checks: 5 -> 0, then panic |
YES β panic: chooseproc: at least the first lp was good |
#1 single-fix kernel |
sysctl: kern.usched_bsd4.queue_checks=0: Invalid argument, exit 0 |
NO |
Valid values (1, 5, 100) are still accepted on the patched kernel. Values <= 0 (0, -1, -999) are rejected with EINVAL. Fix is deterministic (tested multiple values, all consistent).
References
sys/kern/usched_bsd4.c:1483β unchecked loop bound (while (checks < usched_bsd4_queue_checks)).sys/kern/usched_bsd4.c:1544-1548β NULLlp = min_level_lwpβ KASSERT panic.sys/kern/usched_bsd4.c:1560,1572β NULL deref on production (non-INVARIANTS) kernels.sys/kern/usched_bsd4.c:2043-2047βSYSCTL_ADD_INTregistration (no lower bound).sys/kern/usched_bsd4.c:1868-1882βstick_to_levelhandler (validation pattern mirrored).sys/kern/kern_usched.c:72β default scheduler isdfly(why original PoC didn't fire).sys/config/X86_64_GENERIC:56βoptions INVARIANTS(KASSERT compiled in).- CWE-20 Improper Input Validation.
Fix verification
fixedVALIDATED. PoC panics on unpatched #0 (panic: chooseproc: at least the first lp was good, 2x from fresh reset) and does NOT panic on single-fix #1 (sysctl returns EINVAL for 0, PoC exits 0, guest stays up). Valid values still accepted.
BEFORE (#0): queue_checks: 5->0 / panic: chooseproc: at least the first lp was good / guest down. AFTER (#1): sysctl: queue_checks=0: Invalid argument / exit 0 / guest up. Regression: 1,5,100 OK; 0,-1,-999 EINVAL.
Confirmed kernel references
Detail
Exploit chain
none -- this is a pure NULL-deref / KASSERT panic (DoS only). No memory-corruption primitive. Root writes sysctl -> kernel panics on next bsd4 scheduler context switch.
Evidence (decisive lines)
BASELINE (#0): kern.usched_bsd4.queue_checks: 5 -> 0; then SSH dies. Serial: 'panic: chooseproc: at least the first lp was good / bsd4_release_curproc+0x89a / bsd4_acquire_curproc+0x17d / syscall2+0x213 / Stopped at Debugger+0x7c / db>'. PATCHED (#1): 'sysctl: queue_checks=0: Invalid argument' / exit 0 / guest stays up. Valid values 1,5,100 still accepted; 0,-1,-999 rejected.
PoC changes
Wrote df0019.c (NEW working trigger): calls usched_set(0, USCHED_SET_SCHEDULER, "bsd4") first, then sets queue_checks=0. Original queue_checks_panic.sh only wrote the sysctl but never switched scheduler (default is dfly). Authored build.sh, run.sh, fix.diff (sysctl handler + consumer clamp).
Verified recommended fix
fix.diff makes three changes to sys/kern/usched_bsd4.c: (1) adds a sysctl handler that rejects new_val < 1 with EINVAL; (2) changes SYSCTL_ADD_INT to SYSCTL_ADD_PROC at :2043; (3) adds defensive clamp at :1483. Supersedes finding proposal.
Verdict
REPRODUCED. The bug is real: kern.usched_bsd4.queue_checks is registered SYSCTL_ADD_INT(CTLFLAG_RW) with no lower bound. Setting it to 0 makes the while loop at :1483 unreachable, so min_level_lwp stays NULL. Then lp=NULL at :1544 triggers KASSERT(lp) at :1548 on the INVARIANTS GENERIC kernel -- confirmed by the exact predicted panic signature 'panic: chooseproc: at least the first lp was good'. CRITICAL REACHABILITY DETAIL: the default DragonFlyBSD scheduler is 'dfly', NOT 'bsd4' -- the buggy code path is dormant unless a process explicitly switches via usched_set(USCHED_SET_SCHEDULER, "bsd4"). The refined PoC (df0019.c) calls usched_set(2) first, then sets queue_checks=0, which panics immediately on syscall return.
No comments yet.