β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2728

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).

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2728 Β· 12 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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):

  1. MOD_LOAD (pinned to cpu0 via usched :1): lwkt_alloc_thread(NULL, LWKT_THREAD_STACK, cpu=1, TDF_NOSTART) creates a thread homed on cpu1, never started. The module sets td_preemptable = lwkt_preempt, td_pri = TDPRI_INT_MED (28) β€” exactly the state a sleeping hardware interrupt thread has (created by lwkt_create in kern_intr.c:329-336, descheduled in ithread_handler at kern_intr.c:966-967, td_critcount still 1 from lwkt_init_thread, sys/kern/lwkt_thread.c:453).
  2. 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.
  3. Mode 3 (control) first: plain lwkt_schedule() of the non-preemptable target β€” clean, no panic (console mode3: ok, no panic (control)).
  4. 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).
  5. cpu1 receives the hardware Xipiq (idle-spinning user thread β†’ apic_vector.s:Xipiq β†’ lwkt_process_ipiq_frame(frame) with gd_processing_ipiq == 1), dispatches lwkt_schedule_remote(frame), which β€” because frame != NULL && ntd->td_preemptable β€” executes crit_exit_noyield(td); _lwkt_schedule(ntd); crit_enter_quick(td); (lwkt_thread.c:1341-1344) and reaches lwkt_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:Xipiq incl TD_CRITCOUNT), remote βˆ’1 (crit_exit_noyield), _lwkt_schedule +1, restored +1 (crit_enter_quick), Xipiq βˆ’1.
  • gd_intr_nesting_level: lwkt_preempt zeroes it around the switch (lwkt_thread.c:1045-1053) so the preemptor may block via lwkt_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 by lwkt_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_testable
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffper-fix-DF-2728

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.