taskqgroup_create() heap OOB write: unvalidated cnt writes struct taskqgroup_cpu slots past the fixed tqg_queue[MAXCPU] array
| Field | Value |
|---|---|
| ID | DF-2848 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:L |
| CWE | CWE-787 Out-of-bounds Write |
| File | sys/kern/subr_gtaskqueue.c |
| Lines | 788-798 (stores :591-597; array :574) |
| 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_create() kmallocs sizeof(struct taskqgroup) (fixed tqg_queue[MAXCPU] array) and taskqgroup_cpu_create() then writes tqg_queue[i] (LIST_INIT NULL store, tgc_taskq heap-pointer store, tgc_cpu int) for every i < cnt with no bound check β cnt > MAXCPU writes kernel-controlled data past the allocation (24 bytes per slot at caller-chosen distance). Deterministically reproduced on the guest with a KLD: all readback slots past the M_ZERO'd allocation contained non-NULL kernel heap pointers; the first OOB slots also clobbered the object's own tail (tqg_lock/tqg_name/tqg_cnt). A cnt of MAXCPU+2048 overruns ~48KB. No in-tree caller passes anything but ncpus β latent API hazard requiring a root-loaded KLD (DF-0085 family).
Recommended fix
Validate and clamp cnt in taskqgroup_create() before allocating (validated fix.diff in findings/poc/DF-2848/).
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_gtaskqueue.c (GLM 5.3); KLD OOB reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2848 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| gtq_oob.c | β | 3.6 KB | view raw | |
| gtq_smash.c | β | 906 B | view raw | |
| build.sh | β | 201 B | view raw | |
| run.sh | β | 189 B | view raw | |
| build.log | β | 5.6 KB | view raw | |
| run.log | β | 1.9 KB | view raw | |
| run.smash.log | β | 882 B | view raw | |
| run.fixed.log | β | 744 B | view raw | |
| fixbuild.log | β | 220 B | view raw | |
| env.txt | β | 1.3 KB | view raw | |
| fix.diff | β | 660 B | view raw | |
| VERDICT.md | β | 3.4 KB | β raw | |
| README.md | β | 2.1 KB | β raw | |
| verdict.json | β | 4.0 KB | view raw |
DF-2848 β taskqgroup_create() heap OOB write (cnt unvalidated vs MAXCPU)
WHAT
struct taskqgroup embeds a FIXED array struct taskqgroup_cpu
tqg_queue[MAXCPU] (sys/kern/subr_gtaskqueue.c:574). The object is
kmalloc'd with sizeof(struct taskqgroup) (subr_gtaskqueue.c:788) and
taskqgroup_cpu_create() then writes tqg_queue[i] for EVERY i < cnt
with no bound check (subr_gtaskqueue.c:793-797): LIST_INIT (8-byte
NULL store), tgc_taskq = fresh gtaskqueue* (8-byte heap-pointer
store), tgc_cpu = int. cnt > MAXCPU writes past the allocation.
BUILD (on the guest, as root) cd /root/gtq_oob 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 # -> gtq_oob.ko
RUN kldload /root/gtq_oob/gtq_oob.ko # deterministic proof dmesg | tail -30 # see run.log ps -ax -o comm | grep -c '^gtq_oob_' # threads created kldunload gtq_oob
Impact variant (silent-corruption stressor): kldload /root/poc/gtq_smash.ko # cnt = MAXCPU+2048
EXPECTED (stock kernel) - 16 slots probed; every slot at offset >= 6252 ("[PAST ALLOC]") contains a NON-NULL kernel heap pointer in tgc_taskq written by taskqgroup_cpu_create() past the end of the kmalloc'd object. - slot[256]/[257] show the qgroup's own tail (tqg_lock/tqg_name) being clobbered (tgc_cpu=-2134490742 garbage, thread names beyond gtq_oob_257 turn to garbage). - Guest stays up: the corruption is silent (no slab redzones). - FIXED kernel: "taskqgroup_create: gtq_oob cnt 272 > MAXCPU 256, clamping" printed, all [PAST ALLOC] slots read zero, exactly 256 gtq_oob_* threads exist.
FILES gtq_oob.c deterministic OOB-write proof module (readback probe) gtq_smash.c large-overrun impact variant (MAXCPU+2048 slots) build.log / run.log / run.smash.log / run.fixed.log / fixbuild.log fix.diff verified fix (clamp + loud kprintf) manifest.json / verdict.json
VERDICT β DF-2848 (taskqgroup_create unvalidated cnt -> heap OOB write)
REPRODUCED: YES (deterministic), on the stock INVARIANTS guest kernel (DragonFly 6.5-DEVELOPMENT #0, Thu Jul 2 06:02:54 UTC 2026).
HOW
gtq_oob.ko calls taskqgroup_create("gtq_oob", MAXCPU + 16, 1) and
reads the slots [256..271] back through a layout replica (only the
leading tqg_queue[] array is probed, which sits at offset 0 of the
real opaque struct, so replica offsets are exact on x86_64).
Observed (run.log, stock kernel):
gtq_oob: sizeof(struct taskqgroup) <= 6252 bytes (kmalloc size) gtq_oob: slot[261] off=6264 [PAST ALLOC] tgc_taskq=0xfffff8008bb07198 ... ... (11 consecutive [PAST ALLOC] slots, every one non-NULL) gtq_oob: slot[271] off=6504 [PAST ALLOC] tgc_taskq=0xfffff8008bb075a8 ...
Every [PAST ALLOC] slot holds a freshly allocated struct gtaskqueue *
written by taskqgroup_cpu_create() (sys/kern/subr_gtaskqueue.c:592-597)
at byte offsets 6264..6527, i.e. 12..384 bytes PAST the end of the
kmalloc'd object (allocation upper bound 6252 bytes: 6144-byte array +
lock + name + cnt + alignment). The allocation is M_ZERO, so these
non-NULL kernel pointers are unambiguously the kernel's own OOB stores.
Additionally the first OOB slots clobber the object's own tail:
slot[256] readback tgc_cpu=-2134490742 is tqg_lock bytes mixed with
the store, and thread naming beyond index 257 degenerates (only 257
well-named threads of 272 created) β tqg_name itself gets overwritten
by slot 258's tgc_taskq pointer.
Primitive: ~24 bytes per OOB index of kernel-controlled data (heap pointer + int + NULL) written at attacker-chosen distance past a kmalloc'd object (cnt is a caller-supplied int, unvalidated). A large cnt smashes arbitrarily far (run.smash.log: cnt=MAXCPU+2048, ~48KB overrun, 2304 live taskqueue threads β guest survived 30s; corruption is silent, DFly slab has no redzones).
WHY NOT uid=0 / no panic coaxed
The only caller of taskqgroup_create() in the tree is the TASKQGROUP_DEFINE macro's SYSINIT (softirq group, cnt=ncpus<=MAXCPU), and the whole gtaskqueue/taskqgroup API has ZERO in-tree consumers beyond that boot glue (verified: no users of GROUPTASK_ENQUEUE, taskqgroup_attach/detach/create/bind/drain_all anywhere in sys/). Reaching the bug requires loading a KLD that calls the API β root only. So: certain, deterministic memory-corruption primitive, but no unprivileged trigger exists today; it is a latent API hazard of exactly the DF-0085 family (not re-reported here).
EXPLOIT CHAIN: none (see above β root-gated API misuse; no unpriv path).
FIX VALIDATION
fix.diff clamps cnt into [1, MAXCPU] with a loud kprintf. Rebuilt kernel in-guest (#1: Wed Sep 2 09:11:43 UTC 2026, see fixbuild.log: "Kernel install for X86_64_GENERIC completed", BUILD_OK). Re-running the exact PoC on the fixed kernel (run.fixed.log):
taskqgroup_create: gtq_oob cnt 272 > MAXCPU 256, clamping slot[261..271] ... [PAST ALLOC] tgc_taskq=0 tgc_cpu=0 (all zero) FIXED_THREAD_COUNT=256
Baseline OOB writes GONE on the patched kernel. fix_status: fixed.
FILES: run.log (decisive), run.fixed.log, run.smash.log, fixbuild.log, gtq_oob.c, gtq_smash.c, fix.diff, manifest.json, verdict.json. Kernel refs: sys/kern/subr_gtaskqueue.c:574 (array decl), :788-798 (kmalloc + unbounded loop), :585-598 (taskqgroup_cpu_create stores).
Fix verification
fixedfix.diff applied in-guest to /usr/src, kernel rebuilt+installed (nativekernel, BUILD_OK, #1 Wed Sep 2 09:11:43 UTC 2026) and the exact PoC re-run: clamp message printed, every [PAST ALLOC] slot reads zero (no kernel writes past the allocation), exactly 256 gtq_oob_* threads created. Baseline OOB writes gone.
["run.fixed.log: 'taskqgroup_create: gtq_oob cnt 272 > MAXCPU 256, clamping'", "run.fixed.log: 'slot[261] off=6264 [PAST ALLOC] tgc_taskq=0 tgc_cpu=0' (all 11 zero)", "run.fixed.log: 'FIXED_THREAD_COUNT=256'", "fixbuild.log: '>>> Kernel install for X86_64_GENERIC completed' + BUILD_OK"]
Confirmed kernel references
Detail
Evidence (decisive lines)
["run.log: 'slot[261] off=6264 [PAST ALLOC] tgc_taskq=0xfffff8008bb07198' (11 consecutive non-NULL past-alloc slots)", "run.log: 'sizeof(struct taskqgroup) <= 6252 bytes (kmalloc size)'", "run.log: 'slot[256] off=6144 tgc_taskq=0xfffff8008bb06f90 tgc_cpu=-2134490742' (tail clobbered)", 'run.smash.log: cnt=MAXCPU+2048 (~48KB overrun) survives 30s - corruption silent', 'run.fixed.log: clamp message + all past-alloc slots zero + FIXED_THREAD_COUNT=256']
PoC changes
Added #include
Verified recommended fix
Clamp/validate cnt in taskqgroup_create(): if (cnt < 1) cnt = 1; if (cnt > MAXCPU) { kprintf(...); cnt = MAXCPU; }
Verdict
Deterministic heap OOB write proven on the stock INVARIANTS kernel: taskqgroup_create() kmallocs sizeof(struct taskqgroup) (<=6252 bytes: fixed tqg_queue[MAXCPU]=6144B plus tail) and taskqgroup_cpu_create() then stores a fresh gtaskqueue heap pointer + int + LIST_INIT NULL into tqg_queue[i] for every i < cnt with no bound check. With cnt=MAXCPU+16 the readback probe shows all 11 slots at offsets 6264..6527 (provably past the allocation, which is M_ZERO) containing non-NULL kernel pointers (0xfffff8008bb07198 etc), plus intra-object clobbering of tqg_lock/tqg_name (slot[256] tgc_cpu=-2134490742, thread names garbage beyond 257). cnt is an unvalidated caller int, so the overwrite distance/size is caller-chosen (smash variant: ~48KB). No panic was coaxed in the observation window (DFly slab has no redzones; corruption is silent), hence impact recorded as dos-class (heap-corruption instability) rather than a demonstrated crash. No unprivileged trigger exists: the API has zero in-tree consumers beyond the ncpus-bounded softirq SYSINIT, so reaching it requires a root-loaded KLD. Same latent-API-hazard family as DF-0085 (not re-reported).
No comments yet.