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

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

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)

No comments yet.

PoC verification

Evidence pack

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

  1. MOD_LOAD on cpu0 creates a never-started kernel-thread analogue homed on cpu1 (lwkt_alloc_thread(NULL, LWKT_THREAD_STACK, cpu=1, TDF_NOSTART)).
  2. Mode 2 simulates the exact state a racing wakeup observes during either migration window: atomic_set_int(&td->td_flags, TDF_MIGRATING) followed by lwkt_schedule(td) from cpu0 β€” i.e. the thread still homed on cpu1, TDF_MIGRATING set by cpu-side code that has not yet completed the migration handshake.
  3. The panic fired at the sender-side entry of the inlined _lwkt_schedule() (cpuid = 0, trace through df2728_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() whose TDF_MIGRATING guard (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_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 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']
↓ fix.diffper-fix-DF-2729

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.