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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2872 Β· 12 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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)

  1. create q1, TIMEOUT_TASK_INIT, taskqueue_enqueue_timeout(q1, &tt, 5).
  2. taskqueue_free(q1) β€” tq_callouts==1, ignored; queue kfree'd.
  3. Immediately taskqueue_create() (same zone, same size, objcache LIFO): q2 lands on q1's exact address (iteration 0; printed alias YES).
  4. 5 ticks later the callout fires: tt.t.ta_queue == freed q1 == live q2; the kernel enqueues tt onto q2 (a queue that never saw it), q2's worker executes tt_func β€” on the wrong, reallocated-from-freed queue.
  5. 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

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.

['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 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.