sys_mincore() off-by-one: kernel writes one NUL byte below the user-supplied vec pointer; vecindex/lastvecindex truncate at 8TB ranges
| Field | Value |
|---|---|
| ID | DF-2746 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N |
| CWE | CWE-787 / CWE-193 / CWE-190 |
| File | sys/vm/vm_mmap.c |
| Lines | 829 (-1 init), 921/962 (write-before-increment), 791 (int truncation) |
| Area | vm |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:vm |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
In sys_mincore() the gap-fill and tail-fill zero loops execute
subyte(vec + lastvecindex, 0) with lastvecindex initialized to -1, so
the first store lands at vec[-1] β one byte below the user's byte
vector β whenever the first kernel-reported page is not page 0 of the
scanned range (leading unmapped page(s)). Additionally
vecindex/lastvecindex are int while OFF_TO_IDX() yields a 64-bit
vm_pindex_t, so ranges β₯ 2^31 pages (β₯ 8TB of the 128TB user space)
truncate the index and redirect the fills by Β±2GB.
Threat model & preconditions
Unprivileged local user; any mincore() whose range begins with an unmapped page. The kernel stores 0x00 (or a MINCORE_* nibble) at vec-1 β corrupting the byte immediately preceding the application's vector. Impact honestly bounded: std_subyte rejects addresses β₯ VM_MAX_USER_ADDRESS, so no kernel-memory write primitive exists and all writes stay in the caller's own user address space β Low (contract violation + in-process corruption), not memcorrupt.
Proof of concept
VERIFIED unprivileged 3/3 runs on stock (findings/poc/DF-2746/ mincore_oob.c): both the tail-fill and gap-fill loops silently zero the 0x5A sentinel at vec[-1] β "RESULT: VULNERABLE (2)". Fix (write at vec+lastvecindex+1 in both loops + widen indices to vm_pindex_t) validated.
Recommended fix
See the four-hunk diff above (findings/poc/DF-2746/fix.diff).
Timeline
- 2026-08-30 Discovered during pass-2 audit of vm_mmap.c (GLM 5.3); reproduced unpriv + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2746 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.1 KB | β raw | |
| VERDICT.md | β | 3.6 KB | β raw | |
| mincore_oob.c | β | 3.5 KB | view raw | |
| build.sh | β | 131 B | view raw | |
| run.sh | β | 107 B | view raw | |
| build.log | β | 12 B | view raw | |
| run.log | β | 489 B | view raw | |
| run.fixed.log | β | 353 B | view raw | |
| fix_build.info | β | 464 B | β download | |
| env.txt | β | 235 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| verdict.json | β | 6.4 KB | view raw |
DF-2746 β sys_mincore() off-by-one write below the user vec pointer
DragonFlyBSD sys/vm/vm_mmap.c, sys_mincore().
Bug
The byte-vector maintenance loops use lastvecindex = -1 as "nothing
written yet" but then write at vec + lastvecindex (i.e. vec[-1]) on
the first iteration instead of vec + lastvecindex + 1:
- gap-fill loop β
sys/vm/vm_mmap.c:920-927 - tail-fill loop β
sys/vm/vm_mmap.c:961-968
Whenever the first page the kernel reports on is not page 0 of the
scanned range (leading unmapped page(s)), the kernel stores one NUL byte
at vec[-1] β one byte below the pointer the user supplied β before
writing vec[0]. Additionally vecindex/lastvecindex are int while
OFF_TO_IDX() yields a 64-bit page count, so ranges β₯ 2^31 pages
(β₯ 8 TiB) truncate the index (writes redirected by Β±2 GiB; still
confined to user space).
Impact boundary: std_subyte rejects addresses β₯ VM_MAX_USER_ADDRESS
(sys/platform/pc64/x86_64/support.s:632-634), so writes stay inside the
caller's own user address space and the written value is always 0 (or a
MINCORE_* nibble). No privilege boundary is crossed β severity Low
(POSIX contract violation + single-byte corruption of adjacent user
memory chosen by the caller's vec placement; classic heap-metadata
neighbor corruption in the calling process).
Build
cc -O -o /tmp/mincore_oob mincore_oob.c
Run (unprivileged)
/tmp/mincore_oob
Expected output on a vulnerable kernel
DF-2746 mincore(vec) off-by-one probe test1 (all-unmapped range, tail-fill loop): vec[-1] = 0x00 (must still be 0x5A) vec[0] = 0x00 (0x00 ok: page unmapped) => VULNERABLE: kernel wrote NUL at vec[-1] test2 (leading hole, gap-fill loop): ... same ... RESULT: VULNERABLE (2)
Exit code 0 + RESULT: VULNERABLE = bug present.
On a fixed kernel both sentinels stay 0x5A, RESULT: not vulnerable,
exit code 2.
Fix
fix.diff (4 hunks): write at vec + lastvecindex + 1 in both loops and
widen the indices to vm_pindex_t. Validated by kernel rebuild in the
QEMU guest β see VERDICT.md.
DF-2746 β VERDICT
File: sys/vm/vm_mmap.c (sys_mincore) Β· Severity: Low Β· Class: CWE-787 / CWE-193 (off-by-one OOB write; userspace-directed) + index truncation
Reproduced?
YES β reproduced, unprivileged, stable across 3 runs on the stock
INVARIANTS kernel (DragonFly dfbsd 6.5-DEVELOPMENT #0 ... X86_64_GENERIC,
guest uname -a in env.txt). run.log contains the decisive output:
test1 (all-unmapped range, tail-fill loop): vec[-1] = 0x00 (must still be 0x5A) <- kernel wrote NUL one byte BELOW vec => VULNERABLE: kernel wrote NUL at vec[-1] test2 (leading hole, gap-fill loop): vec[-1] = 0x00 (must still be 0x5A) => VULNERABLE: kernel wrote NUL at vec[-1] RESULT: VULNERABLE (2)
Both trigger shapes hit: (1) a fully unmapped range reaches the tail-fill
loop vm_mmap.c:961-968; (2) a range whose leading page is a hole reaches
the gap-fill loop vm_mmap.c:920-927 via vm_map_lookup_entry() failing
so iteration starts at RB_MIN (vm_mmap.c:821-822) and the first
reported page has vecindex >= 1. In each loop the very first iteration
executes subyte(vec + lastvecindex, 0) with lastvecindex == -1
(vm_mmap.c:829) β one NUL byte stored at vec[-1].
Exploit chain / impact ceiling
Rigorously bounded β no privilege escalation path exists:
- The write value is always
0(gap/tail fill); the per-page write uses aMINCORE_*nibble (vm_mmap.c:932). - The target is always
vec + kfor the caller-chosenvec;std_subyterefuses addresses β₯VM_MAX_USER_ADDRESS(sys/platform/pc64/x86_64/support.s:632-634), so a kernel-rangevecor wrapped index returns EFAULT β no kernel-memory write primitive. - Everything lands in the calling process's own address space, which the
caller can already write arbitrarily. Residual harm: silent single-byte
corruption of whatever the application placed immediately before the
vector (e.g. a heap chunk header/length prefix) β in-process corruption
the app cannot anticipate; POSIX mincore(2) promises to touch only
vec[0..ceil(len/PAGE_SIZE)). - Secondary manifestation:
vecindex/lastvecindexareint(vm_mmap.c:791) whileOFF_TO_IDX()yieldsvm_pindex_t; ranges β₯ 2^31 pages (β₯ 8 TiB, allowed β user space is 128 TiB) truncate the index so fills/writes land Β±2 GiB fromvec(still user-space only).
Impact: none for privilege; the honest classification is a kernel
correctness/contract bug with in-process corruption β filed Low.
Root cause
sys/vm/vm_mmap.c:920-927 and :961-968: loop body writes
vec + lastvecindex but increments after the write; with the -1
sentinel (:829) the first store belongs at vec + lastvecindex + 1.
Fix (validated)
fix.diff β write subyte(vec + lastvecindex + 1, 0) in both loops and
widen vecindex/lastvecindex to vm_pindex_t (sentinel becomes
(vm_pindex_t)-1; lastvecindex + 1 still wraps to 0).
Fix validation
- Baseline (stock kernel):
RESULT: VULNERABLE (2), exit 0 (seerun.log). vm.sh reset with-src,patch -p1applied to guest/usr/src(clean, 4/4 hunks),make -j4 nativekernel KERNCONF=X86_64_GENERIC, installkernel, reboot.- Patched kernel:
RESULT: not vulnerable, exit 2 β sentinels intact0x5A,vec[0]/vec[1]still correctly reported 0x00 / 0x1f (seerun.fixed.log). Behavior otherwise identical (mincore still returns 0).
References
sys/vm/vm_mmap.c:791(int indices),:829(-1 sentinel),:920-927(gap-fill),:932(page write),:961-968(tail-fill)sys/platform/pc64/x86_64/support.s:625-644(std_subyteuser-range check)
Fix verification
fixedvm.sh reset with-src; patch -p1 applied clean (4/4 hunks) to guest /usr/src; make -j4 nativekernel KERNCONF=X86_64_GENERIC completed 15:23:54 UTC; installkernel completed 15:27:04 UTC; rebooted into #1. Exact same PoC on patched kernel: vec[-1] sentinels intact (0x5A), correct vec contents (0x00 unmapped / 0x1f in-core), RESULT: not vulnerable, exit 2. Baseline vs patched delta is exactly the bug.
['findings/poc/DF-2746/run.log (baseline VULNERABLE) vs run.fixed.log (patched not vulnerable)', 'findings/poc/DF-2746/fix_build.info (patched uname + build/install stamps)', 'findings/poc/DF-2746/fix.diff']
Confirmed kernel references
Detail
Exploit chain
No escalation chain exists. Attacker controls vec placement, so the kernel-mediated write (value 0x00, one byte below vec, or MINCORE nibbles at truncated +/-2GiB offsets) lands only in the calling process's own user memory - memory the same process can already write directly. std_subyte's VM_MAX_USER_ADDRESS check blocks any kernel-range target (verified support.s:632-634). Ceiling = silent in-app corruption of the byte preceding the vector (e.g. heap chunk metadata/length prefix the app did not expose to the attacker).
Evidence (decisive lines)
["findings/poc/DF-2746/run.log - baseline stock kernel: 'vec[-1] = 0x00 (must still be 0x5A)' both tests, RESULT: VULNERABLE (2), unpriv user, 3 runs", 'findings/poc/DF-2746/run.fixed.log - patched kernel #1 (2026-08-31 15:19:46): sentinels intact 0x5A, vec[0]=0x00/vec[1]=0x1f correct, RESULT: not vulnerable, exit 2', 'findings/poc/DF-2746/fix_build.info - patched-kernel uname -a + build/install completion stamps', 'findings/poc/DF-2746/fix.diff - 4 hunks: subyte(vec + lastvecindex + 1, 0) in both loops + vm_pindex_t indices', 'sys/vm/vm_mmap.c:791,829,920-927,932,961-968 - int indices, -1 sentinel, both buggy write sites', 'sys/platform/pc64/x86_64/support.s:625-644 - std_subyte user-range check that bounds the primitive to user space']
PoC changes
Seed PoC written fresh for this finding (none existed). Final version includes
Verified recommended fix
In sys_mincore() write at vec + lastvecindex + 1 in the gap-fill and tail-fill zero loops and widen vecindex/lastvecindex to vm_pindex_t (see fix.diff).
Verdict
Reproduced as unprivileged user on the stock INVARIANTS X86_64_GENERIC kernel: both byte-vector maintenance loops in sys_mincore() (gap-fill vm_mmap.c:920-927, tail-fill vm_mmap.c:961-968) execute subyte(vec + lastvecindex, 0) with lastvecindex == -1, so whenever the first kernel-reported page is not page 0 of the scanned range the kernel stores one NUL byte at vec[-1], one byte below the user-supplied pointer. Stable across 3 runs; both trigger shapes (fully-unmapped range; leading hole) hit. Impact rigorously bounded at none-for-privilege: std_subyte rejects addresses >= VM_MAX_USER_ADDRESS (support.s:632-634), so no kernel-memory write exists; the corruption stays inside the caller's own user address space (value always 0 or a MINCORE_* nibble). Secondary manifestation: vecindex/lastvecindex are int while OFF_TO_IDX() yields vm_pindex_t, so ranges >= 2^31 pages (>= 8 TiB of the 128 TiB user space) truncate the index and redirect writes by +/-2 GiB (still user-space only). Filed Low: POSIX contract violation with in-process single-byte corruption adjacent to the caller-chosen vec. fix.diff (write vec + lastvecindex + 1; widen indices to vm_pindex_t) validated by full in-guest kernel rebuild: baseline VULNERABLE(2)/exit 0 vs patched not-vulnerable/exit 2 with vec[-1] sentinels intact and correct mincore data.
No comments yet.