taskqueue_free() ignores the write-only tq_callouts counter: armed timeout callout fires into freed/reallocated queue (UAF)
| Field | Value |
|---|---|
| ID | DF-2872 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:L/I:L/A:H |
| CWE | CWE-416 Use After Free |
| File | sys/kern/subr_taskqueue.c |
| Lines | 150-166, 323-338, 359 |
| 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
queue->tq_callouts is incremented at :359 and decremented at :335/:480 but READ nowhere β taskqueue_free() never verifies it is zero. Freeing a queue with an armed timeout_task callout leaves a live callout whose taskqueue_timeout_func() dereferences timeout_task->t.ta_queue pointing at freed memory: locks the freed spinlock, decrements tq_callouts, and STAILQ-inserts its task into the freed (reallocated) queue β silent cross-object kernel heap corruption. In-tree hazard: wlan's scan timeout task sits on ic_tq which is taskqueue_free'd without cancel by ieee80211_scan_detach() β interface destroy during a scan arms exactly this UAF. VERIFIED (findings/poc/DF-2872/tqto.ko): freed queue's address re-landed by taskqueue_create (objcache LIFO alias YES), stray callout fired and the timeout task RAN on the reallocated live queue ("REALIASED (FREED) QUEUE: UAF CONFIRMED"). Fix (free sleeps on &queue->tq_callouts until 0; late enqueues EPIPE) validated: alias still YES but no post-free run.
Recommended fix
Validated fix.diff in findings/poc/DF-2872/.
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_taskqueue.c (GLM 5.3); reallocation UAF reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2872 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| tqto.c | β | 3.7 KB | view raw | |
| Makefile | β | 65 B | β download | |
| build.sh | β | 313 B | view raw | |
| run.sh | β | 123 B | view raw | |
| build.log | β | 5.7 KB | view raw | |
| run.log | β | 794 B | view raw | |
| env.txt | β | 199 B | view raw | |
| fix.diff | β | 4.4 KB | view raw | |
| run.fixed.log | β | 387 B | view raw | |
| VERDICT.md | β | 2.5 KB | β raw | |
| README.md | β | 2.1 KB | β raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-2872 β taskqueue_free() ignores armed timeout callouts β post-free UAF
What
taskqueue_free() never consults queue->tq_callouts (the counter is
write-only in the whole file: incremented at subr_taskqueue.c:359,
decremented at :335/:480, read nowhere). If a queue is freed while a
timeout_task callout is armed, the callout fires later and
taskqueue_timeout_func() (:323-338) dereferences
timeout_task->t.ta_queue, which points at the freed queue: it takes
the freed queue's spinlock, decrements its tq_callouts, and STAILQ-inserts
the task into the freed (here: reallocated, LIVE) queue.
The harness arms a 5-tick callout, frees the queue, then forces a new taskqueue to alias the freed memory (objcache LIFO, same zone/size). The stray callout then enqueues the timeout task onto the NEW LIVE queue, which executes it β decisive cross-object use-after-free.
In-tree hazard: wlan's scan timeout task lives on ic->ic_tq
(ieee80211_scan_sw.c:1023) and ic->ic_tq is taskqueue_free'd at
ieee80211.c:464; ieee80211_scan_detach() never cancels it.
Build / Run
see build.sh / run.sh
Expected output (stock kernel β reproduced)
tqto: step3 armed 5-tick callout (res 0); freeing q1 WITHOUT cancel_timeout
tqto: step4 q1 FREED while callout armed; reallocating...
tqto: step5 alias loop done i=0 q2=0xfffff80117bcd180
tqto: q2=0xfffff80117bcd180 alias of freed q1: YES
tqto: TIMEOUT TASK RAN (run 1) on queue 0xfffff80117bcd180
(q1 was 0xfffff80117bcd180, q2 is 0xfffff80117bcd180)
<== REALIASED (FREED) QUEUE: UAF CONFIRMED
Writes performed by the kernel into freed-then-reallocated memory during
this run: q->tq_callouts-- (live q2 corrupted to -1), f &= ~ARMED,
STAILQ_INSERT_TAIL(&q->tq_queue, ...) into q2's list, spin_lock on q2's
tq_lock.
Fixed kernel
taskqueue_free() waits for tq_callouts == 0 after clearing TQ_FLAGS_ACTIVE; the callout fires while the queue is still allocated and its enqueue is rejected with EPIPE β the timeout task never runs after free (harness prints "callout did not run in 5s?!" β which on the fixed kernel is exactly the correct outcome). See run.fixed.log.
DF-2872 VERDICT β REPRODUCED (post-free UAF via armed timeout callout)
One-line
taskqueue_free() (subr_taskqueue.c:150-166) never checks
queue->tq_callouts β the counter it exists for is write-only (:359
increment, :335/:480 decrement, zero readers) β so freeing a queue with an
armed timeout_task callout leaves a live callout pointing at freed
memory: when it fires, taskqueue_timeout_func() (:323-338) locks the
freed queue's spinlock, decrements its tq_callouts, and STAILQ-inserts
its task into the freed queue.
How it reproduces (100% deterministic)
- create q1,
TIMEOUT_TASK_INIT,taskqueue_enqueue_timeout(q1, &tt, 5). taskqueue_free(q1)β tq_callouts==1, ignored; queue kfree'd.- Immediately
taskqueue_create()(same zone, same size, objcache LIFO): q2 lands on q1's exact address (iteration 0; printed alias YES). - 5 ticks later the callout fires:
tt.t.ta_queue== freed q1 == live q2; the kernel enqueuesttonto q2 (a queue that never saw it), q2's worker executestt_funcβ on the wrong, reallocated-from-freed queue. - Additional writes into the freed-then-reused object during the callout:
q->tq_callouts--(live q2 corrupted to -1),f &= ~DT_CALLOUT_ARMED,STAILQ_INSERT_TAIL(&q->tq_queue, ...),spin_lock(&q->tq_lock).
Evidence
run.log:alias of freed q1: YES+TIMEOUT TASK RAN (run 1) on queue 0xfffff80117bcd180 (q1 was ...180, q2 is ...180) <== REALIASED (FREED) QUEUE: UAF CONFIRMED.- No panic was coaxed (the corrupted fields are not lethal in this shape), so impact is recorded dos-class with the write primitives enumerated above β same convention as DF-2848 (silent heap corruption).
In-tree hazard
wlan: TIMEOUT_TASK_INIT(ic->ic_tq, &ss->ss_scan_curchan, ...)
(ieee80211_scan_sw.c:1023) on the queue that is taskqueue_free'd at
ieee80211.c:464; ieee80211_scan_detach() never cancels it. A scan in
progress at interface destroy arms exactly this UAF (with the scan state
itself also freed at scan_detach β a second UAF on the callout argument).
Fix validation
fix.diff: taskqueue_free() waits for tq_callouts == 0 after clearing
TQ_FLAGS_ACTIVE (completing callouts then fail their enqueue with EPIPE),
and taskqueue_timeout_func() wakes the freer when the count reaches 0.
On the fixed kernel the harness reports the timeout task did NOT run after
free ("callout did not run in 5s?!" β the correct fixed behavior) and
unloads cleanly. 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.
['run.fixed.log']
Confirmed kernel references
- sys/kern/subr_taskqueue.c:62
- sys/kern/subr_taskqueue.c:150
- sys/kern/subr_taskqueue.c:155
- sys/kern/subr_taskqueue.c:165
- sys/kern/subr_taskqueue.c:323
- sys/kern/subr_taskqueue.c:330
- sys/kern/subr_taskqueue.c:335
- sys/kern/subr_taskqueue.c:359
- sys/netproto/802_11/wlan/ieee80211.c:464
- sys/netproto/802_11/wlan/ieee80211_scan_sw.c:1023
Detail
Exploit chain
proven corruption: callout -> freed queue object -> same-zone reallocation -> tq_callouts-- / flag RMW / STAILQ_INSERT_TAIL into live victim object. Groomable to stronger corruption if the freed queue's memory is reused by a function-pointer-bearing object (taskqueue_create is only the demonstration vehicle).
Evidence (decisive lines)
["run.log: 'q2=0xfffff80117bcd180 alias of freed q1: YES'", "run.log: 'TIMEOUT TASK RAN (run 1) on queue ...180 <== REALIASED (FREED) QUEUE: UAF CONFIRMED'", "run.fixed.log: alias still YES but 'callout did not run in 5s' (correct fixed behavior) + clean unload"]
PoC changes
none beyond the DEV_MODULE registration fix (DECLARE_MODULE modules GPF this guest's kldload)
Verified recommended fix
taskqueue_free() must wait for queue->tq_callouts == 0 (after clearing TQ_FLAGS_ACTIVE so late callout enqueues get EPIPE) and taskqueue_timeout_func() must wake the waiter when the count reaches zero.
Verdict
Deterministic post-free use-after-free: taskqueue_free() (subr_taskqueue.c:150-166) never reads queue->tq_callouts - the counter is write-only in the entire file (:359 ++, :335/:480 --), so freeing a queue with an armed timeout_task callout leaves a live callout whose taskqueue_timeout_func() (:323-338) then locks the FREED queue's spinlock, decrements its tq_callouts, and STAILQ-inserts its task into the freed queue. Guest proof: arm a 5-tick callout on q1, free q1, immediately taskqueue_create() (same zone/size, objcache LIFO) - q2 lands on q1's exact address (alias YES at iteration 0); the stray callout fires and executes the timeout task ON the reallocated live q2 (printed), i.e. the kernel wrote q->tq_callouts-- (q2 corrupted to -1), cleared f flags and linked the task into q2's list through freed-then-reused memory. Cross-object heap corruption family; no panic coaxed (fields not lethal in this shape), hence dos-class impact with the write primitives enumerated. In-tree hazard: wlan's scan timeout task (ieee80211_scan_sw.c:1023) lives on ic_tq which is taskqueue_free()d at ieee80211.c:464 and is never cancelled by ieee80211_scan_detach(). fix.diff (free waits for tq_callouts==0 after clearing ACTIVE; timeout_func wakes the freer at 0; late enqueues rejected with EPIPE) validated: timeout task does NOT run after free, module unloads cleanly.
No comments yet.