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)
PoC verification
Evidence pack
findings/poc/DF-3023 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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): withuio_offset = -32752anduio_resid = 32752the 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 computeslen = 16368, and-32752 + 16368 = -16384becomes a huge unsigned value β resize is requested withtmpfs_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 attmpfs_subr.c:990, guest down atdb>.- 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.
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):
sys/kern/sys_generic.c:360-385sys_extpwrite()copies the user offset intoauio.uio_offsetunvalidated (onlynbyte < 0checked);vn_write()(sys/kern/vfs_vnops.c:766-812) passes it through.sys/vfs/tmpfs/tmpfs_vnops.c:666-943tmpfs_write()β unliketmpfs_read()at:559-560β has nouio_offset < 0rejection.struct uio.uio_residis unsignedsize_t(sys/sys/_uio.h:59), souio_offset + uio_residis unsigned 64-bit arithmetic: *:713-717EFBIG check:-32752 + 32752 == 0(mod 2^64) β passes; *:726-730RLIMIT_FSIZE check: same wrap β passes; *:760-766growth check:-32752 + 16368 == -16384β as unsigned huge β resize requested;tmpfs_reg_resize(vp, (off_t)-16384, 1).sys/vfs/tmpfs/tmpfs_subr.c:990KKASSERT(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 thetm_pages_maxENOSPC 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<0gate + 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 makesoffset+residwrap-free there (verified on guest: EFBIG). Only negative offsets (which skip the EFAULT/EFBIG behavior for aligned residuals) reach resize.
Fix verification
fixedSingle-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
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.
No comments yet.