hrtimer shim shares one struct task across per-CPU taskqueue_thread queues β cross-CPU restart while a previous expiry's task is pending panics (KKASSERT) or races unlocked task state
Summary
The expiry task enqueues on the FIRING cpu's queue while re-arm happens on the CALLING cpu - one task migrating between per-cpu queues violates the taskqueue contract and trips KKASSERT(queue==ta_queue) on INVARIANTS kernels. Deterministically REPRODUCED on guest (panic with __hrtimer_function in trace, first attempt); i915 pmu/forcewake/perf all do legal cross-cpu restarts. Pin-the-home-queue fix validated on rebuilt drm.ko (5 attempts no panic).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5176 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5176.c | β | 4.0 KB | view raw | |
| Makefile | β | 151 B | β download | |
| build.sh | β | 155 B | view raw | |
| run.sh | β | 136 B | view raw | |
| build.log | β | 1.0 KB | view raw | |
| run.log | β | 933 B | view raw | |
| run.log.full | β | 14.5 KB | β download | |
| panic.txt | β | 894 B | view raw | |
| run.fix.log | β | 493 B | view raw | |
| fix.diff | β | 2.8 KB | view raw | |
| env.txt | β | 354 B | view raw | |
| README.md | β | 2.7 KB | β raw | |
| VERDICT.md | β | 5.5 KB | β raw | |
| verdict.json | β | 5.8 KB | view raw |
DF-5176 β hrtimer shim shares one struct task across per-CPU taskqueues (cross-CPU re-arm panics / races unlocked task state)
What
sys/dev/drm/linux_hrtimer.c (the LinuxKPI hrtimer shim, compiled into
drm.ko) keeps ONE struct task (timer->task) per hrtimer but enqueues it
on whichever CPU's taskqueue_thread[] the systimer fires on
(__hrtimer_function, linux_hrtimer.c:37), while the systimer itself is
(re-)armed on whichever CPU calls hrtimer_start
(hrtimer_start_range_ns β systimer_init_oneshot, linux_hrtimer.c:115,
setting st.gd = mycpu).
DragonFly's taskqueue explicitly forbids this β
sys/kern/subr_taskqueue.c:185-188:
* NOTE! If using the per-cpu taskqueues ``taskqueue_thread[mycpuid]'', * be sure NOT TO SHARE the ``task'' between CPUs. TASKS ARE NOT LOCKED. * So either use a throwaway task which will only be enqueued once, or * use one task per CPU!
because taskqueue_enqueue_locked() (subr_taskqueue.c:205-209) only handles
the same-queue case and asserts KKASSERT(queue == task->ta_queue).
Re-arming the timer from a different CPU while the previous expiry's task is
still pending therefore either panics (INVARIANTS kernels β the stock
X86_64_GENERIC has INVARIANTS, sys/config/X86_64_GENERIC:56) or performs an
unlocked cross-queue ta_pending++ on task state owned by another CPU.
Reproduce (on the audit QEMU guest)
scp df5176.c Makefile dfbsd:/root/df5176/ ssh dfbsd 'cd /root/df5176 && make SYSDIR=/usr/src/sys && kldload drm' ssh dfbsd 'kldload /root/df5176/df5176.ko' # guest panics immediately
Expected (baseline, unpatched drm.ko)
[df5176] attempt 1: arming on cpu0 (1ms) [df5176] task ta_pending=1 ta_queue == q0 [df5176] re-arming from cpu1 (2ms) panic: assertion "queue == task->ta_queue" failed in taskqueue_enqueue_locked at /usr/src/sys/kern/subr_taskqueue.c:206 taskqueue_enqueue_locked+0x159 taskqueue_enqueue+0x45 __hrtimer_function+0x21 systimer_intr+0xd6 pcpu_timer_process_oncpu+0x82
(fix-validated: with fix.diff applied and drm.ko rebuilt, the same module
prints FAILED to trigger - no panic and the guest stays up β see
run.fix.log.)
Files
df5176.c/Makefileβ the PoC KLDbuild.log/run.log/run.log.full/panic.txtβ baseline evidencerun.fix.logβ fixed-kernel rerun (no panic)fix.diffβ one coherent fix for DF-5176 + DF-5177 (pins the home taskqueue athrtimer_init; makeshrtimer_cancelloop until quiescent with the token held acrosssystimer_del; drops therunning=falselie fromhrtimer_start)env.txtβ guest kernel/INVARIANTS/kldstatVERDICT.md,manifest.json,verdict.jsonβ full narrative + machine verdict
DF-5176 VERDICT β REPRODUCED (deterministic panic)
1. The bug
__hrtimer_function(linux_hrtimer.c:33-40) is the DF systimer callback. It runs in hard-interrupt context on the CPU that owns the systimer (st.gd) and does:c struct taskqueue *tq = taskqueue_thread[mycpuid]; /* :37 β firing CPU */ BUG_ON(taskqueue_enqueue(tq, &timer->task) != 0); /* :39 */hrtimer_start_range_ns(linux_hrtimer.c:87-119) arms that systimer on the calling CPU:systimer_init_oneshot(&timer->st, ..., timer->gd = mycpuviasystimer_init_oneshotβsystimer_add, kern_systimer.c:359-371,368).
So the single shared timer->task is enqueued on the per-CPU thread
taskqueue of whichever CPU last started the timer. A timer that is started
from CPU A and then re-started from CPU B (while A's expiry task has not run
yet) migrates the same struct task between per-CPU taskqueues β exactly
what sys/kern/subr_taskqueue.c:185-188 forbids ("TASKS ARE NOT LOCKED ...
use one task per CPU!").
taskqueue_enqueue_locked (subr_taskqueue.c:205-209):
if (task->ta_pending) {
KKASSERT(queue == task->ta_queue); /* :206 */
task->ta_pending++;
return 0;
}
With INVARIANTS (stock X86_64_GENERIC, sys/config/X86_64_GENERIC:56 β
confirmed #define INVARIANTS 1 in the guest's
/usr/obj/usr/src/sys/X86_64_GENERIC/opt_global.h) this is a guaranteed
panic; without it, ta_pending++ executes on B's queue lock while the task
is linked into A's queue β an unlocked cross-queue race on task state.
This is NOT the known DF-2175 (re-init of an armed systimer corrupting the
systimer queue): in this scenario the previous systimer has already fired
(its task is pending), so systimer_init_oneshot's bzero+add is queue-safe;
the defect is purely on the taskqueue side.
2. Reachability (in-tree callers)
Cross-CPU start of the same hrtimer is legal and used by the Linux drivers the shim serves:
i915_pmu:__i915_pmu_maybe_start_timer()(i915_pmu.c:130-135) callshrtimer_start_range_nsfromi915_pmu_gt_unparked/engine-pmu paths underpmu.lockon any CPU, whilei915_pmu_unregisterteardown (i915_pmu.c:1089) cancels; PMU events schedule on arbitrary CPUs.intel_uncore:fw_domain_arm_timer(intel_uncore.c:70-78) re-arms from forcewake-get paths on any CPU;intel_uncore_forcewake_reset(intel_uncore.c:393) cancels outsideuncore.lock.i915_perf: poll timer started at i915_perf.c:1914 / cancelled at i915_perf.c:1952.amdgpu dce_virtual: dce_virtual.c:709/732/736.
Any of these started from two different CPUs in quick succession (task still pending on the first CPU's queue β easy under load, since the per-CPU taskqueue thread may be busy) hits the assert.
3. PoC and result (deterministic β no race required)
df5176.c (KLD, MODULE_DEPEND(drm)):
taskqueue_block(taskqueue_thread[0])β keeps the fired task pending (enqueue-while-blocked does not wake the queue thread, subr_taskqueue.c:232-237).- Kernel thread pinned to cpu0 (
lwkt_create(..., cpu=0)) callshrtimer_start(1ms)β fire on cpu0 β task pending on q0 (ta_pending=1, verified by the PoC printout). - Kernel thread pinned to cpu1 calls
hrtimer_start(2ms)β legal per the Linux API, and not the DF-2175 shape because the old systimer already fired. - Fire on cpu1 β
__hrtimer_functionβtaskqueue_enqueue(q1, task)withta_queue == q0β KKASSERT panic.
Baseline run (run.log / run.log.full / panic.txt):
[df5176] attempt 1: arming on cpu0 (1ms) [df5176] task ta_pending=1 ta_queue == q0 [df5176] re-arming from cpu1 (2ms) panic with 1 spinlocks held panic: assertion "queue == task->ta_queue" failed in taskqueue_enqueue_locked at /usr/src/sys/kern/subr_taskqueue.c:206 cpuid = 1 taskqueue_enqueue_locked() at taskqueue_enqueue_locked+0x159 taskqueue_enqueue() at taskqueue_enqueue+0x45 __hrtimer_function() at __hrtimer_function+0x21 <-- linux_hrtimer.c:39 systimer_intr() at systimer_intr+0xd6 pcpu_timer_process_oncpu() at pcpu_timer_process_oncpu+0x82
The __hrtimer_function frame directly implicates the shim.
4. Fix validation
fix.diff (shared with DF-5177): pin the task's home taskqueue at
hrtimer_init (timer->tq = taskqueue_thread[mycpuid]) and enqueue on
timer->tq in __hrtimer_function; cancel semantics fixed separately.
Applied inside the guest /usr/src (patch -p1), drm.ko rebuilt and
installed, guest rebooted, PoC rebuilt against the patched header and
re-run (run.fix.log):
[df5176] task ta_pending=0 ta_queue != q0 (task stayed on its pinned home queue) [df5176] FAILED to trigger - no panic (5 attempts) GUEST-STILL-UP
Cross-queue enqueue can no longer occur (coalescing on the pinned queue instead); the panic is gone.
5. Impact
- INVARIANTS kernels (stock GENERIC): deterministic local DoS (panic) from any drm hrtimer restarted cross-CPU while a previous expiry's task is pending β unprivileged-reachable on GPU machines via drm ioctls/perf.
- non-INVARIANTS kernels: unlocked
ta_pending/ta_queuemutation racing the owning CPU's taskqueue thread (state corruption window rather than a clean panic).
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
fixedfix.diff applied inside guest /usr/src (patch -p1 clean), drm.ko rebuilt from patched source and installed to /boot/kernel, guest rebooted into it, PoC rebuilt against the patched header: task stayed on its pinned home queue ('ta_queue != q0', ta_pending=0 coalescing), 5 attempts, NO panic, guest up β the baseline panic is gone
run.fix.log ('[df5176] task ta_pending=0 ta_queue != q0 ... FAILED to trigger - no panic ... GUEST-STILL-UP') vs panic.txt/run.log.full (baseline KKASSERT panic through __hrtimer_function)
Confirmed kernel references
- sys/dev/drm/linux_hrtimer.c:33-40
- sys/dev/drm/linux_hrtimer.c:87-119
- sys/kern/subr_taskqueue.c:185-188
- sys/kern/subr_taskqueue.c:205-209
- sys/kern/kern_systimer.c:359-371
- sys/dev/drm/i915/i915_pmu.c:130-135
- sys/dev/drm/i915/intel_uncore.c:70-78
- sys/dev/drm/i915/intel_uncore.c:393
- sys/dev/drm/i915/i915_perf.c:1914
- sys/dev/drm/amd/amdgpu/dce_virtual.c:709
Detail
Exploit chain
unpriv user on a GPU machine (drm.ko + i915/amdgpu loaded): cause the same hrtimer to be restarted from two different cpus while the first expiry's task is still pending on the first cpu's taskqueue_thread (e.g. i915 pmu sampling timer started from gt-unpark/engine-enable paths on cpu A and again on cpu B under load, intel_uncore.c:70-78 forcewake re-arms, or i915_perf poll timer) -> fire on cpu B -> taskqueue_enqueue_locked KKASSERT(queue == task->ta_queue) -> kernel panic (DoS). On non-INVARIANTS kernels the same path does an unlocked ta_pending++ against a task linked into another cpu's queue, a state-corruption window rather than a clean panic. Root escalation not developed (panic-class primitive).
Evidence (decisive lines)
panic.txt + run.log.full: '[df5176] task ta_pending=1 ta_queue == q0' then after re-arm from cpu1 'panic: assertion "queue == task->ta_queue" failed ... taskqueue_enqueue_locked+0x159 <- taskqueue_enqueue+0x45 <- __hrtimer_function+0x21 <- systimer_intr+0xd6 <- pcpu_timer_process_oncpu+0x82'; run.fix.log: post-fix 'task ta_pending=0 ta_queue != q0 ... FAILED to trigger - no panic ... GUEST-STILL-UP'; env.txt: INVARIANTS=1, X86_64_GENERIC, 6 cpus, drm.ko loaded
PoC changes
PoC written from scratch (no seed): KLD with MODULE_DEPEND(drm); v1 migrated the kldload user thread via lwkt_setcpu_self which wedged the guest scheduler, v2 uses dedicated per-cpu kernel threads created with lwkt_create(..., TDF_NOSTART, cpuid) + lwkt_schedule; DF's DECLARE_MODULE requires a moduledata_t VARIABLE (passing the handler directly makes the kernel strcmp() machine-code bytes as the module name β fixed by following the selffree.c pattern); taskqueue_block(taskqueue_thread[0]) keeps the fired task deterministically pending so no race is needed
Verified recommended fix
Pin the task's home queue for the timer's lifetime: add struct taskqueue *tq to struct hrtimer, set timer->tq = taskqueue_thread[mycpuid] in hrtimer_init, and make __hrtimer_function enqueue on timer->tq instead of the firing cpu's queue (see fix.diff)
Verdict
The LinuxKPI hrtimer shim enqueues the single shared timer->task on the FIRING cpu's taskqueue_thread[mycpuid] (linux_hrtimer.c:37) while (re-)arming the underlying systimer on the CALLING cpu (linux_hrtimer.c:115 / kern_systimer.c:368), so restarting one hrtimer from two different cpus migrates the same struct task between per-cpu taskqueues β explicitly forbidden by subr_taskqueue.c:185-188 because tasks are not locked. Deterministically reproduced on the guest with a KLD driving the exact sequence (block q0, start from cpu0, fire, start from cpu1, fire): panic 'assertion "queue == task->ta_queue" failed in taskqueue_enqueue_locked at /usr/src/sys/kern/subr_taskqueue.c:206' with __hrtimer_function in the backtrace, on the first attempt. Distinct from known DF-2174 (epilogue active-flag clobber) and DF-2175 (re-init of an ARMED systimer): here the previous systimer already fired, the bzero+add is queue-safe, and the defect is the cross-queue task migration. fix.diff (pin timer->tq at hrtimer_init; enqueue on the pinned queue) rebuilt as drm.ko inside the guest eliminates the panic (5 attempts, 'FAILED to trigger - no panic', guest stays up).
No comments yet.