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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2869 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 returnEBUSYfor the running task, andtaskqueue_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).
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)
taskqueue_start_threads(&tq, 2, ...)β an API-supported configuration (subr_taskqueue.c:548, thread distribution across CPUs at :580-581).- Worker A dequeues T1 (slow func: blocks on a flag).
tq_running = T1. - Worker B dequeues T2 (instant). On completion B executes
queue->tq_running = NULL; wakeup(T2);β while T1 is still running. taskqueue_cancel(tq, &T1)returns 0 (should be EBUSY) βT1 != tq_running(NULL).taskqueue_drain(tq, &T1)returns immediately β same check at :493.- Caller frees T1's context (the documented pattern), kfree + kmalloc the same size (objcache LIFO β same address, printed "alias YES").
- Controller pokes
ctx->release(UAF write); T1 resumes and readsctx->magic == 0xdeadbeefβpanic("DF-2869: ...").
Evidence
run.log/panic.txt: the full sequence above, includingtquaf: taskqueue_cancel(t1) = 0 while t1 runningandtquaf: BUG PROVEN: taskqueue_drain(t1) returned while t1 ta_func still executingand the panic trace throughtaskqueue_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>=2queue: 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
fixedCombined 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']
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.
No comments yet.