lwkt_preempt() KKASSERT(gd->gd_processing_ipiq == 0) contradicts the designed preemption-from-framed-IPI path of lwkt_schedule_remote() β INVARIANTS kernels panic
| Field | Value |
|---|---|
| ID | DF-2728 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-670 Always-Incorrect Control Flow |
| File | sys/kern/lwkt_thread.c |
| Lines | 1026 (vs designed dance :1328-1348) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
lwkt_schedule_remote() deliberately drops the critical section when a
remotely-scheduled preemptable thread arrives via a framed IPI
(gd_processing_ipiq elevated) so _lwkt_schedule_post() can call
lwkt_preempt() from inside IPI dispatch. lwkt_preempt() then executes
KKASSERT(gd->gd_processing_ipiq == 0), necessarily false on that
path β every INVARIANTS kernel panics the moment the designed
remote-preemption path is used. Any in-kernel user of the exported
lwkt_schedule() API that remotely schedules a td_preemptable thread
(interrupt threads get td_preemptable=lwkt_preempt) β KLDs,
third-party drivers, future in-tree users β panics debug builds; not
reachable by unprivileged userland on stock (in-tree interrupt-thread
wakeups are same-cpu).
Proof of concept
VERIFIED on the stock INVARIANTS guest (findings/poc/DF-2728/): KLD
creates a TDF_NOSTART thread homed on cpu1 with
td_preemptable=lwkt_preempt and TDPRI_INT_MED (the exact state of a
sleeping interrupt thread), spins a user thread on cpu1, calls
lwkt_schedule(td) from cpu0 β panic: assertion
"gd->gd_processing_ipiq == 0" failed in lwkt_preempt at
lwkt_thread.c:1026, backtrace lwkt_preempt β lwkt_schedule_remote β
lwkt_process_ipiq_core β lwkt_process_ipiq_frame. No userβroot route
(INVARIANTS-only assert abort; stock path correct by design).
Recommended fix
Delete the assert (the path is designed; lwkt_switch's own assert at :576 remains valid since lwkt_preempt zeroes gd_intr_nesting_level around the switch) β diff in the pack.
Timeline
- 2026-08-30 Discovered during pass-2 audit of lwkt_thread.c (GLM 5.3); deterministic INVARIANTS panic reproduced same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2728 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2728.c | β | 4.7 KB | view raw | |
| Makefile | β | 83 B | β download | |
| build.sh | β | 199 B | view raw | |
| run.sh | β | 357 B | view raw | |
| build.log | β | 920 B | view raw | |
| run.log | β | 1.1 KB | view raw | |
| panic.txt | β | 2.6 KB | view raw | |
| env.txt | β | 510 B | view raw | |
| VERDICT.md | β | 5.3 KB | β raw | |
| fix.diff | β | 666 B | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.0 KB | view raw |
DF-2728 VERDICT β reproduced (panic, INVARIANTS kernel)
Status: reproduced. Impact: panic (INVARIANTS builds only). Confidence: certain.
How it was reproduced
Single KLD (df2728.c, this pack) driving the generic remote-schedule API
on the audit guest (DragonFly 6.5-DEVELOPMENT #0, config X86_64_GENERIC with
options INVARIANTS, 6 cpus, stock kernel, no modification of /sys):
MOD_LOAD(pinned to cpu0 viausched :1):lwkt_alloc_thread(NULL, LWKT_THREAD_STACK, cpu=1, TDF_NOSTART)creates a thread homed on cpu1, never started. The module setstd_preemptable = lwkt_preempt,td_pri = TDPRI_INT_MED(28) β exactly the state a sleeping hardware interrupt thread has (created bylwkt_createinkern_intr.c:329-336, descheduled inithread_handleratkern_intr.c:966-967,td_critcountstill 1 fromlwkt_init_thread,sys/kern/lwkt_thread.c:453).- A userland spinner is pinned to cpu1 so the target cpu is executing a user thread (critcount 0, low td_pri) in userland when the IPI lands.
- Mode 3 (control) first: plain
lwkt_schedule()of the non-preemptable target β clean, no panic (consolemode3: ok, no panic (control)). - Mode 1 (trigger):
lwkt_schedule(g_td1)issued from cpu0. This is verbatim_lwkt_schedule()'s remote branch (sys/kern/lwkt_thread.c:1309-1311):lwkt_send_ipiq3(td->td_gd, lwkt_schedule_remote, td, 0). - cpu1 receives the hardware Xipiq (idle-spinning user thread β
apic_vector.s:Xipiqβlwkt_process_ipiq_frame(frame)withgd_processing_ipiq == 1), dispatcheslwkt_schedule_remote(frame), which β becauseframe != NULL && ntd->td_preemptableβ executescrit_exit_noyield(td); _lwkt_schedule(ntd); crit_enter_quick(td);(lwkt_thread.c:1341-1344) and reacheslwkt_preempt()through_lwkt_schedule_post()(lwkt_thread.c:1278-1282).
Result (full text in panic.txt, console interleave garbles some bytes but
every element is legible):
panic: assertion "gd->gd_processing_ipiq == 0" failed in lwkt_preempt at /usr/src/sys/kern/lwkt_thread.c:1026 cpuid = 1 lwkt_preempt() at lwkt_preempt+0x24d lwkt_schedule_remote() at lwkt_schedule_remote+0x3f lwkt_process_ipiq_core.isra.2() at lwkt_process_ipiq_core+0xff lwkt_process_ipiq_frame() at lwkt_process_ipiq_frame+0x10d
The backtrace is exactly the designed remote-preemption pipeline; the assert
at lwkt_thread.c:1026 rejects it.
Why the assert is wrong (not the path)
lwkt_schedule_remote()'s comment (lwkt_thread.c:1328-1334) states the
design: "To allow preemption we have to drop the critical section so only one
is present in _lwkt_schedule_post." The whole crit_exit_noyield() dance
exists only to let lwkt_preempt() run from framed-IPI dispatch. On a
non-INVariANTS kernel the sequence is fully balanced:
- critcount: Xipiq +1 (
apic_vector.s:Xipiqincl TD_CRITCOUNT), remote β1 (crit_exit_noyield),_lwkt_schedule+1, restored +1 (crit_enter_quick), Xipiq β1. gd_intr_nesting_level:lwkt_preemptzeroes it around the switch (lwkt_thread.c:1045-1053) so the preemptor may block vialwkt_switch()without hitting the "switch from fast interrupt/IPI" panic (lwkt_thread.c:586-594), then restores.gd_processing_ipiq: held elevated across the preemption bylwkt_process_ipiq_frame(sys/kern/lwkt_ipiq.c:605), which is precisely the condition the assert forbids.
The first mode-1 run (before the spinner was started) landed on an
idle/crit-elevated context, took the non-frame else branch, and printed
mode1: returned WITHOUT panic (non-INVariANTS semantics: designed preempt)
β the same call sequence completing cleanly when lwkt_preempt() declines
(priority/crit checks at lwkt_thread.c:967-984) β showing the panic is
solely the assert, not any state corruption. No corruption occurs: the
assert fires before any state mutation of the interrupted thread beyond
ntd->td_preempted = td bookkeeping, and the guest is deterministic-reset
clean afterwards.
Reachability today
Unprivileged userland cannot reach it on the stock configuration: in-tree
wakeups of interrupt threads are same-cpu (sched_ithd_intern() same-cpu
branch kern_intr.c:590-594; live-lock systimer and emergency timer are
cpu-local; sched_ithd_remote uses its own non-framed callback). The defect
is latent but live for any KLD or driver that remotely schedules a
td_preemptable thread via the exported lwkt_schedule() API, and it makes
INVARIANTS kernels unable to exercise/validate the designed path at all.
Escalation assessment
Not applicable: INVARIANTS-only assert panic; stock kernels execute the path correctly with balanced accounting (verified by control run). No memory corruption primitive exists β the assert aborts before any unsafe state, and without INVARIANTS there is nothing wrong with the path at all.
Fix
fix.diff (assert removal, matches the fact that lwkt_switch()'s
KKASSERT(gd->gd_processing_ipiq == 0) at lwkt_thread.c:576 remains valid
β lwkt_preempt()'s switch bypasses lwkt_switch() for the interrupted
thread, and lwkt_preempt()'s own intr-nesting zeroing handles the
preemptor). Fix build not performed: Low/INVARIANTS-only finding; the control
run demonstrates the identical path is clean when the assert is absent
(non-INVariANTS semantics).
Fix verification
not_testablefix.diff removes the contradicting assert (git apply --check clean against sys/). Kernel rebuild validation not performed: Low / INVARIANTS-only finding with no stock-kernel effect - the control run and first non-framed mode-1 run already demonstrate the identical call sequence completes cleanly whenever the assert is not compiled in or not reached.
['fix.diff (git-apply clean)', 'run.log: control + non-framed counterfactuals']
Confirmed kernel references
Detail
Evidence (decisive lines)
['panic.txt: \'panic: assertion "gd->gd_processing_ipiq == 0" failed in lwkt_preempt at /usr/src/sys/kern/lwkt_thread.c:1026\', \'cpuid = 1\', four-frame backtrace through lwkt_schedule_remote and lwkt_process_ipiq_frame', "panic.txt: 'df2728: mode3: ok, no panic (control)' and 'mode1: returned WITHOUT panic (non-INVariANTS semantics: designed preempt)' - clean counterfactuals", 'VERDICT.md: full crit/intr-nesting/ipiq accounting trace showing the stock path is balanced by design', "df2728.c: trigger drives exactly _lwkt_schedule()'s remote branch (lwkt_thread.c:1309-1311)"]
PoC changes
Trigger authored from scratch (no seed PoC existed): KLD creates a TDF_NOSTART thread homed on cpu1 with td_preemptable=lwkt_preempt and td_pri=TDPRI_INT_MED (state of a sleeping interrupt thread), plus a sysctl trigger that calls lwkt_schedule() from cpu0. First attempt did not panic because cpu1 was idle (no framed preempt context); fixed by pinning a userland spinner to cpu1 so the Xipiq lands on a critcount-0 user thread.
Verified recommended fix
Remove the KKASSERT(gd->gd_processing_ipiq == 0) from lwkt_preempt() (lwkt_thread.c:1026); preemption from framed-IPI dispatch is designed behavior of lwkt_schedule_remote().
Verdict
Reproduced on the stock INVARIANTS guest (X86_64_GENERIC, 6 cpus): remotely scheduling a td_preemptable thread via the generic _lwkt_schedule() remote branch makes lwkt_schedule_remote() drop the critical section inside framed-IPI dispatch (gd_processing_ipiq == 1) and enter lwkt_preempt(), whose KKASSERT(gd->gd_processing_ipiq == 0) at lwkt_thread.c:1026 panics with the exact predicted backtrace (lwkt_process_ipiq_frame -> lwkt_process_ipiq_core -> lwkt_schedule_remote -> lwkt_preempt, cpuid=1). The control mode and the first (non-framed) run show the identical sequence completes cleanly whenever lwkt_preempt() is not entered, proving the panic is solely the assert contradicting a designed path, not state corruption. INVARIANTS builds only; no stock-kernel impact; no escalation primitive.
No comments yet.