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

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.

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)

No comments yet.

PoC verification

Evidence pack

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

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.

VERDICT.md
↓ download raw

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 a MINCORE_* nibble (vm_mmap.c:932).
  • The target is always vec + k for the caller-chosen vec; std_subyte refuses addresses β‰₯ VM_MAX_USER_ADDRESS (sys/platform/pc64/x86_64/support.s:632-634), so a kernel-range vec or 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/lastvecindex are int (vm_mmap.c:791) while OFF_TO_IDX() yields vm_pindex_t; ranges β‰₯ 2^31 pages (β‰₯ 8 TiB, allowed β€” user space is 128 TiB) truncate the index so fills/writes land Β±2 GiB from vec (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 (see run.log).
  • vm.sh reset with-src, patch -p1 applied 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 intact 0x5A, vec[0]/vec[1] still correctly reported 0x00 / 0x1f (see run.fixed.log). Behavior otherwise identical (mincore still returns 0).

References

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

vm.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Aug 31 15:19:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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 (first build warned about implicit exit), two trigger shapes (tail-fill and gap-fill loops), adjacent-page sentinel placement to observe the byte below vec, and exit-code contract (0=vulnerable, 2=fixed).

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.