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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5176 Β· 14 files
FileTypeDescriptionSize
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 KLD
  • build.log / run.log / run.log.full / panic.txt β€” baseline evidence
  • run.fix.log β€” fixed-kernel rerun (no panic)
  • fix.diff β€” one coherent fix for DF-5176 + DF-5177 (pins the home taskqueue at hrtimer_init; makes hrtimer_cancel loop until quiescent with the token held across systimer_del; drops the running=false lie from hrtimer_start)
  • env.txt β€” guest kernel/INVARIANTS/kldstat
  • VERDICT.md, manifest.json, verdict.json β€” full narrative + machine verdict
VERDICT.md
↓ download raw

DF-5176 VERDICT β€” REPRODUCED (deterministic panic)

1. The bug

sys/dev/drm/linux_hrtimer.c:

  • __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 = mycpu via systimer_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) calls hrtimer_start_range_ns from i915_pmu_gt_unparked/engine-pmu paths under pmu.lock on any CPU, while i915_pmu_unregister teardown (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 outside uncore.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)):

  1. taskqueue_block(taskqueue_thread[0]) β€” keeps the fired task pending (enqueue-while-blocked does not wake the queue thread, subr_taskqueue.c:232-237).
  2. Kernel thread pinned to cpu0 (lwkt_create(..., cpu=0)) calls hrtimer_start(1ms) β†’ fire on cpu0 β†’ task pending on q0 (ta_pending=1, verified by the PoC printout).
  3. 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.
  4. Fire on cpu1 β†’ __hrtimer_function β†’ taskqueue_enqueue(q1, task) with ta_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_queue mutation 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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)
↓ fix.diffper-fix-DF-5176

Confirmed kernel references

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