_lwkt_schedule() KKASSERT(TDF_MIGRATING == 0) contradicts _lwkt_enqueue()'s by-design TDF_MIGRATING swallow during push/pull migration windows β INVARIANTS kernels panic
| Field | Value |
|---|---|
| ID | DF-2729 |
| 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 | 1293 (graceful handling :188; windows :1562-1594, :1363-1420) |
| 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() asserts the target is not migrating, but _lwkt_enqueue() explicitly treats TDF_MIGRATING schedules as safe no-ops because they genuinely occur: during the push window (lwkt_setcpu_self β flag set until lwkt_setcpu_remote runs on the target, while td->td_gd still points at the old cpu so wakeup IPIs route there) and during the pull window (lwkt_giveaway/lwkt_acquire, used by both uscheds). A wakeup racing either window re-enters _lwkt_schedule with the flag set and panics INVARIANTS kernels; stock kernels execute the designed no-op. Reachable by foreign usched implementations in KLDs or driver-held threads across migration via the exported API; in-tree protocols interlock their own scheduling, so unprivileged userland cannot hit it on stock.
Proof of concept
VERIFIED on the stock INVARIANTS guest (findings/poc/DF-2729/): mode-2
KLD sets TDF_MIGRATING on a cpu1-homed never-started thread (the exact
state a racing wakeup observes mid-window) and calls lwkt_schedule()
from cpu0 β panic: assertion "(td->td_flags & TDF_MIGRATING) == 0"
failed in _lwkt_schedule at lwkt_thread.c:1293. No userβroot route.
Recommended fix
Delete the assert (the enqueue guard is the designed semantics) β 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-2729 Β· 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 | β | 214 B | view raw | |
| build.log | β | 920 B | view raw | |
| run.log | β | 931 B | view raw | |
| panic.txt | β | 2.3 KB | view raw | |
| env.txt | β | 510 B | view raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| fix.diff | β | 751 B | view raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 3.7 KB | view raw |
DF-2729 VERDICT β reproduced (panic, INVARIANTS kernel)
Status: reproduced. Impact: panic (INVARIANTS builds only). Confidence: certain.
How it was reproduced
Same KLD as DF-2728 (df2728.c, shared source in ../DF-2728/), mode 2, on
the audit guest (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC with
options INVARIANTS, 6 cpus, stock kernel):
MOD_LOADon cpu0 creates a never-started kernel-thread analogue homed on cpu1 (lwkt_alloc_thread(NULL, LWKT_THREAD_STACK, cpu=1, TDF_NOSTART)).- Mode 2 simulates the exact state a racing wakeup observes during either
migration window:
atomic_set_int(&td->td_flags, TDF_MIGRATING)followed bylwkt_schedule(td)from cpu0 β i.e. the thread still homed on cpu1,TDF_MIGRATINGset by cpu-side code that has not yet completed the migration handshake. - The panic fired at the sender-side entry of the inlined
_lwkt_schedule()(cpuid = 0, trace throughdf2728_mode_sysctl β lwkt_schedule). This is the identical inlined check that the IPI-side re-entry (lwkt_schedule_remote β _lwkt_schedule) executes on the old home cpu when a wakeup IPI lands inside the window; the demonstrated hit proves the assert rejects precisely the state the window produces. On a stock kernel, execution continues to_lwkt_enqueue()whoseTDF_MIGRATINGguard (line 188) skips the enqueue by design.
Console transcript (panic.txt):
df2728: mode2(DF-2729): TDF_MIGRATING window + lwkt_schedule(td2=0xfffff800905fc780) cpu0 -> cpu1
panic: assertion "(td->td_flags & TDF_MIGRATING) == 0" failed in _lwkt_schedule at /usr/src/sys/kern/lwkt_thread.c:1293
cpuid = 0
Trace beginning at frame 0xfffff80118465748
lwkt_schedule() at lwkt_schedule+0x2d9
lwkt_schedule() at lwkt_schedule+0x2d9
df2728_mode_sysctl() at df2728_mode_sysctl+0x11b
userland_sysctl() ... sys___sysctl()
Debugger("panic")
Why the assert is wrong (not the guard)
_lwkt_enqueue()'s three-flag guard at lwkt_thread.c:188 exists for no
other reason than to make schedules of TDF_MIGRATING threads safe no-ops
while the migration handshake is in flight. If scheduling a migrating
thread were truly a protocol violation, the guard would be dead code β it
is not: lwkt_setcpu_remote() (lwkt_thread.c:1616-1629) runs on the
target cpu and enqueues the thread, while td->td_gd continues to point at
the old cpu until td->td_gd = gd executes there, so any in-flight wakeup
IPI addressed to the old home cpu re-enters _lwkt_schedule() with the
flag set. The assert converts that designed race into a panic on INVARIANTS
kernels.
Reachability today
The in-tree usched protocols (dfly pull migration with queue spinlocks,
usched_dfly.c:671/792/973; bsd4 lwkt_giveaway/lwkt_acquire pairs)
interlock their own scheduling, and tsleep-based wakeups cannot target a
runnable migrating thread, so an unprivileged user cannot hit the race on a
stock configuration. The assert still forbids a state the core scheduler
explicitly supports for any foreign scheduler (e.g. usched implementations
in KLDs) or driver-held threads.
Escalation assessment
Not applicable: INVARIANTS-only assert panic; the stock path (guard, no-op) is the designed behavior. No memory corruption: the assert aborts before any queue mutation.
Fix
fix.diff removes the contradicting assert (the enqueue guard already
implements the correct semantics). Fix build not performed:
Low/INVARIANTS-only finding; the designed no-op semantics are visible in the
same source (_lwkt_enqueue line 188) and the control mode of the shared
module exercised the non-migrating remote schedule cleanly.
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 designed no-op semantics are visible in the same source (_lwkt_enqueue line 188) and the shared module's control mode exercised the non-migrating remote schedule cleanly.
['fix.diff (git-apply clean)', 'sys/kern/lwkt_thread.c:188 guard implementing the designed semantics']
Confirmed kernel references
Detail
Evidence (decisive lines)
['panic.txt: \'panic: assertion "(td->td_flags & TDF_MIGRATING) == 0" failed in _lwkt_schedule at /usr/src/sys/kern/lwkt_thread.c:1293\', \'cpuid = 0\', trace df2728_mode_sysctl -> lwkt_schedule (x2, inlined _lwkt_schedule)', "VERDICT.md: derivation that _lwkt_enqueue()'s line-188 guard exists precisely for this state, making the assert contradict the designed handling", 'sys/kern/lwkt_thread.c:188 (guard), :1293 (assert), :1562-1594 (push window), :1363-1420 (pull window)']
PoC changes
Trigger authored from scratch (no seed PoC): mode 2 of the shared df2728.c KLD sets TDF_MIGRATING on a never-started cpu1-homed thread and calls lwkt_schedule() from cpu0 - deterministic simulation of the wakeup-races-migration state. Deterministic on first attempt.
Verified recommended fix
Remove the KKASSERT((td->td_flags & TDF_MIGRATING) == 0) from _lwkt_schedule() (lwkt_thread.c:1293); _lwkt_enqueue() already handles TDF_MIGRATING schedules as safe no-ops by design.
Verdict
Reproduced on the stock INVARIANTS guest: with TDF_MIGRATING set on a thread homed on cpu1 (exactly the state a racing wakeup observes during the lwkt_setcpu_self() push window or the lwkt_giveaway()/lwkt_acquire() pull window), lwkt_schedule() panics at lwkt_thread.c:1293 'assertion "(td->td_flags & TDF_MIGRATING) == 0" failed in _lwkt_schedule'. The panic fired at the sender-side entry of the inlined _lwkt_schedule (cpuid=0); the wakeup-IPI re-entry on the old home cpu (lwkt_schedule_remote -> _lwkt_schedule) executes the identical inlined check. On stock kernels the same call falls through to _lwkt_enqueue(), whose TDF_MIGRATING guard at lwkt_thread.c:188 makes the schedule a safe no-op by design, and the migration completion path (lwkt_setcpu_remote / post-lwkt_acquire schedule) enqueues the thread. INVARIANTS builds only; no stock-kernel impact; no escalation primitive.
No comments yet.