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

taskqgroup_detach() unsynchronized lifecycle tail: gt_taskqueue=NULL window panics racing enqueuers; unlocked TASK_NOENQUEUE clear can lose the TASK_ENQUEUED bit and corrupt the queue list

Field Value
ID DF-2849
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:L/A:H
CWE CWE-362 / CWE-476
File sys/kern/subr_gtaskqueue.c
Lines 746-747 (racing sites :203-207/:218-219)
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

taskqgroup_detach() ends with gtask->gt_taskqueue = NULL followed by a NON-ATOMIC, UNLOCKED ta_flags &= ~TASK_NOENQUEUE. (1) A racer calling plain GROUPTASK_ENQUEUE() during the NULL window reaches the INVARIANTS panic("queue == NULL") β€” reproduced on the stock INVARIANTS guest (~897k window-hits per 30s of churn; non-INVARIANTS: TQ_LOCK(NULL) β†’ kernel page fault). (2) The unlocked RMW can lose a concurrently-set TASK_ENQUEUED bit while the gtask is linked in the STAILQ, so the next enqueue double-inserts it β†’ STAILQ self-loop β†’ the queue worker re-runs the task forever (code-certain race, not observed). Zero in-tree callers today β€” latent, DF-0085 family.

Keep TASK_NOENQUEUE sticky across detach, re-arm under the queue lock in attach, make enqueue NULL-safe β€” validated 4-hunk fix.diff in findings/poc/DF-2849/.

Timeline

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2849 Β· 16 files
FileTypeDescriptionSize
gtq_null.c β€” 2.0 KB view raw
gtq_race.c β€” 3.7 KB view raw
gtq_race2.c β€” 3.5 KB view raw
build.sh β€” 202 B view raw
run.sh β€” 570 B view raw
build.log β€” 93 B view raw
run.log β€” 852 B view raw
run.2.log β€” 1.3 KB view raw
run.fixed.log β€” 292 B view raw
panic.txt β€” 544 B view raw
fixbuild.log β€” 220 B view raw
env.txt β€” 1.3 KB view raw
fix.diff β€” 1.9 KB view raw
VERDICT.md β€” 4.0 KB ↓ raw
README.md β€” 2.7 KB ↓ raw
verdict.json β€” 4.6 KB view raw

DF-2849 β€” taskqgroup_detach() unsynchronized lifecycle tail: enqueue-vs-detach race (NULL-window panic + lost-update flag corruption) =====================================================================

WHAT taskqgroup_detach() (sys/kern/subr_gtaskqueue.c:731-748) leaves the grouptask in an enqueue-able-but-inconsistent state: - line 746: gtask->gt_taskqueue = NULL; - line 747: gtask->gt_task.ta_flags &= ~TASK_NOENQUEUE; / UNLOCKED / The flag clear is a non-atomic read-modify-write OUTSIDE the taskqueue lock, racing grouptaskqueue_enqueue()'s locked ta_flags |= TASK_ENQUEUED (line 218-219).

Failure modes of the same root cause: (1) racer enqueues in the gt_taskqueue==NULL window: INVARIANTS panic("queue == NULL") (lines 203-207) -- REPRODUCED AND CAPTURED (panic.txt); on non-INVARIANTS kernels TQ_LOCK(NULL) -> lockmgr on NULL-derived address -> kernel trap. (2) lost update: detach's unlocked RMW clobbers TASK_ENQUEUED set concurrently under the lock while the gtask IS linked in the STAILQ -> next enqueue double-inserts -> STAILQ self-loop -> queue worker re-runs the task forever / queue corruption. (Code-proven window; NOT empirically observed - see VERDICT.md.)

BUILD (on the guest, as root; modules in /root/poc or /root/gtq_race) 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 . for m in null race race2; do printf "KMOD=gtq_$m\nSRCS=gtq_$m.c\n.include \n" > Makefile.$m make -f Makefile.$m -m /usr/share/mk SYSDIR=/usr/src/sys done

RUN (each module is a standalone KLD) kldload gtq_null.ko # mode 1: expect panic within seconds (stock) kldload gtq_race.ko # 1 racer, 200k cycles; kldunload prints stats kldload gtq_race2.ko # 4 racers/8 tasks: HEAVY - starves userland # for the whole churn (minutes); see run.2.log

EXPECTED (stock kernel) gtq_null: panic "queue == NULL" with backtrace through grouptaskqueue_enqueue() <- racer() (see panic.txt) gtq_race: stats line; post-stop fn_count frozen (no corruption in 200k cycles at 1 racer - the mode-2 window is ~2 cycles). FIXED kernel: gtq_null churns indefinitely without panic; gtq_race prints 'no post-stop growth'; clean kldunload.

FILES gtq_null.c mode-1 trigger (racer + attach/detach churn) [primary] gtq_race.c lightweight stats churn (1 racer, 200k cycles) gtq_race2.c high-contention corruption hunter (4x8, yields) panic.txt captured stock-kernel panic (serial console) run.log run.2.log run.fixed.log fixbuild.log fix.diff manifest.json verdict.json env.txt

VERDICT.md
↓ download raw

VERDICT β€” DF-2849 (taskqgroup_detach unsynchronized lifecycle tail)

REPRODUCED: YES β€” mode (1), race-triggered kernel panic, captured on the stock INVARIANTS guest kernel (DragonFly 6.5-DEVELOPMENT #0).

HOW

gtq_null.ko runs a kernel thread pinned to cpu1 doing exactly what any in-tree-style interrupt handler would do β€” GROUPTASK_ENQUEUE(&gt), i.e. grouptaskqueue_enqueue(gt.gt_taskqueue, &gt.gt_task) β€” against a detacher thread on cpu2 doing taskqgroup_attach()/taskqgroup_detach() churn on a privately created 2-queue group. Within seconds the racer calls enqueue while taskqgroup_detach() is between

gtask->gt_taskqueue = NULL;            /* subr_gtaskqueue.c:746 */

and the next attach, and the kernel panics (panic.txt, serial console):

gtask: 0xffffffff82601280 ta_flags=0 ta_priority=0 ta_func=0xffffffff82601000 ta_context=0
panic: queue == NULL
cpuid = 1
grouptaskqueue_enqueue() at grouptaskqueue_enqueue+0x11d
racer() at racer+0x2f

That is the INVARIANTS check at subr_gtaskqueue.c:203-207. On a production (non-INVARIANTS) kernel the same interleaving executes TQ_LOCK(NULL) -> lockmgr(&((struct gtaskqueue *)NULL)->tq_lock, ...) -> kernel page fault. A race-triggered kernel panic/DoS.

Mode (2) β€” the unlocked ta_flags &= ~TASK_NOENQUEUE RMW at line 747 losing an update against the locked ta_flags |= TASK_ENQUEUED at line 219, leaving a linked task with TASK_ENQUEUED clear so the next enqueue double-inserts it (STAILQ self-loop; worker re-runs the task forever, starving the queue) β€” is proven possible by the code (the RMW runs with no lock; both sites touch the same uint16_t) but was NOT empirically observed: the window is ~2 CPU cycles and 200k-2M attach/detach cycles at up to 4 concurrent racers either completed clean (run.log: fn_count frozen post-stop) or starved the guest for the harness's own reasons before printing (see below). Reported as a certain code defect / speculative empirical trigger.

HARNESS CAVEAT (for anyone re-verifying)

The 4-racer gtq_race2 churn WEDGES the guest by itself: pinned LWKT daemon-priority threads spinning in while (!stop) starve user and protocol threads on their CPUs (lwkt_yield() only yields to equal-priority threads). Both stock- and fixed-kernel race2 runs ended in an ssh-dead but QEMU-alive (233-466% CPU) guest with NO panic on serial β€” these runs are harness starvation, NOT bug evidence, and must not be counted either way. The decisive runs are gtq_null (stock: panic in seconds) and gtq_race (lightweight, completes).

FIX VALIDATION

fix.diff (4 hunks): - grouptaskqueue_enqueue(): NULL queue -> return EINVAL instead of INVARIANTS-panic / NULL deref. - taskqgroup_detach(): keep TASK_NOENQUEUE set while detached (drop the unlocked clear entirely). - taskqgroup_attach()/taskqgroup_attach_cpu(): re-arm the flag under the taskqueue lock, inside the tqg_lock-protected assignment. Kernel rebuilt and installed in-guest (#1: Wed Sep 2 09:11:43 UTC 2026, fixbuild.log BUILD_OK). On the fixed kernel: - gtq_null churn: 25s + 120s soak + later 15+ min inside the race2 soak with zero panic (mode 1 closed). - gtq_race full run (run.2.log tail): 200k cycles, enq_ok=1685324, enq_eagain=1807005, enq_null=1086196, fn_count 6109 -> 6109 (frozen post-stop), clean kldunload, guest up. Mode 2 is closed by construction: every ta_flags mutation now happens under the owning taskqueue lock, and the single TASK_ENQUEUED gate cannot be lost while the task is linked.

EXPLOIT CHAIN: none beyond panic/DoS β€” the API has zero in-tree consumers (verified), so both the trigger and any corruption follow from root-loaded KLD misuse or future adoption; no unprivileged path.

FILES: panic.txt (decisive), run.log, run.2.log, run.fixed.log, fixbuild.log, gtq_null.c, gtq_race.c, gtq_race2.c, fix.diff, manifest.json, verdict.json. Kernel refs: sys/kern/subr_gtaskqueue.c:746-747 (defect), :218-219 (racing locked RMW), :203-207 (panic), :200-224 (enqueue).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (4 hunks) applied in-guest, kernel rebuilt/installed (#1 Wed Sep 2 09:11:43 UTC 2026). Exact PoC re-run on fixed kernel: gtq_null churn 25s+120s plus 15+ min inside the race2 soak with NO panic (baseline stock kernel panicked in seconds); gtq_race full 200k-cycle run completes with post-stop fn_count frozen and clean kldunload. Mode 2 closed by construction: all ta_flags mutations are now under the owning taskqueue lock, so TASK_ENQUEUED cannot be lost while the task is linked (no double-insert/self-loop path remains).

['run.fixed.log: guest up after 25s/120s of the churn that panicked stock', "run.2.log tail: 'enq_ok=1685324 ... fn_count=6109 -> 6109' + FIXED_KERNEL_UNLOAD_OK", "fixbuild.log: '>>> Kernel install for X86_64_GENERIC completed' + BUILD_OK"]
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 09:11:43 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Evidence (decisive lines)

["panic.txt: 'panic: queue == NULL' + 'grouptaskqueue_enqueue() at grouptaskqueue_enqueue+0x11d' + 'racer() at racer+0x2f' on cpuid 1", 'run.log: stock-kernel gtq_race stats (enq_null=896739 NULL-window hits in 30s - each is a panic lottery ticket on the unfixed kernel)', 'run.fixed.log: fixed kernel, same gtq_null churn 25s+120s: no panic, guest up', 'run.2.log (tail): fixed kernel gtq_race 200k cycles, fn_count 6109 -> 6109 frozen post-stop, clean unload', 'fixbuild.log: fixed kernel #1 build+install BUILD_OK']

PoC changes

Added #include for DEV_MODULE; created three variants because the seed sketch did not exist (seed was a KLD-harness hint only): gtq_null.c (mode-1 panic trigger), gtq_race.c (lightweight stats churn), gtq_race2.c (4-racer corruption hunter with lwkt_yield - later learned that yielding daemon-priority spinners still starves userland, so race2 runs are wedges to be ignored); generated-header symlinks needed for out-of-tree KLD builds.

Verified recommended fix

Keep TASK_NOENQUEUE set in taskqgroup_detach (drop the unlocked clear); re-arm it under the taskqueue lock in taskqgroup_attach/attach_cpu; make grouptaskqueue_enqueue return EINVAL for a NULL queue.

Verdict

Race-triggered kernel panic reproduced and captured on the stock INVARIANTS kernel: a kernel thread doing plain GROUPTASK_ENQUEUE() against concurrent taskqgroup_attach/detach churn hits the window where taskqgroup_detach() has already stored gtask->gt_taskqueue = NULL (subr_gtaskqueue.c:746) but the task object is still shared, and grouptaskqueue_enqueue() panics ('panic: queue == NULL', backtrace grouptaskqueue_enqueue+0x11d <- racer, see panic.txt). On a non-INVARIANTS kernel the same interleaving is TQ_LOCK(NULL) -> kernel page fault. The second face of the same defect - the unlocked non-atomic 'ta_flags &= ~TASK_NOENQUEUE' at line 747 racing the locked 'ta_flags |= TASK_ENQUEUED' at lines 218-219, which can drop TASK_ENQUEUED while the gtask is linked and make the next enqueue double-insert it into the STAILQ (self-loop -> worker re-runs the task forever, queue corruption) - is a certain code-level race but was not empirically observed (2-cycle window; clean 200k-cycle runs; heavier harnesses starve the guest for their own reasons, documented in run.2.log). Reachability: the taskqgroup API has zero in-tree consumers, so both modes require root-KLD misuse or future API adoption - same latent family as DF-0085.