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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2849 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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 β 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(>), i.e. grouptaskqueue_enqueue(gt.gt_taskqueue, >.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
fixedfix.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"]
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
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.
No comments yet.