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)
PoC verification
Evidence pack
findings/poc/DF-4842 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 insiderun_job(), an ithread draining, any slow kthread):lwkt_free_thread()on aTDF_RUNNING/TDF_TSLEEPQthread βKKASSERTfatal 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]andkthread_stop()objcache_put()s the same td intothread_cacheβ the same pointer exists in two freelists. The next thread exit on that CPU consumesgd_freetdand puts the td into the cache a second time; two subsequent thread allocations can be handed the samestruct 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_threadwith tracelwkt_free_thread β kthread_stop β drm_sched_fini. - test 2 (baseline): see
run.logβ probe showsgd_freetd[cpu] == tdSafterdrm_sched_fini()returned (td still parked while kthread_stop already cache-put it), the sacrificial exit drives the second put, and a cache scan observesthread_cachehandingtdSto a fresh allocation. - patched (
fix.diff, rebuilt drm.ko + amdgpu.ko): seerun_fixed.logβ test 1 returns cleanly in ~3 s (kthread_stop waits for the real body exit), no panic, guest up;run_fixed_slowbody.logshows the wait blocking a stuck body for the full 120 s safety timeout instead of freeing the live thread.
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 brokenkthread_park), the asserts pass and a live, still-schedulablestruct threadis returned to the cache while it remains ongd_tdallq. - Case A (body exited < 1s): the td is parked in
gd_freetd[cpu]ANDobjcache_put()bykthread_stop()β one pointer in two freelists. The next thread exit on that CPU consumesgd_freetdand puts the td into the cache a second time; subsequentlwkt_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
drm_sched_fini()β sys/dev/drm/scheduler/sched_main.c:652-656 β every amdgpu ring teardown / driver unload / rebind. This is the teardown companion of the scheduler/entity files audited in this batch (sched_entity.c destroy path β scheduler fini).intel_engine_fini_breadcrumbs()β sys/dev/drm/i915/intel_breadcrumbs.c:891 (i915 engine teardown).
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 βticksread 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
fixedfixed: 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
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).
No comments yet.