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

kthread_stop() sleeps a fixed 1 second then lwkt_free_thread()s a struct thread DragonFly kthreads reclaim themselves β€” free of a live TDF_RUNNING thread (panic) or thread_cache double-entry (same struct thread handed to two owners) on every drm_sched_fini

Summary

The shim's kthread_stop sets SHOULD_STOP, fire-and-forgets the unpark, tsleeps a FIXED 1s that proves nothing, then unconditionally lwkt_free_thread(ts->dfly_td) - but DFly kthreads reclaim themselves via lwkt_exit/gd_freetd, so the extra free either hits a still-running td (KKASSERT panic) or objcache_puts a td parked in gd_freetd: the next thread exit on that cpu double-puts it and two allocations can receive the SAME struct thread. Reached from drm_sched_fini on every amdgpu ring teardown/unload and intel_engine_fini_breadcrumbs. REPRODUCED deterministic first try (busy 3s body -> panic assertion in lwkt_free_thread <- kthread_stop <- drm_sched_fini) plus proven thread_cache double-entry on the idle path; fix (kt_done handshake + never free the td + KTHREAD_IS_PARKED-guarded schedule) validated on rebuilt drm.ko+amdgpu.ko (clean return, slow-body blocks on handshake instead).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4842 Β· 15 files
FileTypeDescriptionSize
README.md β€” 3.3 KB ↓ raw
VERDICT.md β€” 7.4 KB ↓ raw
df4842.c β€” 12.6 KB view raw
Makefile β€” 328 B ↓ download
build.sh β€” 93 B view raw
run.sh β€” 227 B view raw
build.log β€” 5.8 KB view raw
run.log β€” 1014 B view raw
run_t1_ssh.log β€” 65 B view raw
run_fixed.log β€” 1.7 KB view raw
run_fixed_slowbody.log β€” 1.4 KB view raw
panic.txt β€” 1.2 KB view raw
env.txt β€” 199 B view raw
fix.diff β€” 2.8 KB view raw
verdict.json β€” 5.6 KB view raw

DF-4842 β€” kthread_stop() frees a struct thread it does not own

sys/dev/drm/linux_kthread.c:89-101 β€” the DragonFly linux-kthread shim's kthread_stop() sleeps a fixed 1 second (tsleep(kthread_stop, 0, "kstop", hz)) β€” which proves nothing about the target thread's state β€” and then calls lwkt_free_thread(ts->dfly_td) unconditionally.

On DragonFly a kthread reclaims itself: when the body returns, the cpu_set_thread_handler() wrapper runs kthread_exit() β†’ lwkt_exit() (sys/kern/lwkt_thread.c:1684-1744), which parks the exiting struct thread in gd->gd_freetd for the next thread exiting on that CPU to objcache_put() into thread_cache. kthread_stop()'s extra lwkt_free_thread() therefore hits one of two cases:

  • Case B β€” body still running at +1s (e.g. drm_sched_main() busy inside run_job(), an ithread draining, any slow kthread): lwkt_free_thread() on a TDF_RUNNING/TDF_TSLEEPQ thread β†’ KKASSERT fatal panic (assertions at lwkt_thread.c:512-514) β€” or, on a kernel built without those assertions catching it, a freed-but-scheduled live td (UAF of the thread control block).
  • Case A β€” body exited within 1s (the common case for an idle drm scheduler): the td is already parked in gd_freetd[cpu] and kthread_stop() objcache_put()s the same td into thread_cache β€” the same pointer exists in two freelists. The next thread exit on that CPU consumes gd_freetd and puts the td into the cache a second time; two subsequent thread allocations can be handed the same struct thread, or a live thread's td is freed underneath it.

In-tree callers: drm_sched_fini() (sys/dev/drm/scheduler/sched_main.c:652-656) β€” every amdgpu ring teardown / driver unload β€” and intel_engine_fini_breadcrumbs() (sys/dev/drm/i915/intel_breadcrumbs.c:891). Also note kthread_stop()'s helper kthread_unpark() does a raw lwkt_schedule() on a thread that may be running or tsleeping (runq/sleepq invariant breakage), and the task_struct itself is leaked (never kfree'd).

Build (guest, root)

kldload amdgpu            # pulls in drm.ko, provides drm_sched_* symbols
cd /tmp/df4842 && make    # Makefile next to df4842.c

Run

kldload /tmp/df4842/df4842.ko
sysctl hw.df4842_test=1   # case B: 'panic: assertion ... lwkt_free_thread' ~1s later, db> wedge
# after vm.sh reset with-src + rebuild:
sysctl hw.df4842_test=2   # case A: staged double-put evidence, guest stays up

Expected output

  • test 1 (baseline): see panic.txt β€” panic: assertion "(td->td_flags & (TDF_RUNNING | ...)) == 0" failed in lwkt_free_thread with trace lwkt_free_thread ← kthread_stop ← drm_sched_fini.
  • test 2 (baseline): see run.log β€” probe shows gd_freetd[cpu] == tdS after drm_sched_fini() returned (td still parked while kthread_stop already cache-put it), the sacrificial exit drives the second put, and a cache scan observes thread_cache handing tdS to a fresh allocation.
  • patched (fix.diff, rebuilt drm.ko + amdgpu.ko): see run_fixed.log β€” test 1 returns cleanly in ~3 s (kthread_stop waits for the real body exit), no panic, guest up; run_fixed_slowbody.log shows the wait blocking a stuck body for the full 120 s safety timeout instead of freeing the live thread.
VERDICT.md
↓ download raw

DF-4842 VERDICT β€” kthread_stop() frees a struct thread it does not own

Status: REPRODUCED (panic + demonstrated thread_cache double-entry; fix validated)

Root cause

sys/dev/drm/linux_kthread.c:89-101:

int
kthread_stop(struct task_struct *ts)
{
    set_bit(KTHREAD_SHOULD_STOP, &ts->kt_flags);

    kthread_unpark(ts);
    wake_up_process(ts);

    /* XXX use a better mechanism to wait for the thread to finish running */
    tsleep(kthread_stop, 0, "kstop", hz);   /* fixed 1s β€” proves nothing */
    lwkt_free_thread(ts->dfly_td);      /* frees a thread it does not own */

    return ts->kt_exitvalue;
}

DragonFly kthreads reclaim themselves: when the body returns, cpu_set_thread_handler()'s exit handler runs kthread_exit() β†’ lwkt_exit() (sys/kern/lwkt_thread.c:1684-1744), which leaves the exiting td in gd->gd_freetd for the next exiting thread on that CPU to objcache_put() into thread_cache. lwkt_free_thread() from kthread_stop() is a second, uncoordinated free:

  • Case B (body busy > 1s at stop time): the td is TDF_RUNNING (or blocked on a sleepq) when the fixed 1-second sleep elapses; lwkt_free_thread()'s KKASSERT (lwkt_thread.c:513-514) fires β€” or, if the thread merely sits descheduled (e.g. via the broken kthread_park), the asserts pass and a live, still-schedulable struct thread is returned to the cache while it remains on gd_tdallq.
  • Case A (body exited < 1s): the td is parked in gd_freetd[cpu] AND objcache_put() by kthread_stop() β€” one pointer in two freelists. The next thread exit on that CPU consumes gd_freetd and puts the td into the cache a second time; subsequent lwkt_alloc_thread() calls can hand the same td to two different new threads (shared td β†’ td_sp/td_pri/ td_lwp cross-corruption of unrelated kernel threads), or free the td of a thread that is still using it.

Secondary defects in the same function: kthread_unpark() (called from kthread_stop) does a raw lwkt_schedule() on a thread that may be running or tsleeping (_lwkt_enqueue() has no TDF_RUNNING/TDF_TSLEEPQ guard β€” lwkt_thread.c:1287-1315 enqueues whatever it is handed), and the task_struct is leaked (never kfree'd).

Reachability

Trigger is privileged (driver unload/device teardown), but the corruption is deterministic on every teardown and lands in the global thread cache β€” every subsequent kernel-thread creation on the box shares the corrupted objects.

Reproduction (guest, no GPU required β€” module drives the real in-tree

drm_sched_* code from amdgpu.ko/drm.ko)

Case B β€” free of a live TDF_RUNNING thread (deterministic panic)

sysctl hw.df4842_test=1 (panic.txt):

[DF4842] run_job(...): entered on td=0xfffff800905fc100 cpu=1 - now BUSY-SPINNING ...
[DF4842] calling drm_sched_fini(): ...
panic: assertion "(td->td_flags & (TDF_RUNNING | TDF_PREEMPT_LOCK | TDF_RUNQ | TDF_TSLEEPQ | TDF_KERNELFP)) == 0" failed in lwkt_free_thread at /usr/src/sys/kern/lwkt_thread.c:514
cpuid = 0
Trace:
lwkt_free_thread() at lwkt_free_thread+0x119
kthread_stop() at kthread_stop+0xb4
sysctl_df4842_test() at ...
Debugger("panic") ... db>

The scheduler thread was mid-run_job() on cpu 1 when kthread_stop() (on cpu 0) hit its fixed 1-second sleep and freed the running thread's td. On a build where the KKASSERT does not fire first, this is a silent free-of-live-thread (UAF).

Case A β€” thread_cache double-entry (staged, guest stays up)

sysctl hw.df4842_test=2 (run.log):

[DF4842] round 1: sched thread td=0xfffff800905fd480 on cpu=1; calling drm_sched_fini() ...
[DF4842] probe: cpu=1 gd_freetd=0xfffff800905fd480 tdS=0xfffff800905fd480 -> == tdS STILL PARKED
           in gd_freetd (while kthread_stop already objcache_put() it into thread_cache: DOUBLE OWNERSHIP)
[DF4842] sacr: exiting on cpu=1 - lwkt_exit() will now objcache_put(gd_freetd=tdS ...) AGAIN (2nd put)
[DF4842] scan(cpu=1) alloc 1: thread_cache handed out tdS 0xfffff800905fd480 β€” SAME struct thread
           in a NEW allocation while the 2nd put also sits in the cache

After drm_sched_fini() returned, the exited td was still parked in gd_freetd while kthread_stop() had already cache-put it (code path at linux_kthread.c:98); the sacrificial exit drives the second put; the cache then hands the same td to a fresh allocation β€” an allocation that must never see that td again while a second copy of it sits in the cache. Two live threads receiving the same td is the direct consequence (observed as a single dispense in this run; per-CPU objcache magazine locality kept the second copy from being dispensed to the scanners, which the round loop retries).

Exploitability

Primitive: deterministic struct thread UAF / thread_cache double-entry on every drm scheduler (amdgpu) teardown, and on i915 breadcrumbs teardown. The escalated route (uid=0) would reclaim a freed td via thread-cache allocations and forge td fields (td_sp / td_lwp / td_ucred) β€” the allocation-side write control into a recycled td is weak (thread fields are kernel-set at init; td_comm via kthread names is the main attacker-influen- ced bytes), so a reliable local privesc chain was not constructed in this run. Demonstrated impact: hard kernel panic (case B) + proven double-entry of the global thread cache (case A) β€” kernel memory corruption of the highest-value allocator in the system.

Fix validation

fix.diff (authored after verification; never applied to the read-only audit tree): kthread_stop() now waits for the wrapper to publish kt_done (handshake before the body returns into kthread_exit()), never frees the td (the DFly thread reclaims itself through lwkt_exit()/gd_freetd), frees the leaked task_struct, and stops doing a raw lwkt_schedule() on non-parked threads (KTHREAD_IS_PARKED bit set by kthread_parkme(), honored by kthread_unpark()/kthread_stop()).

Applied to the guest's /usr/src, drm.ko and amdgpu.ko rebuilt:

  • test 1 baseline: panic at +1 s (panic.txt) β€” test 1 patched: drm_sched_fini RETURNED (no panic?!) in ~3 s (the true body runtime), guest up, load+unload clean (run_fixed.log).
  • slow-body variant (body stuck >120 s): patched kthread_stop() blocked for its 120 s safety timeout and returned without freeing the running thread β€” no panic (run_fixed_slowbody.log); baseline would have panicked at +1 s. (The ssh loss afterwards in that log was the PoC's own accidentally-infinite spin β€” ticks read non-volatile under -O2 β€” since fixed with a volatile read; not a kernel issue.)
  • test 2 patched: probe still parks the td in gd_freetd (normal exit path), one legit cache dispense β€” the kthread_stop extra put is gone.

Guest state

Baseline from vm.sh reset with-src; guest reset with-src at end of run.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fixed: fix.diff applied to guest /usr/src, drm.ko+amdgpu.ko rebuilt; baseline test1 panics at +1s (panic.txt) vs patched test1 clean ~3s return with guest up (run_fixed.log); patched slow-body variant blocks 120s on the handshake instead of freeing the running td (run_fixed_slowbody.log); patched test2 shows the normal single park/dispense lifecycle.

fix.diff; run_fixed.log; run_fixed_slowbody.log; VERDICT.md
↓ fix.diffper-fix-DF-4842

Confirmed kernel references

Detail

Exploit chain

driver teardown (amdgpu unload/rebind, i915 breadcrumbs fini) -> drm_sched_fini -> kthread_stop: (A) idle thread: td parked in gd_freetd + kthread_stop objcache_put -> next thread exit on that cpu double-puts the td -> two lwkt_alloc_thread calls can receive the SAME struct thread (cross-thread td_sp/td_pri/td_lwp corruption) or a live td is freed; (B) busy thread (run_job >1s): lwkt_free_thread on TDF_RUNNING td -> KKASSERT panic, or silent free of a live schedulable td -> subsequent td reuse -> UAF write through any td field mutation. uid0 escalation would require reclaiming the freed td with controlled contents (td_sp/td_ucred forge) - thread-cache allocation-side write control is weak, not demonstrated.

Evidence (decisive lines)

panic.txt (case B: assertion panic + lwkt_free_thread<-kthread_stop trace); run.log (case A: gd_freetd==tdS probe after fini, 2nd put, cache dispense of tdS to a new allocation); run_fixed.log (patched: clean stop, no panic); run_fixed_slowbody.log (patched: waits 120s for stuck body instead of freeing); fix.diff; VERDICT.md

PoC changes

Seed was authored fresh for this finding. Two iterations needed: (1) -Werror unused-variable cleanups; (2) the case-B run_job busy-spin read 'ticks' non-volatile - under -O2 the compiler register-cached it and the '3-4s' spin became infinite (this also produced a confusing patched-kernel wedge where kthread_stop correctly waited 120s while the PoC starved a cpu - diagnosed, fixed with a volatile read, and kept as slow-body fix evidence).

Verified recommended fix

kthread_stop must wait for the body to return (kt_done handshake set by linux_ktfn_wrapper before it returns into kthread_exit) and must never call lwkt_free_thread - the DFly thread reclaims its own td via lwkt_exit/gd_freetd; also kfree(task_struct) and only lwkt_schedule threads that actually parked (KTHREAD_IS_PARKED).

Verdict

Reproduced on a fresh-reset guest with a kld module driving the real in-tree drm_sched_* code (amdgpu.ko/drm.ko). kthread_stop (linux_kthread.c:89-101) sleeps a fixed 1s and then unconditionally lwkt_free_thread(ts->dfly_td) on a thread DragonFly kthreads reclaim themselves (body return -> kthread_exit -> lwkt_exit parks the td in gd_freetd, next exiting thread on that cpu objcache_puts it). Case B (drm_sched_main busy inside run_job() at stop time, reached via drm_sched_fini sched_main.c:652-656): 'panic: assertion "(td->td_flags & (TDF_RUNNING|...)) == 0" failed in lwkt_free_thread' with trace lwkt_free_thread <- kthread_stop <- drm_sched_fini (panic.txt); on an assert-less interleave this is a silent free of a live, schedulable struct thread. Case A (idle thread exits <1s, the common amdgpu unload path): probe proves gd_freetd[cpu]==tdS after drm_sched_fini returned while kthread_stop already objcache_put the same td into thread_cache (one pointer, two freelists); a forced thread exit on that cpu consumes gd_freetd and puts the td into the cache a SECOND time, and a scanner allocation then received tdS from the cache (run.log) - double-free of the global thread cache, i.e. two live kernel threads can be handed the same struct thread. Trigger is driver-teardown (privileged), but the corruption is deterministic per teardown and hits the global thread allocator; a uid=0 chain via td reclaim was not constructed (weak attacker write control into recycled td fields) - demonstrated impact is hard panic + thread-cache double-entry. Fix validated: patched drm.ko+amdgpu.ko, kthread_stop now waits for a kt_done handshake and never frees the td: test1 clean ~3s return, no panic, guest up (run_fixed.log); slow-body variant shows the wait blocking 120s instead of freeing the running thread (run_fixed_slowbody.log).