โฌข DragonFlyBSD Kernel Audit
โ† dashboard
DF-0001

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 an INVARIANTS kernel.
  • Privileges gained or impact: kernel panic (denial of service).
  • Required config or capabilities: INVARIANTS kernel and quota enabled and a filesystem that can fail GETATTR post-lookup (NFS is the realistic case). No privilege beyond write access to the target.
  • Reachability: truncate(2) โ†’ kern_truncate and ftruncate(2) โ†’ kern_ftruncate, directly. A malicious/in-the-path NFS server returning NFSERR_IO/NFSERR_STALE for GETATTR makes 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.

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 โ€” KASSERT expands to panic under INVARIANTS.
  • sys/kern/vfs_quota.c โ€” vfs_quota_enabled tunable 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
FileTypeDescriptionSize
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
README.md readme human-facing build/run/preconditions + file index
โ†“ download 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)

  1. options INVARIANTS kernel. The audited guest's X86_64_GENERIC kernel does ship INVARIANTS โ€” 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.
  2. vfs.quota_enabled=1. The sysctl is CTLFLAG_RD, so it is a boot loader tunable (/boot/loader.conf: vfs.quota_enabled="1") and requires a reboot. Default is 0.
  3. A filesystem whose VOP_GETATTR returns 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 with error=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:

  1. verifies the running kernel has the live KASSERT (INVARIANTS on);
  2. sets vfs.quota_enabled="1" in /boot/loader.conf and reboots (non-reverting vm.sh down && vm.sh up);
  3. 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,...);
  4. as the unprivileged user (maxx, uid 1001, not in wheel), opens /mnt/estale_target, holding a fixed filehandle on the fd;
  5. invalidates that filehandle server-side (rm + touch โ†’ new inode, client fd now references a stale handle, server still UP);
  6. the process wakes and calls ftruncate(fd, 0) โ†’ kern_ftruncate โ†’ VOP_GETATTR_FP โ†’ GETATTR RPC on the stale handle โ†’ server returns NFSERR_STALE โ†’ nfs_getattr returns ESTALE โ†’ KASSERT(error==0) at vfs_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
VERDICT.md verdict full mechanism walkthrough: why dead-server doesn't fire but ESTALE does, path:line at every hop
โ†“ download raw

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) and nfs_getattr() (sys/vfs/nfs/nfs_vnops.c:685-738) returns cached/local attributes with error=0 rather than propagating the transport error (the attribute cache at sys/vfs/nfs/nfs_subs.c:885-933 serves the GETATTR, and for a client-written file the NLMODIFIED local-attr path makes the hit sticky). So truncate()/ftruncate() propagate the error only from the later VOP_SETATTR RPC (as EINTR/EIO), and the KASSERT โ€” which sits on GETATTR, before SETATTR โ€” 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 open fd (fixed vnode/filehandle), the server deletes and recreates the file (new inode โ†’ old filehandle is now stale), and the server โ€” still up and responding โ€” returns NFSERR_STALE for the GETATTR RPC on the stale handle. nfsm_request + the NEGKEEPOUT/ ERROROUT macros (sys/vfs/nfs/nfsm_subs.h:109-124) propagate that error out of nfs_getattr() (return (error) at nfs_vnops.c:737), so VOP_GETATTR_FP returns ESTALE to kern_ftruncate, and the KASSERT at vfs_syscalls.c:4113 fires.

Trigger choreography (see run.sh)

  1. Boot vfs.quota_enabled=1 (loader tunable, reboot).
  2. 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,...).
  3. As the unprivileged user maxx (uid 1001, not in wheel), open() /mnt/estale_target โ†’ fd holds a fixed vnode/filehandle; sleep.
  4. Server-side: rm /export/estale_target && touch /export/estale_target (new inode; the client fd's handle is now stale; server still UP).
  5. The process wakes and calls ftruncate(fd, 0): kern_ftruncate โ†’ VOP_GETATTR_FP โ†’ NFS GETATTR RPC on the stale filehandle โ†’ server returns NFSERR_STALE โ†’ nfs_getattr returns ESTALE โ†’ KASSERT(error == 0, ...) at vfs_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 original trunc_panic.c (path-based truncate against 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.c implements that choreography.
  • Added trunc_only.c โ€” errno-printing diagnostic that proved (via the EINTR-from-SETATTR result) that the dead-server path returns GETATTR=0 and therefore cannot trip the KASSERT. Kept as the negative evidence / baseline.
  • Sharpened trunc_panic.c to 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.sh builds all three sources as the unprivileged user.

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.