Reachable KASSERT panic in kern_truncate()/kern_ftruncate() when VOP_GETATTR fails under quotas
| Field | Value |
|---|---|
| ID | DF-0001 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-617 Reachable Assertion |
| File | sys/kern/vfs_syscalls.c |
| Lines | 4036-4042, 4111-4117 |
| Area | kern |
| Confidence | likely |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
kern_truncate() and kern_ftruncate(), both reachable from unprivileged
users via truncate(2)/ftruncate(2), unconditionally KASSERT that
VOP_GETATTR succeeded whenever VFS quota accounting is enabled. On kernels
built with INVARIANTS (the default debug/development build), any
VOP_GETATTR failure on the target vnode โ e.g. an NFS transient ESTALE/
EIO or a forced-reclaim vnode โ panics the kernel. The assertion depends on
runtime/network/FS state rather than an invariant the caller can guarantee.
Root cause
In kern_truncate (sys/kern/vfs_syscalls.c:4036-4042):
if (vfs_quota_enabled) {
error = VOP_GETATTR(vp, &vattr);
KASSERT(error == 0, ("kern_truncate(): VOP_GETATTR didn't return 0")); /* line 4038 */
...
}
and identically in kern_ftruncate (sys/kern/vfs_syscalls.c:4111-4117,
KASSERT at line 4113 using VOP_GETATTR_FP).
KASSERT is defined in sys/sys/systm.h:94-96 to panic() when INVARIANTS
is compiled in (and is a no-op otherwise, systm.h:117). VOP_GETATTR is not
guaranteed to succeed: for NFS it can return ESTALE/EIO on a transient
server error or a mid-operation stale filehandle, and forced-reclaim vnodes
(the caller takes LK_FAILRECLAIM at vfs_syscalls.c:4027) can also fail
GETATTR. vfs_quota_enabled is a boot TUNABLE_INT
(sys/kern/vfs_quota.c, default 0) so it is enabled on any operator who
turns quotas on. On an INVARIANTS+quota box, a write-permitted user calling
truncate(path, len) on such a vnode takes the whole machine down.
Threat model & preconditions
- Attacker position: any local unprivileged user with write permission to a
file on a quota-enabled mount (
vfs.quota_enabled=1), running under anINVARIANTSkernel. - Privileges gained or impact: kernel panic (denial of service).
- Required config or capabilities:
INVARIANTSkernel and quota enabled and a filesystem that can failGETATTRpost-lookup (NFS is the realistic case). No privilege beyond write access to the target. - Reachability:
truncate(2)โkern_truncateandftruncate(2)โkern_ftruncate, directly. A malicious/in-the-path NFS server returningNFSERR_IO/NFSERR_STALEforGETATTRmakes it deterministic.
Proof of concept
PoC source: findings/poc/DF-0001/trunc_panic.c
Build & run
cc -o trunc_panic findings/poc/DF-0001/trunc_panic.c ./trunc_panic /nfs/mount/target
Expected output
panic: kern_truncate(): VOP_GETATTR didn't return 0 Fatal trap 12: page fault while in kernel mode
The system halts / dumps. The ftruncate variant prints the analogous
kern_ftruncate() message. On a non-INVARIANTS kernel the KASSERT compiles
away and truncate(2) just returns the GETATTR error (no memory-safety
impact) โ which is why this is rated Low.
Impact
Denial of service on INVARIANTS+quota hosts. Realistic target is hosts using
network filesystems with quotas enabled (e.g. NFS clients on developer boxes
that ship INVARIANTS kernels). No memory corruption, no privilege escalation;
the production (non-INVARIANTS) kernel is unaffected except for the
GETATTR error propagating to the caller.
Recommended fix
Propagate the GETATTR error instead of asserting; release the vnode lock
cleanly on the error path. Both done labels already perform the correct
cleanup (vput at vfs_syscalls.c:4051; fdrop at vfs_syscalls.c:4128).
--- a/sys/kern/vfs_syscalls.c
+++ b/sys/kern/vfs_syscalls.c
@@ -4036,7 +4036,8 @@ kern_truncate(struct nlookupdata *nd, off_t length)
if (vfs_quota_enabled) {
error = VOP_GETATTR(vp, &vattr);
- KASSERT(error == 0, ("kern_truncate(): VOP_GETATTR didn't return 0"));
+ if (error)
+ goto done; /* vput(vp) releases lock+ref */
uid = vattr.va_uid;
gid = vattr.va_gid;
old_size = vattr.va_size;
@@ -4111,7 +4112,11 @@ kern_ftruncate(int fd, off_t length)
if (vfs_quota_enabled) {
error = VOP_GETATTR_FP(vp, &vattr, fp);
- KASSERT(error == 0, ("kern_ftruncate(): VOP_GETATTR didn't return 0"));
+ if (error) {
+ vn_unlock(vp);
+ goto done; /* fdrop(fp) at 'done' */
+ }
uid = vattr.va_uid;
gid = vattr.va_gid;
old_size = vattr.va_size;
References
sys/sys/systm.h:94โKASSERTexpands topanicunderINVARIANTS.sys/kern/vfs_quota.cโvfs_quota_enabledtunable definition.truncate(2),ftruncate(2)man pages.- Related class: CVE entries for reachable-assertion panics (CWE-617) in VFS paths.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/vfs_syscalls.c. - pending Reported to DragonFlyBSD security contact.
PoC verification
Evidence pack
findings/poc/DF-0001 ยท 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| estale_trig.c | trigger-source | THE trigger that fires the panic: open fd -> server-side stale-FH invalidation -> ftruncate -> GETATTR ESTALE -> KASSERT | 1.9 KB | view raw |
| trunc_panic.c | trigger-source | original reviewer PoC (path truncate), sharpened to print errnos and populate the target | 3.5 KB | view raw |
| trunc_only.c | diagnostic | errno diagnostic proving dead-server GETATTR returns cached attrs (error=0) -> KASSERT not reached (negative evidence) | 1.3 KB | view raw |
| build.sh | build-script | ships sources to guest + cc as unprivileged user maxx | 814 B | view raw |
| run.sh | run-script | full multi-step reproducer: quota reboot + loopback NFS + ESTALE handle invalidation -> panic | 3.9 KB | view raw |
| VERDICT.md | verdict | full mechanism walkthrough: why dead-server doesn't fire but ESTALE does, path:line at every hop | 8.7 KB | โ raw |
| README.md | readme | human-facing build/run/preconditions + file index | 4.8 KB | โ raw |
| fix.diff | suggested-fix | git-apply-able: KASSERT -> proper error-return + cleanup (vn_unlock+goto done for ftruncate); applies cleanly to sys/kern/vfs_syscalls.c | 853 B | view raw |
| panic.txt | panic-signature | serial-console panic: kern_ftruncate(): VOP_GETATTR didn't return 0 at kern_ftruncate+0x152 | 475 B | view raw |
| run.log | run-log | decisive confirmation run (fresh vm.sh reset), step-by-step, full output | 4.1 KB | view raw |
| boot.log.full | serial-log | full untrimmed serial log of the panicking boot | 13.2 KB | view raw |
| build.log | build-log | final successful build of estale_trig on guest | 70 B | view raw |
| env.txt | environment | uname, cc, quota default, INVARIANTS check (KASSERT strings in kernel binary) | 669 B | view raw |
| manifest.json | manifest | this catalog | 3.3 KB | view raw |
DF-0001 โ PoC: reachable KASSERT panic in kern_truncate()/kern_ftruncate()
kern_truncate() / kern_ftruncate() unconditionally KASSERT that
VOP_GETATTR succeeded whenever vfs_quota_enabled is on
(sys/kern/vfs_syscalls.c:4036-4042 and :4111-4117). On an INVARIANTS
kernel, any VOP_GETATTR failure on the target vnode panics the kernel โ
reachable from any local user with write access to the file via
truncate(2) / ftruncate(2), no privilege required. CWE-617 reachable
assertion. Severity Low (local DoS only; no memory corruption).
Verdict
REPRODUCED โ deterministic panic: kern_ftruncate(): VOP_GETATTR didn't
return 0 at kern_ftruncate+0x152, confirmed across two runs including one
from a fresh vm.sh reset. See VERDICT.md for the full mechanism and
panic.txt / run.log for the evidence.
Preconditions (all three are required)
options INVARIANTSkernel. The audited guest'sX86_64_GENERICkernel does shipINVARIANTSโ verified by the presence of both panic strings in/boot/kernel/kernel. On a non-INVARIANTS kernel the KASSERT is a compiled-out no-op and nothing happens.vfs.quota_enabled=1. The sysctl isCTLFLAG_RD, so it is a boot loader tunable (/boot/loader.conf:vfs.quota_enabled="1") and requires a reboot. Default is0.- A filesystem whose
VOP_GETATTRreturns a nonzero error. Local hammer2/UFS GETATTR is effectively infallible. The realistic case is NFS returning ESTALE for GETATTR on a stale open-fd filehandle (the clean trigger; see below). Note: a merely-dead NFS server does NOT trip the bug on master โ the client's attribute cache serves cached/local attrs witherror=0, so the KASSERT never sees a failure. An application-level GETATTR error (ESTALE) is required.
Build
./build.sh # ships sources to the guest and cc's them as user maxx
Or manually on the guest:
cc -O0 -g -o estale_trig estale_trig.c cc -O0 -g -o trunc_panic trunc_panic.c cc -O0 -g -o trunc_only trunc_only.c
Run (full choreography)
./run.sh # enables quota+reboots, stands up loopback NFS, triggers panic
run.sh does, end to end:
- verifies the running kernel has the live KASSERT (
INVARIANTSon); - sets
vfs.quota_enabled="1"in/boot/loader.confand reboots (non-revertingvm.sh down && vm.sh up); - stands up a loopback NFS server (
rpcbind/mountd/nfsd) exporting/export, and NFS-mounts it soft, UDP, attribute-cache disabled (mount_nfs -U -s -x 1 -t 1 -o acregmin=0,acregmax=0,...); - as the unprivileged user (
maxx, uid 1001, not inwheel), opens/mnt/estale_target, holding a fixed filehandle on thefd; - invalidates that filehandle server-side (
rm+touchโ new inode, clientfdnow references a stale handle, server still UP); - the process wakes and calls
ftruncate(fd, 0)โkern_ftruncateโVOP_GETATTR_FPโ GETATTR RPC on the stale handle โ server returnsNFSERR_STALEโnfs_getattrreturnsESTALEโKASSERT(error==0)atvfs_syscalls.c:4113โ panic.
Expected output (bug present)
panic: kern_ftruncate(): VOP_GETATTR didn't return 0
kern_ftruncate() at kern_ftruncate+0x152
...
Debugger("panic")
db>
The guest halts in DDB; ssh dies. Recover with dfbsd-qemu/vm.sh reset.
Expected output (bug absent โ non-INVARIANTS kernel)
The KASSERT compiles to a no-op; truncate(2)/ftruncate(2) simply return
the GETATTR error (ESTALE/EIO). No memory-safety impact โ which is why
this is rated Low.
Files
| file | role |
|---|---|
estale_trig.c |
the trigger that fires the panic (open fd + server-side stale-FH invalidation + ftruncate) |
trunc_panic.c |
original reviewer PoC (path truncate), sharpened to print errnos |
trunc_only.c |
errno diagnostic proving the dead-server path returns GETATTR=0 (negative evidence) |
build.sh/run.sh |
reproducible build + full multi-step run |
VERDICT.md |
full mechanism + why dead-server doesn't fire but ESTALE does |
panic.txt |
serial-console panic signature (the crash proof) |
run.log |
decisive confirmation run, step by step |
boot.log.full |
full untrimmed serial log of the panicking boot |
build.log/env.txt |
build output + guest environment (incl. INVARIANTS check) |
fix.diff |
git apply-able fix: KASSERT โ proper error-return + cleanup |
manifest.json |
machine-readable artifact catalog |
DF-0001 โ Verdict
Verdict: REPRODUCED (deterministic kernel panic, confirmed across two
independent runs including one from a fresh vm.sh reset).
Impact: local denial-of-service (kernel panic) on INVARIANTS+quota
hosts; no memory corruption, no privilege escalation. Rated Low by the
finding โ confirmed.
What the bug is
kern_truncate() and kern_ftruncate() (both reachable from any local user
with write permission to a file via truncate(2)/ftruncate(2)) call
VOP_GETATTR/VOP_GETATTR_FP to read the file's uid/gid/size for quota
accounting, and unconditionally KASSERT that the call succeeded:
sys/kern/vfs_syscalls.c:4036-4042โkern_truncate:c if (vfs_quota_enabled) { error = VOP_GETATTR(vp, &vattr); KASSERT(error == 0, ("kern_truncate(): VOP_GETATTR didn't return 0")); /* :4038 */ ... }sys/kern/vfs_syscalls.c:4111-4117โkern_ftruncate:c if (vfs_quota_enabled) { error = VOP_GETATTR_FP(vp, &vattr, fp); KASSERT(error == 0, ("kern_ftruncate(): VOP_GETATTR didn't return 0")); /* :4113 */ ... }
KASSERT is panic under options INVARIANTS and a no-op otherwise
(sys/sys/systm.h:94-117). The block is gated only by the global
vfs_quota_enabled (a boot loader tunable, sys/kern/vfs_quota.c:112-115,
CTLFLAG_RD so settable only at boot), not by any per-mount or
can-the-FS-actually-fail-GETATTR check. VOP_GETATTR is not guaranteed
to succeed: any filesystem whose vop_getattr can return a nonzero error
post-lookup/lock trips it. There is no privilege check before the KASSERT.
Precondition check on the tested guest (master DEV, X86_64_GENERIC)
| Precondition | Status on guest |
|---|---|
options INVARIANTS in kernel config |
PRESENT โ sys/config/X86_64_GENERIC has options INVARIANTS (not commented) |
| KASSERT compiled in (panic, not no-op) | YES โ strings /boot/kernel/kernel shows both panic strings (kern_truncate(): VOP_GETATTR didn't return 0, kern_ftruncate(): ...) |
vfs.quota_enabled=1 |
default 0; set to 1 via /boot/loader.conf vfs.quota_enabled="1" + reboot (sysctl is CTLFLAG_RD) |
| Reachable from unprivileged user | YES โ sys_truncateโkern_truncate, sys_ftruncateโkern_ftruncate, no suser/priv_check before the KASSERT |
A VFS whose VOP_GETATTR can return nonzero |
local hammer2/UFS GETATTR is effectively infallible โ must use a network FS; loopback NFS used (see below) |
The guest's X86_64_GENERIC kernel does ship INVARIANTS, so the
KASSERT is a live panic() โ contrary to the common assumption that
production GENERIC kernels leave INVARIANTS off. This is what makes the
bug fire on this exact kernel.
How the panic was triggered (the ESTALE path)
The finding's narrative names two GETATTR-failure modes: an NFS transient
(ESTALE/EIO) and a forced-reclaim vnode. Empirically, on DragonFly
master:
- Dead-server (transport-failure) does NOT reach the KASSERT. When the
NFS server is killed, the client logs
nfs server ... not responding/nfs send error 61(ECONNREFUSED) andnfs_getattr()(sys/vfs/nfs/nfs_vnops.c:685-738) returns cached/local attributes witherror=0rather than propagating the transport error (the attribute cache atsys/vfs/nfs/nfs_subs.c:885-933serves the GETATTR, and for a client-written file theNLMODIFIEDlocal-attr path makes the hit sticky). Sotruncate()/ftruncate()propagate the error only from the laterVOP_SETATTRRPC (asEINTR/EIO), and the KASSERT โ which sits onGETATTR, beforeSETATTRโ never sees a nonzero value. The most obvious "kill the NFS server" scenario does not trip this bug on master. - A genuine application-level GETATTR error DOES reach the KASSERT. The
clean way to force
nfs_getattr()to return a nonzero error is ESTALE: the client holds an openfd(fixed vnode/filehandle), the server deletes and recreates the file (new inode โ old filehandle is now stale), and the server โ still up and responding โ returnsNFSERR_STALEfor the GETATTR RPC on the stale handle.nfsm_request+ theNEGKEEPOUT/ERROROUTmacros (sys/vfs/nfs/nfsm_subs.h:109-124) propagate that error out ofnfs_getattr()(return (error)atnfs_vnops.c:737), soVOP_GETATTR_FPreturnsESTALEtokern_ftruncate, and the KASSERT atvfs_syscalls.c:4113fires.
Trigger choreography (see run.sh)
- Boot
vfs.quota_enabled=1(loader tunable, reboot). - Stand up a loopback NFS server (
rpcbind/mountd/nfsd) exporting/export; NFS-mount it soft, UDP, attribute-cache disabled (mount_nfs -U -s -x 1 -t 1 -o acregmin=0,acregmax=0,...). - As the unprivileged user
maxx(uid 1001, not inwheel),open()/mnt/estale_targetโfdholds a fixed vnode/filehandle; sleep. - Server-side:
rm /export/estale_target && touch /export/estale_target(new inode; the clientfd's handle is now stale; server still UP). - The process wakes and calls
ftruncate(fd, 0):kern_ftruncateโVOP_GETATTR_FPโ NFS GETATTR RPC on the stale filehandle โ server returnsNFSERR_STALEโnfs_getattrreturnsESTALEโKASSERT(error == 0, ...)atvfs_syscalls.c:4113โ panic.
Decisive evidence
Serial-console panic signature (dfbsd-qemu/boot.log), identical across the
initial run and the fresh-vm.sh reset confirmation run:
panic: kern_ftruncate(): VOP_GETATTR didn't return 0
cpuid = 0
Trace beginning at frame 0xfffff800abb23798
kern_ftruncate() at kern_ftruncate+0x152 0xffffffff80705532
kern_ftruncate() at kern_ftruncate+0x152 0xffffffff80705532
sys_xsyscall() at sys_xsyscall+0x89 0xffffffff80bd6749
syscall2() at syscall2+0x11e 0xffffffff80bd611e
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbd77f9(%rip)
db>
The panic names exactly the function the finding cites (kern_ftruncate,
KASSERT at :4113 โ +0x152 in the disassembly), reached via the normal
syscall2 โ sys_xsyscall โ kern_ftruncate path from an unprivileged
ftruncate(2). The kern_truncate twin at :4038 is the same bug; the
ftruncate variant was used for the demonstration only because the ESTALE
choreography is cleanest on an open fd. (trunc_panic.c / trunc_only.c
are retained as the path-truncate variants and the local-FS/no-panic baselines.)
Exploit chain
None โ this is not a memory-corruption class. It is a reachable
assertion (CWE-617): the primitive is a kernel panic() (DoS), full stop.
No bytes are corrupted, no pointers are hijacked, no privilege changes. The
realistic impact ceiling is denial of service of an INVARIANTS+quota host
by any local user with write access to a file on a GETATTR-failing (NFS)
mount. No further primitive is derivable.
PoC changes made during verification
- Added
estale_trig.cโ the trigger that actually fires the panic. The originaltrunc_panic.c(path-basedtruncateagainst a "failing GETATTR FS") does not fire on master because, as traced above, a transport GETATTR failure is papered over by the NFS attribute cache; only an application-level GETATTR error (ESTALE on a stale open-fd handle) reaches the KASSERT.estale_trig.cimplements that choreography. - Added
trunc_only.cโ errno-printing diagnostic that proved (via theEINTR-from-SETATTR result) that the dead-server path returnsGETATTR=0and therefore cannot trip the KASSERT. Kept as the negative evidence / baseline. - Sharpened
trunc_panic.cto print errnos and populate the target. - Added
run.shโ the multi-step reproducer (quota reboot + loopback NFS + ESTALE handle invalidation), since the bug needs three runtime preconditions the clean-install guest lacks.build.shbuilds all three sources as the unprivileged user.
Recommended fix
Convert both KASSERTs to proper error-returns (the done: labels already
perform the correct cleanup: vput(vp) for kern_truncate at :4051,
fdrop(fp) for kern_ftruncate at :4128; kern_ftruncate must
vn_unlock(vp) first because its done: sits after the vn_unlock at
:4126). The standalone git apply-able diff is fix.diff; it applies
cleanly to sys/kern/vfs_syscalls.c. This matches the finding markdown's
## Recommended fix proposal (same error-return + cleanup shape); the
runner's diff additionally carries an explicit vn_unlock(vp) before the
goto done in kern_ftruncate to avoid leaking the vnode lock.
Confirmed kernel references
Detail
Exploit chain
none. This is a reachable-assertion (CWE-617) DoS, not a memory-corruption primitive: the bug yields a kernel panic() and nothing more (no byte corruption, no pointer control, no privilege change). No escalation chain is derivable. Realistic impact = local DoS of an INVARIANTS+quota host by any user with write access to a file on a GETATTR-failing (NFS) mount.
Evidence (decisive lines)
panic.txt / dfbsd-qemu/boot.log: 'panic: kern_ftruncate(): VOP_GETATTR didn't return 0' / 'kern_ftruncate() at kern_ftruncate+0x152' / 'sys_xsyscall() at sys_xsyscall+0x89' / 'syscall2() at syscall2+0x11e' / 'Debugger("panic")' / 'db>'. Triggered by maxx (uid 1001, not in wheel). INVARIANTS check: strings /boot/kernel/kernel shows both 'kern_truncate(): VOP_GETATTR didn't return 0' and 'kern_ftruncate(): ...' (2 hits). Full untrimmed logs in findings/poc/DF-0001/{panic.txt,run.log,boot.log.full}.
PoC changes
Added estale_trig.c (the trigger that actually fires the panic: open fd -> server-side stale-filehandle invalidation -> ftruncate -> GETATTR ESTALE -> KASSERT). Added trunc_only.c (errno diagnostic proving the dead-server path returns GETATTR=0 via the attr cache, so it CANNOT trip the KASSERT - negative evidence that refined the finding). Sharpened trunc_panic.c to print errnos and populate the target. Added build.sh + run.sh (the full multi-step reproducer: quota reboot + loopback NFS soft mount + ESTALE choreography), VERDICT.md, panic.txt, run.log, boot.log.full, build.log, env.txt, fix.diff, manifest.json. Original trunc_panic.c is retained as the path-truncate variant.
Verified recommended fix
In sys/kern/vfs_syscalls.c, replace both KASSERTs with proper error-returns: kern_truncate (:4038) -> 'if (error) goto done;' (done: at :4050 does vput(vp)); kern_ftruncate (:4113) -> 'if (error) { vn_unlock(vp); goto done; }' (done: at :4127 does fdrop(fp); the explicit vn_unlock avoids leaking the vnode lock since done sits after the vn_unlock at :4126). Full git-apply-able diff in findings/poc/DF-0001/fix.diff (validated: applies cleanly). Matches the finding markdown's ## Recommended fix proposal, with the explicit vn_unlock added for kern_ftruncate.
Verdict
REPRODUCED. The audited X86_64_GENERIC master DEV kernel DOES ship options INVARIANTS (both KASSERT panic strings are present in /boot/kernel/kernel), so the KASSERT at sys/kern/vfs_syscalls.c:4038 (kern_truncate) and :4113 (kern_ftruncate) are LIVE panic()s, not no-ops. With vfs_quota_enabled=1 (boot loader tunable; sysctl is CTLFLAG_RD), any VOP_GETATTR/VOP_GETATTR_FP returning nonzero panics the kernel, reachable from an unprivileged user via truncate(2)/ftruncate(2) with no privilege check before the assert. I triggered a deterministic 'panic: kern_ftruncate(): VOP_GETATTR didn't return 0' at kern_ftruncate+0x152 (confirmed identically across an initial run and a fresh vm.sh reset) by opening an fd on a loopback-NFS file, invalidating that filehandle server-side (rm+touch -> stale handle, server still up), then ftruncate(fd): VOP_GETATTR_FP -> NFS GETATTR on the stale handle -> NFSERR_STALE -> nfs_getattr() returns ESTALE -> KASSERT fires. Notable refinement of the finding: a merely-DEAD NFS server does NOT trip the bug on master because nfs_getattr() (sys/vfs/nfs/nfs_vnops.c:685-738) serves cached/local attrs with error=0 on transport failure; only an application-level GETATTR error (ESTALE) reaches the KASSERT. The local hammer2 root FS cannot fail GETATTR at all. No memory corruption -> no exploit chain; impact ceiling is denial of service.