β¬’ DragonFlyBSD Kernel Audit
← triage Β· 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.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0001 Β· 22 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
run_fix_validate.sh fix-run-script same choreography minus the INVARIANTS-string precondition (used to re-run the trigger on the patched #1 kernel, where the strings are intentionally absent) 3.5 KB view raw
VERDICT.md verdict full mechanism walkthrough + Phase-8 fix-validation (baseline panic vs patched clean-error) 11.5 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; VALIDATED on a built #1 kernel 853 B view raw
panic.txt panic-signature serial-console panic from prior session: kern_ftruncate(): VOP_GETATTR didn't return 0 at kern_ftruncate+0x152 475 B view raw
baseline_panic.txt panic-signature Phase-8 baseline re-confirmation on #0: same panic signature 475 B view raw
baseline_run.log run-log Phase-8 baseline run.sh output (full): trigger sequence + panic signature on #0 1.4 KB view raw
baseline_boot.log serial-log full untrimmed serial log of the baseline (#0) panicking boot 13.3 KB view raw
fix_build.log build-log FULL nativekernel build of the single-fix kernel (35393 lines): vfs_syscalls.o recompiled, kernel.stripped linked, rc=0 5.6 MB ↓ download
fix_run.log run-log Phase-8 patched-#1 PoC re-run (2x determinism): ftruncate returns ESTALE cleanly, guest up, NO panic 1.4 KB view raw
run.log run-log prior-session decisive confirmation run (fresh vm.sh reset), step-by-step 4.1 KB view raw
boot.log.full serial-log prior-session full untrimmed serial log of the panicking boot 13.2 KB ↓ download
build.log build-log prior-session 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 5.0 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B 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 + Phase-8 fix-validation (baseline panic vs patched clean-error)
↓ 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.


Fix validation (Phase 8 β€” built + booted a single-fix kernel)

fix_status: fixed β€” clean before/after on the same guest.

Baseline (unpatched #0)

  • kern.version: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026
  • KASSERT panic strings present in /boot/kernel/kernel (verified: both kern_truncate(): VOP_GETATTR didn't return 0 and kern_ftruncate(): VOP_GETATTR didn't return 0).
  • Ran run.sh end-to-end (loader tunable vfs.quota_enabled=1 + reboot + loopback NFS export + ESTALE handle invalidation + ftruncate(2)): PANIC β€” guest halted in DDB. Serial-console signature (baseline_panic.txt): panic: kern_ftruncate(): VOP_GETATTR didn't return 0 cpuid = 1 Trace beginning at frame 0xfffff801185a3798 kern_ftruncate() at kern_ftruncate+0x152 0xffffffff80705e22 kern_ftruncate() at kern_ftruncate+0x152 0xffffffff80705e22 sys_xsyscall() at sys_xsyscall+0x89 0xffffffff80bd7039 syscall2() at syscall2+0x11e 0xffffffff80bd6a0e Debugger("panic") Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip) db>

Patched (single-fix #1)

  • kern.version: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 17:15:30 UTC 2026
  • sha256 /boot/kernel/kernel: f30fe81d658b01e8fcf2469190aae82f0c5b67961c112da2314f5bece9999902
  • Build: make -j6 nativekernel KERNCONF=X86_64_GENERIC from /usr/src with fix.diff applied (vfs_syscalls.o recompiled, kernel.debug re-linked, kernel.stripped produced, rc=0). Full output: fix_build.log (35393 lines). Build time β‰ˆ 6 min on warm obj.
  • KASSERT panic strings ABSENT from /boot/kernel/kernel post-build (verified: strings /boot/kernel/kernel | grep "VOP_GETATTR did" returns nothing β€” the assertion was successfully removed).
  • Ran the same ESTALE choreography (run_fix_validate.sh) β€” twice for determinism:
  • NO PANIC. kern_ftruncate now propagates the GETATTR error to the caller instead of asserting. Trigger output: estale_trig: DIAG fstat (VOP_GETATTR) returned error: errno: Stale NFS file handle estale_trig: FTRUNCATE returned error (...): errno: Stale NFS file handle (Both lines are reached β€” on the unpatched kernel, the ftruncate line was never reached because the KASSERT fired first.)
  • Guest stays UP after the trigger; boot.log contains no panic: / Stopped at / db> line.

Verdict

The fix converts a kernel panic() (CWE-617 reachable assertion, local DoS on INVARIANTS+quota hosts) into a clean error-return: truncate(2) / ftruncate(2) now return the underlying VOP_GETATTR error (ESTALE for the NFS stale-handle case, EIO for transport failures, etc.) to the caller, matching the documented syscall contract. Before/after is deterministic across two independent runs on the patched kernel.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. fix.diff applies cleanly to /usr/src (patch -p1, both hunks succeeded), compiles (nativekernel rc=0, vfs_syscalls.o rebuilt, kernel.stripped linked, panic strings absent from the binary), and the previously-panicking PoC on the unpatched #0 baseline (panic: kern_ftruncate(): VOP_GETATTR didn't return 0 at kern_ftruncate+0x152, guest halted in DDB) does NOT panic on the single-fix #1 kernel: ftruncate(2) now returns ESTALE (Stale NFS file handle) cleanly and the guest stays up. Confirmed deterministic across two independent runs on the patched kernel. fix_status=fixed.

baseline #0: panic: kern_ftruncate(): VOP_GETATTR didn't return 0 ; kern_ftruncate() at kern_ftruncate+0x152 ; Debugger("panic") ; db> (guest down)\npatched #1: estale_trig: DIAG fstat returned ESTALE ; estale_trig: FTRUNCATE returned ESTALE (no panic, guest up, boot.log clean)\nbuild: nativekernel rc=0, vfs_syscalls.o recompiled, kernel.debug linked (12378929 bytes), kernel.stripped produced
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 17:15:30 UTC 2026 (sha256 /boot/kernel/kernel = f30fe81d658b01e8fcf2469190aae82f0c5b67961c112da2314f5bece9999902)

Confirmed kernel references

Detail

Exploit chain

Local denial-of-service via reachable KASSERT panic in kern_truncate()/kern_ftruncate() on INVARIANTS+quota hosts when VOP_GETATTR fails (NFS ESTALE on a stale open-fd filehandle is the clean trigger). CWE-617 reachable assertion; no memory corruption, no privilege escalation. Not a memory-corruption class so no further primitive is derivable.

Evidence (decisive lines)

BASELINE (#0 unpatched): panic: kern_ftruncate(): VOP_GETATTR didn't return 0\n  kern_ftruncate() at kern_ftruncate+0x152 0xffffffff80705e22\n  sys_xsyscall() at sys_xsyscall+0x89\n  syscall2() at syscall2+0x11e\n  Debugger("panic"); Stopped at Debugger+0x7c; db>\nPATCHED (#1, single-fix kernel): estale_trig: DIAG fstat (VOP_GETATTR) returned error: errno: Stale NFS file handle\n  estale_trig: FTRUNCATE returned error (...): errno: Stale NFS file handle\n  guest stays up; boot.log has no panic / no db> prompt. (Both lines UNREACHED on baseline because the KASSERT fired first.)

PoC changes

Fixed a regex bug in run.sh's INVARIANTS-string precondition (pattern 'did.n.t' only matched 'did not', not the kernel's literal 'didn\'t'; replaced with 'kern_.truncate.VOP_GETATTR' which is robust). Added run_fix_validate.sh: same choreography as run.sh but without the KASSERT-string precondition, so it can be run on the patched #1 kernel (where the strings are intentionally absent) to verify the no-panic/error-return behavior. Saved new artifacts: baseline_run.log, baseline_panic.txt, baseline_boot.log, fix_build.log (full nativekernel output, 35393 lines), fix_run.log (patched-kernel PoC re-run x2).

Verified recommended fix

In sys/kern/vfs_syscalls.c, replace both KASSERT(error == 0, ...) calls with proper error-returns: kern_truncate at :4038 -> 'if (error) goto done;' (the done: label at :4051 already does vput(vp)); kern_ftruncate at :4113 -> 'if (error) { vn_unlock(vp); goto done; }' (vn_unlock needed because done: at :4128 sits after the vn_unlock at :4126, and fdrop(fp) at done releases the fp). Propagates the GETATTR error to the caller instead of panicking. Matches the finding markdown's ## Recommended fix proposal (the runner's diff additionally adds the explicit vn_unlock before goto done in kern_ftruncate to avoid leaking the vnode lock). Standalone git-apply-able diff in findings/poc/DF-0001/fix.diff; VALIDATED on a built #1 kernel.

Verdict

REPRODUCED on the unpatched master DEV #0 kernel: the KASSERT at sys/kern/vfs_syscalls.c:4113 (and its twin at :4038) fires deterministically when VOP_GETATTR returns nonzero on a quota-enabled (vfs.quota_enabled=1) INVARIANTS kernel. Confirmed by re-running the ESTALE choreography (open fd -> server-side delete+recreate -> stale NFS filehandle -> ftruncate(2) -> VOP_GETATTR_FP returns ESTALE -> KASSERT panics). Serial-console signature: 'panic: kern_ftruncate(): VOP_GETATTR didn't return 0' at kern_ftruncate+0x152, reached via the normal syscall2 -> sys_xsyscall -> kern_ftruncate path from the unprivileged maxx user. Phase-8 fix-validation then applied fix.diff, built a single-fix #1 kernel (rc=0, vfs_syscalls.o recompiled, panic strings absent from /boot/kernel/kernel), installed+booted it, and re-ran the SAME PoC: no panic, guest stays up, both fstat and ftruncate now propagate ESTALE cleanly to the caller. Fix is deterministically effective across two runs.