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

tmpfs_write accepts negative pwrite offsets: unsigned-wrap of the EFBIG/RLIMIT/growth checks drives tmpfs_reg_resize(negative) β†’ unprivileged kernel panic

Field Value
ID DF-3023
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-20 / CWE-191
File sys/vfs/tmpfs/tmpfs_vnops.c
Lines 691-693 (checks :713/:726/:760; sink tmpfs_subr.c:990)
Area vfs/tmpfs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket base:vfs
Reported pending
Known CVE none
CVE match novel

Summary

tmpfs_write() (unlike tmpfs_read at :559) never rejects uio_offset < 0, and sys_extpwrite() passes the user's pwrite offset through unvalidated. Because uio_resid is unsigned size_t, offset+resid is evaluated mod 2^64: for pwrite(fd, buf, 32752, βˆ’32752) the entry checks (EFBIG vs tm_maxfilesize, RLIMIT_FSIZE) wrap to 0 and pass, then iteration 1 computes uio_offset+len = βˆ’16384 β†’ as unsigned huge > tn_size β†’ tmpfs_reg_resize(vp, (off_t)βˆ’16384) β†’ KKASSERT(newsize >= 0) at tmpfs_subr.c:990 β†’ panic. Reproduced twice from clean guest boots: 'panic: assertion "newsize >= 0" failed in tmpfs_reg_resize', stack kern_pwritevβ†’vn_writeβ†’vop_writeβ†’tmpfs_writeβ†’ tmpfs_reg_resize, guest drops to db>. 16K-aligned single-block variants also pass every limit but the buffer cache rejects loffset<0 with EFAULT β€” no silent unaccounted-page primitive. Production (non-INVARIANTS) kernels fail cleanly (round_page64 negative β†’ huge page count trips the tm_pages_max ENOSPC guard) β€” wrong errno only, no corruption, no escalation path. Any unprivileged local user with write access to a tmpfs mount (default /tmp, /var/run/shm) panics INVARIANTS/DEBUG kernels with one syscall. Fix validated in-guest (uio_offset<0 β†’ EINVAL, mirroring tmpfs_read): both variants return EINVAL, regression-clean.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of tmpfs_vnops.c (GLM 5.3); unpriv panic reproduced twice + fix validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3023 Β· 14 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 3.9 KB ↓ raw
trigger.c β€” 2.2 KB view raw
fix.diff β€” 642 B view raw
build.sh β€” 532 B view raw
run.sh β€” 159 B view raw
run.log β€” 804 B view raw
run.patched.log β€” 159 B view raw
boot_panic_raw.txt β€” 2.2 KB view raw
panic.txt β€” 2.2 KB view raw
fix_build.log β€” 1.8 KB view raw
env.txt β€” 337 B view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 3.9 KB view raw

DF-3023 β€” tmpfs_write: negative pwrite offset β†’ tmpfs_reg_resize(negative) β†’ kernel panic

What

tmpfs_write() (sys/vfs/tmpfs/tmpfs_vnops.c:666) β€” unlike its sibling tmpfs_read() (:559-560) β€” never rejects a negative uio_offset. sys_extpwrite() (sys/kern/sys_generic.c:360-385) passes the user-supplied pwrite() offset through completely unvalidated (only nbyte < 0 is checked), so an unprivileged user can reach tmpfs_write() with uio_offset < 0.

Because struct uio.uio_resid is an unsigned size_t (sys/sys/_uio.h:59), every limit check in tmpfs_write is evaluated in unsigned 64-bit arithmetic and wraps mod 2^64:

  • entry check uio_offset + uio_resid > tm_maxfilesize (:713-717): with uio_offset = -32752 and uio_resid = 32752 the sum wraps to exactly 0 β†’ the EFBIG check passes;
  • RLIMIT_FSIZE check (:726-730): same wrap β†’ passes;
  • per-iteration growth check (uio_offset + len) > tn_size (:760-766): iteration 1 computes len = 16368, and -32752 + 16368 = -16384 becomes a huge unsigned value β†’ resize is requested with tmpfs_reg_resize(vp, (off_t)-16384, ...).

tmpfs_reg_resize() then hits KKASSERT(newsize >= 0) (sys/vfs/tmpfs/tmpfs_subr.c:990) and panics the kernel:

panic: assertion "newsize >= 0" failed in tmpfs_reg_resize at /usr/src/sys/vfs/tmpfs/tmpfs_subr.c:990
tmpfs_reg_resize() at tmpfs_reg_resize+0x2e9
tmpfs_write() at tmpfs_write+0x170
vop_write() at vop_write+0x9d
vn_write() at vn_write+0x134
kern_pwritev() at kern_pwritev+0xf2

Trigger

Unprivileged, single syscall, default tmpfs /tmp:

int fd = open("/tmp/x", O_CREAT|O_RDWR|O_TRUNC, 0644);
pwrite(fd, buf, 32752, -32752);   /* panics */

Variants observed on the guest

pwrite(n @ off) result
16384 @ -16384 (16K-aligned, sum wraps to 0, iter-1 sum lands exactly at 0) no resize; bread() at loffset βˆ’16384 β†’ EFAULT ("tmpfs_write uiomove error 14") β€” the buffer cache blocks the silent-unaccounted-write variant
16 @ -16384 / 1024 @ -65536 entry check catches (sum wraps to huge) β†’ EFBIG
32752 @ -32752 iter-1 sum wraps to βˆ’16384 β†’ reg_resize(-16384) β†’ PANIC
16 @ -16 / 8 @ -8 / 64 @ -64 passes checks, bread(βˆ’16384) β†’ EFAULT
16 @ 0 normal write (control)

Production (non-INVARIANTS) kernels

The two KKASSERTs compile out; round_page64(-16384)/PAGE_SIZE (sys/sys/param.h:411) evaluates to a huge page count which trips the tm_pages_used + newpages > tm_pages_max ENOSPC check (tmpfs_subr.c:1007-1010) β†’ the write fails cleanly with ENOSPC and no state is modified. So the memory-safety impact is confined to INVARIANTS/DEBUG kernels; production gets a wrong errno (ENOSPC instead of EINVAL) only.

Reproduction record

  • run.log + panic.txt (serial console, fresh boot): probe EFAULT, then panic at tmpfs_subr.c:990, guest down at db>.
  • Reproduced twice from clean-snapshot boots (vm.sh reset).
  • boot_panic_raw.txt β€” first occurrence (identical signature).

Fix

fix.diff β€” one gate in tmpfs_write, mirroring tmpfs_read:559:

    if (uio->uio_offset < 0)
        return (EINVAL);

Validated in-guest with a rebuilt kernel: see "Fix validation" in VERDICT.md.

VERDICT.md
↓ download raw

DF-3023 β€” VERDICT

Status: REPRODUCED (panic, unprivileged local DoS on INVARIANTS kernels) Fix: VALIDATED (single-fix kernel; bad behavior gone, no regression)

Reproduced β€” how and why

Unprivileged single pwrite() with a negative offset on the default tmpfs /tmp panics the kernel:

int fd = open("/tmp/x", O_CREAT|O_RDWR|O_TRUNC, 0644);
pwrite(fd, buf, 32752, -32752);          /* -> panic */

Root cause chain (all lines cited against the audited tree):

  1. sys/kern/sys_generic.c:360-385 sys_extpwrite() copies the user offset into auio.uio_offset unvalidated (only nbyte < 0 checked); vn_write() (sys/kern/vfs_vnops.c:766-812) passes it through.
  2. sys/vfs/tmpfs/tmpfs_vnops.c:666-943 tmpfs_write() β€” unlike tmpfs_read() at :559-560 β€” has no uio_offset < 0 rejection.
  3. struct uio.uio_resid is unsigned size_t (sys/sys/_uio.h:59), so uio_offset + uio_resid is unsigned 64-bit arithmetic: * :713-717 EFBIG check: -32752 + 32752 == 0 (mod 2^64) β†’ passes; * :726-730 RLIMIT_FSIZE check: same wrap β†’ passes; * :760-766 growth check: -32752 + 16368 == -16384 β†’ as unsigned huge β†’ resize requested; tmpfs_reg_resize(vp, (off_t)-16384, 1).
  4. sys/vfs/tmpfs/tmpfs_subr.c:990 KKASSERT(newsize >= 0) β†’ panic.

Observed (fresh-boot serial console, panic.txt / run.log):

panic: assertion "newsize >= 0" failed in tmpfs_reg_resize at /usr/src/sys/vfs/tmpfs/tmpfs_subr.c:990
tmpfs_reg_resize() at tmpfs_reg_resize+0x2e9
tmpfs_write() at tmpfs_write+0x170
vop_write() at vop_write+0x9d
vn_write() at vn_write+0x134
kern_pwritev() at kern_pwritev+0xf2

Reproduced twice from clean-snapshot boots. The 16K-aligned single-block variant (pwrite(16384 @ -16384)) also passes every limit check but is stopped by the buffer cache: bread() at loffset βˆ’16384 fails with EFAULT ("tmpfs_write uiomove error 14" on console) β€” so no silent unaccounted-page primitive exists; the exploitable outcome is the panic.

Why it stops at a panic (impact ceiling)

  • INVARIANTS/DEBUG kernels (this guest; dev/build machines): immediate unprivileged kernel panic β€” DoS. reproduced=true, impact=panic.
  • Production (non-INVARIANTS) kernels: the two KKASSERTs compile out; round_page64(-16384)/PAGE_SIZE (sys/sys/param.h:411) yields a huge page count that trips the tm_pages_max ENOSPC guard (tmpfs_subr.c:1007-1010) β†’ the write fails cleanly (wrong errno ENOSPC instead of EINVAL), no state change, no corruption.
  • No path to uid=0: the resize target is fully kernel-derived (never attacker-data), and both the buffer cache (EFAULT) and the pages limit (ENOSPC) contain the non-panic variants. Exploit chain: none beyond DoS.

Fix validation

fix.diff = one gate in tmpfs_write mirroring tmpfs_read:559:

    if (uio->uio_offset < 0)
        return (EINVAL);

In-guest: patch -p0 into /usr/src, make -j6 nativekernel (BUILD_RC=0), make installkernel, reboot into DragonFly 6.5-DEVELOPMENT #1 (fix present at tmpfs_vnops.c:702).

  • baseline: probe β†’ EFAULT; panic trigger β†’ kernel panic, guest down (Γ—2)
  • patched: probe β†’ EINVAL; panic trigger β†’ EINVAL; guest up; regression (write+pwrite+ftruncate) β†’ correct 128-byte file.

Bad behavior gone; fix validated. See fix_build.log, run.patched.log.

Relationship to prior findings

  • DF-2920/DF-2921 covered beyond-EOF page semantics (mmap/fault side) and HAMMER1 truncate-side signed overflow. DF-3023 is the write-syscall-side negative-offset entry into tmpfs resize β€” distinct root cause (missing uio_offset<0 gate + unsigned wrap in tmpfs_write's limit checks) and distinct fix.
  • The positive-offset 2^63 variant of this idea is NOT exploitable: size_t uio_resid's unsigned promotion makes offset+resid wrap-free there (verified on guest: EFBIG). Only negative offsets (which skip the EFAULT/EFBIG behavior for aligned residuals) reach resize.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (uio_offset<0 -> EINVAL at tmpfs_vnops.c:702) built via make nativekernel and booted. Exact PoC rerun: both negative-offset pwrites return EINVAL, no panic, guest up (run.patched.log). Normal write/pwrite/ftruncate regression OK. Baseline panicked twice before the fix.

fix_build.log, run.patched.log, env.txt
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 5 11:23:49 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['panic.txt β€” serial-console panic signature (tmpfs_reg_resize newsize>=0, stack via kern_pwritev->vn_write->vop_write->tmpfs_write)', 'run.log β€” decisive run: probe EFAULT + panic trigger (second clean-boot reproduction; first in boot_panic_raw.txt)', 'run.patched.log β€” patched kernel: both variants return EINVAL=22, guest stays up', 'fix_build.log β€” nativekernel build/install/boot record + regression check', 'trigger.c β€” minimal unpriv reproducer']

PoC changes

Seed concept (positive 2^63-offset wrap) was refuted live: size_t uio_resid is unsigned so offset+resid cannot wrap for positive offsets (guest returned EFBIG). Re-derived the negative-offset variant: entry-sum wraps to exactly 0 for offset=-N/resid=N, and iteration-1 sum goes negative for offsets below -16384; empirically bisected the trigger matrix (EFAULT/EFBIG/panic) to pwrite(32752 @ -32752).

Verified recommended fix

tmpfs_write: reject uio_offset < 0 with EINVAL, mirroring tmpfs_read (fix.diff validated in-guest); belt-and-suspenders: sys_extpwrite could also validate offset sign.

Verdict

Unprivileged pwrite() with a negative offset on the default tmpfs /tmp panics INVARIANTS kernels: sys_extpwrite() passes the offset unvalidated, tmpfs_write() (unlike tmpfs_read:559) never rejects uio_offset<0, and the unsigned size_t uio_resid makes the EFBIG/RLIMIT/growth checks wrap mod 2^64, driving tmpfs_reg_resize(vp, -16384) into KKASSERT(newsize>=0) at tmpfs_subr.c:990. Reproduced twice from clean boots; guest drops to db>. Production (non-INVARIANTS) kernels fail cleanly with ENOSPC via the tm_pages_max guard - no memory corruption, no escalation path (resize target is kernel-derived; buffer cache returns EFAULT for the silent-write variants). Fix (uio_offset<0 -> EINVAL in tmpfs_write) validated on a rebuilt kernel: panic gone, EINVAL returned, no regression.