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

tq_running single-slot with unlocked clear breaks taskqueue_cancel/drain under concurrent runners β†’ premature-free UAF

Field Value
ID DF-2869
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
CWE CWE-416 (via CWE-362)
File sys/kern/subr_taskqueue.c
Lines 407, 411, 429, 493, 512
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

taskqueue_run() tracks the executing task in ONE queue->tq_running slot, set at dequeue and cleared OUTSIDE the queue spinlock. With β‰₯2 concurrent runners on one queue (API-supported countβ‰₯2; vmxnet3 in-tree uses nthreadsβ‰₯2 and drains at detach), completion of task B nulls tq_running while task A is still executing, so taskqueue_cancel_locked returns 0 instead of EBUSY and taskqueue_drain/drain_simple return immediately while ta_func(A) is on-CPU. The documented cancel/drain-then-free contract then frees A's task/context β†’ use-after-free read+write from inside the taskqueue runner; slab-groomable free-while-running corruption.

Proof of contest

VERIFIED (findings/poc/DF-2869/tquaf.ko): 2-thread queue; T1's func blocks, T2 completes (tq_running=NULL while T1 runs); taskqueue_cancel(t1)=0 (should be EBUSY); taskqueue_drain returns while func still executing ("BUG PROVEN"); kfree + same-size kmalloc (objcache LIFO address alias YES); running func reads magic=0xdeadbeef → panic with trace taskqueue_thread_loop→taskqueue_run→t1_func. Fix (per-task TASKQ_RUNNING flag set/cleared under TQ_LOCK, wakeup after unlock) validated: cancel=EBUSY, drain blocks, clean unload.

Validated fix.diff in findings/poc/DF-2869/ (combined with DF-2870/2872/2873 fixes).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_taskqueue.c (GLM 5.3); KLD UAF panic reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2869 Β· 13 files
FileTypeDescriptionSize
tquaf.c β€” 5.9 KB view raw
Makefile β€” 67 B ↓ download
build.sh β€” 314 B view raw
run.sh β€” 194 B view raw
build.log β€” 5.7 KB view raw
run.log β€” 1.1 KB view raw
panic.txt β€” 933 B view raw
env.txt β€” 199 B view raw
fix.diff β€” 4.4 KB view raw
run.fixed.log β€” 407 B view raw
VERDICT.md β€” 3.6 KB ↓ raw
README.md β€” 2.5 KB ↓ raw
verdict.json β€” 5.5 KB view raw

DF-2869 β€” taskqueue tq_running single-slot race β†’ cancel/drain miss running task β†’ UAF

What

taskqueue_run() (sys/kern/subr_taskqueue.c) tracks the currently-executing task in the single queue->tq_running slot and clears it outside the queue spinlock. With β‰₯2 concurrent runners on one queue the completion of task B clobbers/clears tq_running while task A is still executing, so:

  • taskqueue_cancel() fails to return EBUSY for the running task, and
  • taskqueue_drain() / taskqueue_drain_simple() return immediately

while ta_func(A) is still on the CPU. A caller following the documented cancel/drain-then-free contract then frees A's task/context β†’ use-after-free (read + write) executed from inside the taskqueue runner.

Concurrent runners exist in-tree: taskqueue_start_threads(count>=2) is an API-supported configuration (vmxnet3 uses nthreads = ntxqueues/2 β‰₯ 2, sys/dev/virtual/vmware/vmxnet3/if_vmx.c:990-997, and drains those tasks at if_vmx.c:1012), and taskqueue_free() co-runs the queue with the service thread (subr_taskqueue.c:155 racing the worker's run loop).

Build (on the guest as root)

cd /root/poc/tquaf
ln -sf /usr/obj/usr/src/sys/X86_64_GENERIC/device_if.h .
ln -sf /usr/obj/usr/src/sys/X86_64_GENERIC/bus_if.h .
make -m /usr/share/mk SYSDIR=/usr/src/sys

Run

kldload /root/poc/tquaf/tquaf.ko
sleep 6
dmesg | grep tquaf        # then read the serial console for the panic

Expected output (stock kernel β€” reproduced)

tquaf: queue 0xfffff8011756aec8 started (0)
tquaf: taskqueue_cancel(t1) = 0 while t1 running (0 = contract violated, EBUSY=16 expected)
tquaf: BUG PROVEN: taskqueue_drain(t1) returned while t1 ta_func still executing
tquaf: freeing live task context 0xfffff8008d2e0810
tquaf: freed ctx reallocated at 0xfffff8008d2e0810 (alias YES) magic=deadbeef
tquaf: UAF CONFIRMED: running task sees ctx 0xfffff8008d2e0810 magic=deadbeef (expected cafebabe)
panic: DF-2869: task context freed while task running (magic=deadbeef)
Trace: t1_func() at t1_func+0x9b
       taskqueue_run() at taskqueue_run+0xbb
       taskqueue_thread_loop() at taskqueue_thread_loop+0x5d

The panic backtrace is the proof: taskqueue_run β†’ t1_func executing on memory that was freed and reallocated (objcache LIFO aliasing forced by the harness).

Fixed kernel

taskqueue_cancel returns EBUSY, taskqueue_drain blocks until the func completes β†’ harness prints "NOT REPRODUCED: drain blocked correctly", no panic (see fix.diff / run.fixed.log).

VERDICT.md
↓ download raw

DF-2869 VERDICT β€” REPRODUCED (panic; memory-corruption primitive proven)

One-line

The single tq_running slot (set at subr_taskqueue.c:407, cleared outside the lock at :411) makes taskqueue_cancel (:429) and taskqueue_drain (:493) blind to concurrently-running tasks whenever β‰₯2 threads execute taskqueue_run() on one queue; the documented cancel/drain-then-free contract then frees a task whose ta_func is still executing β€” proven as a deterministic use-after-free with a panic backtrace taskqueue_thread_loop β†’ taskqueue_run β†’ t1_func on freed-and-reallocated memory.

How it reproduces (100% deterministic, no timing needed)

  1. taskqueue_start_threads(&tq, 2, ...) β€” an API-supported configuration (subr_taskqueue.c:548, thread distribution across CPUs at :580-581).
  2. Worker A dequeues T1 (slow func: blocks on a flag). tq_running = T1.
  3. Worker B dequeues T2 (instant). On completion B executes queue->tq_running = NULL; wakeup(T2); β€” while T1 is still running.
  4. taskqueue_cancel(tq, &T1) returns 0 (should be EBUSY) β€” T1 != tq_running(NULL).
  5. taskqueue_drain(tq, &T1) returns immediately β€” same check at :493.
  6. Caller frees T1's context (the documented pattern), kfree + kmalloc the same size (objcache LIFO β‡’ same address, printed "alias YES").
  7. Controller pokes ctx->release (UAF write); T1 resumes and reads ctx->magic == 0xdeadbeef β†’ panic("DF-2869: ...").

Evidence

  • run.log / panic.txt: the full sequence above, including tquaf: taskqueue_cancel(t1) = 0 while t1 running and tquaf: BUG PROVEN: taskqueue_drain(t1) returned while t1 ta_func still executing and the panic trace through taskqueue_run+0xbb β†’ t1_func+0x9b.

Primitive characterization (write-capable bug)

The premature-free gives the attacker's teardown path a "free a live kernel object while a CPU is still executing its handler" primitive. In the harness the victim is a 16-byte kmalloc'd context; in real consumers the victim is driver-owned state (e.g. vmxnet3's tx queue drained at if_vmx.c:1012 while its deferred-rx/tx taskqueue runs β‰₯2 threads, if_vmx.c:990-997). The reallocated memory is read AND written by the running handler before it returns (entered/release/magic), i.e. a slab-groomable UAF read/write of task-owned objects. Escalation to uid=0 requires a consumer whose task context embeds a pointer that the still-running handler dereferences or stores through after the free; with heap grooming (same-zone reallocation) this is the standard "free-while-running" corruption family. No unprivileged in-tree trigger exists on this guest (vmxnet3 teardown needs root; the queue must be a countβ‰₯2 or a free-racing queue), so the uid0 chain was not exercised end-to-end β€” that is a hard blocker of reach, not of the primitive, which is proven here.

Who else co-runs a queue (reachability)

  • Any count>=2 queue: API-documented, vmxnet3 in-tree.
  • taskqueue_free() itself co-runs the queue with the worker (subr_taskqueue.c:155 calls taskqueue_run while the worker may be inside its own run loop) β€” every thread-based taskqueue gets transient multi-runner windows during teardown.
  • SWI queues are safe from this variant (single global ithread dispatches them, kern_intr.c sched_ithd_soft).

Fix validation

Combined fix (fix.diff in this pack): per-task TASKQ_RUNNING flag set at dequeue and cleared under TQ_LOCK; wakeup(task) after unlock; cancel/drain consult the flag. On the fixed kernel: taskqueue_cancel returns EBUSY(16), taskqueue_drain blocks until the func completes, the harness takes its "NOT REPRODUCED: drain blocked correctly" path, no panic. See run.fixed.log.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Combined fix.diff (per-task TASKQ_RUNNING under TQ_LOCK + wakeup after unlock in taskqueue_run; ACTIVE re-check before worker park; tq_callouts wait in taskqueue_free; timeout_func freer wakeup) built as kernel #1 Wed Sep 2 17:34:00. Baseline bad behavior GONE on every PoC: DF-2869 cancel=EBUSY + drain blocks + no UAF/panic + clean unload; DF-2870 taskqueue_free returns, no wedged threads; DF-2872 timeout task no longer runs after free; DF-2873 hammer does not lose a wakeup (bounded negative). Guest left clean via vm.sh reset with-src. NOTE: one intermediate fixed-kernel run crashed because the module had been rebuilt against the STOCK ABI while struct task grew by ta_flags - module-build logistics, not a fix defect; final run used modules built against the patched /usr/obj.

['run.fixed.log']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 17:34:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

proven: 2-runner queue -> tq_running clobbered -> cancel=0 + drain early-return -> kfree(live ctx) + same-size kmalloc (LIFO alias) -> running ta_func reads/writes foreign object (magic flip observed) -> controlled panic. uid0 route (not exercised, reach-blocked): vmxnet3-style count>=2 consumer torn down under rx/tx load -> premature free of driver softc during detach drain -> slab groom -> function-pointer corruption -> code exec in ring0.

Evidence (decisive lines)

["run.log/panic.txt: 'taskqueue_cancel(t1) = 0 while t1 running' + 'taskqueue_drain(t1) returned while t1 ta_func still executing'", "run.log/panic.txt: 'freed ctx reallocated at 0xfffff8008d2e0810 (alias YES) magic=deadbeef' + 'UAF CONFIRMED'", 'panic.txt: panic trace t1_func+0x9b <- taskqueue_run+0xbb <- taskqueue_thread_loop+0x5d', "run.fixed.log: 'taskqueue_cancel(t1) = 16' + releaser observes drain() blocking + clean unload, no panic"]

PoC changes

Baseline evidence was produced with the initial harness (identical bug path); the final source adds a releaser thread + clean-shutdown path so the FIXED kernel run terminates without wedging (first fixed-kernel attempt left the controller blocked inside the now-correctly-blocking drain and kldunload crashed - harness flaw, not a fix failure, documented in VERDICT.md). Also: taskqueue_start_threads name must not carry %d (auto-suffixed), DECLARE_MODULE/MODULE_VERSION modules GPF this guest's kldload - DEV_MODULE used instead (see deploy_guest.sh).

Verified recommended fix

Track running tasks with a per-task TASKQ_RUNNING flag set at dequeue and cleared under the queue spinlock (wakeup after unlock); make taskqueue_cancel/taskqueue_drain(_simple) consult the flag instead of the single tq_running slot.

Verdict

Deterministic premature-free UAF proven on the stock INVARIANTS kernel: with two threads servicing one taskqueue (API-supported count>=2), completion of a fast task clears the single tq_running slot while a slow task is still executing, so taskqueue_cancel() returns 0 instead of EBUSY and taskqueue_drain() returns immediately (subr_taskqueue.c:407/:411/:429/:493). The harness then follows the documented cancel/drain-then-free contract, kfree()s the running task's context, forces objcache-LIFO reallocation of the same address (printed alias YES), and the still-running ta_func reads the overwritten memory (magic 0xdeadbeef) -> panic with the backtrace taskqueue_thread_loop -> taskqueue_run -> t1_func, i.e. the taskqueue runner executing on freed-and-reused kernel memory. Primitive: slab-groomable free-while-running (UAF read+write of task-owned context of attacker-chosen consumer objects). No unprivileged in-tree trigger on this guest: the co-runner precondition needs a count>=2 queue (in-tree: vmxnet3 if_vmx.c:990-997, torn down by an admin) or the taskqueue_free() co-run window (subr_taskqueue.c:155); reaching the victim task from userspace requires such a consumer under attack-driven load. That is a reach blocker, not a primitive blocker - the corruption itself is proven. uid0 chain therefore not exercised end-to-end; the natural chain is: drive victim driver's multi-thread queue under load, race its detach drain, free its per-queue softc while the deferred task runs, reallocate same-zone, corrupt function-pointer-bearing state, hijack. fix.diff (per-task TASKQ_RUNNING under TQ_LOCK + wakeup after unlock) validated: cancel returns EBUSY=16, drain blocks until ta_func completes, no panic, module unloads cleanly.