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

setfown() leaks vnode exclusive lock and reference on VOP_GETATTR failure causing permanent file DoS

Summary

setfown calls vget(vp LK_EXCLUSIVE) to lock and reference vnode then calls VOP_GETATTR to read old uid/gid/size for quota accounting. If VOP_GETATTR fails function returns immediately without calling vput(vp) permanently leaking exclusive vnode lock and reference. Vnode permanently locked exclusively every subsequent operation on that inode blocks forever leaked reference prevents vnode reclaim consuming kernel memory. Only vget-then-VOP_GETATTR site with this pattern setfflags/setfmode/kern_futimens all call vput unconditionally. NFS primary vector when server unreachable VOP_GETATTR returns ESTALE/EIO. Attacker calls chown on NFS file while server down vnode permanently locked.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2550 Β· 18 files
FileTypeDescriptionSize
trigger.c trigger-source unprivileged fchown poll-loop trigger (maxx) 2.2 KB view raw
run.sh run-script baseline NFS repro driver (sets up NFS, kills server, captures panic) 3.9 KB view raw
verify_fix.sh run-script fix-validation driver (same repro, expects guest to stay up) 2.7 KB view raw
build.sh build-script cc -o trigger trigger.c 154 B view raw
fix.diff suggested-fix git-apply-able: add vput(vp) before early return on VOP_GETATTR failure 476 B view raw
panic.txt panic-signature panic: lockmgr locking against myself (sys_fchown->setfown->vget->vn_lock) 784 B view raw
run.log run-log baseline repro log (NFS setup, kill server, panic detected) 1.6 KB view raw
trigger.out run-log baseline trigger output: rc=0 -> rc=-1 EINTR (leak) -> panic 938 B view raw
fix_run.log run-log patched-kernel validation log (no panic, guest up) 1.2 KB view raw
trigger_fix.out run-log patched trigger output: rc=-1 EINTR x2 repeated, no panic 1.0 KB view raw
fix_build.log build-log full single-fix kernel build output (NK_DONE rc=0) 5.6 MB ↓ download
dmesg.txt dmesg NFS 'not responding' / 'send error 61' kernel messages 460 B view raw
env.txt environment uname/kern.version baseline #0 + patched #1, cc 8.3, vfs.usermount=0 403 B view raw
probe_fifo.c investigation probe: FIFO getattr path (ruled out) 1005 B view raw
probe_procfs2.c investigation probe: procfs reaped-proc getattr (ruled out) 2.3 KB view raw
probe_kill.c investigation probe: procfs SIGKILL+reap getattr (ruled out) 2.3 KB view raw
README.md readme 6.0 KB ↓ raw
VERDICT.md verdict 6.4 KB ↓ raw
README.md readme
↓ download raw

DF-2550 β€” setfown() vnode lock+reference leak on VOP_GETATTR failure

Severity: Medium (local DoS / kernel panic) File: sys/kern/vfs_syscalls.c β€” setfown() Status: REPRODUCED (kernel panic) + FIX VALIDATED (panic gone on single-fix kernel)

The bug

setfown() (called by chown/fchown/fchownat/lchown) acquires an exclusive vnode lock and a reference with vget(vp, LK_EXCLUSIVE), then reads the old uid/gid/size via VOP_GETATTR(vp) for quota accounting. If VOP_GETATTR fails, the function returns immediately without calling vput(vp) β€” permanently leaking the exclusive vnode lock and the vget-added reference.

// sys/kern/vfs_syscalls.c  (setfown, around line 3541)
    if ((error = vget(vp, LK_EXCLUSIVE)) == 0) {
        if ((error = VOP_GETATTR(vp, &vattr)) != 0)
            return error;            //  <-- BUG: no vput(vp); lock+ref leaked
        ...
        error = VOP_SETATTR(vp, &vattr, td->td_ucred);
        vput(vp);                    //  only reached on the success path
    }

Effect

The leaked exclusive lock means the caller's very next operation that vget()s the same vnode deadlocks against its own leaked lock. Because the holder is the same thread, DragonFly's lockmgr detects the self-deadlock and panics:

panic: lockmgr: locking against myself
vn_lock() at vn_lock+0xc0
vget()   at vget+0x3e
setfown() at setfown+0x39
sys_fchown() at sys_fchown+0x8b

i.e. an unprivileged fchown() on a vnode whose VOP_GETATTR can fail crashes the kernel. If the same path were hit by two different threads/processes, the second would block forever instead of panicking (classic permanent DoS).

Trigger (realistic)

VOP_GETATTR fails on a vnode whose backing filesystem reports an error. The classic unprivileged case is a remote filesystem (NFS) whose server becomes unavailable (server crash / network partition / service stopped): the NFS client's getattr RPC eventually errors (nfs send error 61 / "not responding"), so setfown()'s VOP_GETATTR returns a non-zero error and the leak fires.

Sequence, all as the unprivileged user maxx: 1. fchown(fd) in a loop on an open fd into an NFS mount (fd is a normal user file descriptor β€” no privilege required). 2. The NFS server is stopped / becomes unreachable (environmental condition: server crash, partition, admin service nfsd stop). This is the only precondition and it does not require the attacker to be root. 3. Once VOP_GETATTR starts failing, the loop's iteration N returns the error (the leak occurs), and iteration N+1 vget()s the leaked-locked vnode β†’ kernel panic (or, from a different thread, permanent hang).

What does NOT trigger it (and why)

The audit exhaustively checked the other ways to make VOP_GETATTR fail on this guest and ruled them out (see VERDICT.md for full detail): - tmpfs / ufs / hammer2 getattr read in-memory metadata and never fail. - umount -f of a held mount kills every process holding an fd into it (unmount_allproc_cb SIGINT/SIGKILL in dounmount) before the vnode goes dead, so no live caller survives to observe a dead vnode. - revoke(2) sets FREVOKED on the file descriptor (fdrevoke), so fchown fails at holdvnode before ever reaching setfown. - procfs with a reaped target process: DragonFly defers proc-struct reclamation, so pfs_pfind(pid) keeps returning the (gone-from-ps) proc with a valid p_ucred and VOP_GETATTR keeps succeeding. - NFS attribute cache would normally mask the failure (returns cached attrs); the PoC defeats it with -o acregmin=0,acregmax=0.

NFS server-death is therefore the demonstrated realistic trigger.

Reproduce

# from the host repo root (guest already booted on the unpatched #0 kernel):
cd findings/poc/DF-2550
scp -F ../../../dfbsd-qemu/config trigger.c dfbsd-maxx:poc/DF-2550/   # (mkdir first)
ssh -F ../../../dfbsd-qemu/config dfbsd-maxx 'cd poc/DF-2550 && cc -o trigger trigger.c'
./run.sh                # sets up local NFS server+soft mount, runs trigger, kills nfsd

run.sh brings up a local NFS server (rpcbind/mountd/nfsd) as root, mounts 127.0.0.1:/export over UDP (soft,-t 1,-x 1, no attribute cache), launches maxx's trigger (an fchown poll-loop), kills the NFS server, and waits. Expected on the unpatched kernel: the guest panics (lockmgr: locking against myself, setfown→vget→vn_lock in the trace) and vm.sh status ⇒ down. The panic signature is captured to panic.txt.

Fix

Add vput(vp) on the VOP_GETATTR-failure early-return path (see fix.diff):

        if ((error = VOP_GETATTR(vp, &vattr)) != 0) {
            vput(vp);
            return error;
        }

This releases the exclusive lock and reference acquired by vget, so the next vget on the vnode no longer self-deadlocks.

Fix validation

verify_fix.sh repeats the exact repro on a single-fix kernel built from fix.diff (6.5-DEVELOPMENT #1). Result: no panic β€” the fchown loop returns the VOP_GETATTR error (EINTR) cleanly on iteration after iteration (rc=-1 errno=4), the guest stays up, and the serial log shows no panic. Baseline #0 panicked at the identical point. See fix_run.log / fix_build.log.

Files

  • trigger.c β€” unprivileged fchown poll-loop trigger.
  • run.sh β€” baseline repro driver (sets up NFS, kills server, captures panic).
  • verify_fix.sh β€” fix-validation driver (same repro, expects guest to stay up).
  • fix.diff β€” one-line git apply-able fix.
  • panic.txt β€” kernel panic trace from the baseline repro.
  • run.log / trigger.out β€” baseline run output (shows rc=0 β†’ rc=-1 EINTR β†’ panic).
  • fix_run.log / trigger_fix.out β€” patched-kernel output (rc=-1, no panic, guest up).
  • fix_build.log β€” full single-fix kernel build log.
  • env.txt, dmesg.txt β€” guest environment + NFS kernel messages.
  • VERDICT.md, manifest.json β€” narrative + machine catalog.
  • probe_*.c β€” investigation probes that ruled out fifo/tmpfs/revoke/procfs paths.
VERDICT.md verdict
↓ download raw

DF-2550 β€” VERDICT

Verdict: REPRODUCED (kernel panic / local DoS). FIX VALIDATED (panic eliminated on the single-fix kernel).

Root cause (confirmed line-by-line)

setfown() in sys/kern/vfs_syscalls.c (around line 3528) is invoked by sys_chown/sys_lchown/sys_fchown/sys_fchownat to change a vnode's owner. It does:

3541:   if ((error = vget(vp, LK_EXCLUSIVE)) == 0) {        // lock + reference
3542:       if ((error = VOP_GETATTR(vp, &vattr)) != 0)
3543:           return error;                                // BUG: no vput(vp)
...
3551:       error = VOP_SETATTR(vp, &vattr, td->td_ucred);
3552:       vput(vp);                                       // only on success path
3553:   }

vget(vp, LK_EXCLUSIVE) (sys/kern/vfs_lock.c:571) increments v_refcnt and acquires the vnode's exclusive lock. The matching release is vput(vp) (unlock + drop ref). On the VOP_GETATTR-failure path (line 3543) the function returns without vput, so:

  • the vnode is left exclusively locked (by the calling thread), and
  • the vget-added reference is leaked.

Primitive β†’ effect

The leaked exclusive lock means the same thread's next operation that vget()s the vnode tries to take LK_EXCLUSIVE on a lock it already holds. DragonFly's lockmgr detects this self-deadlock and panics:

panic: lockmgr: locking against myself
lockmgr_exclusive() at lockmgr_exclusive+0x3e0
vn_lock()           at vn_lock+0xc0
vget()              at vget+0x3e
setfown()           at setfown+0x39
sys_fchown()        at sys_fchown+0x8b

If the second access came from a different thread/process, the result would be a permanent block (DoS) rather than a panic. Either way it is an unprivileged denial of service: the caller only needs an open fd on the vnode, and the trigger condition is simply a VOP_GETATTR that can return an error.

Trigger path (how VOP_GETATTR is made to fail, realistically)

VOP_GETATTR fails on a vnode whose backing filesystem returns an error. The demonstrated, realistic, unprivileged case is NFS server unavailability (server crash / network partition / service nfsd stop). The NFS client's getattr RPC eventually errors:

kernel: nfs server 127.0.0.1:/export: not responding
kernel: nfs send error 61 for server 127.0.0.1:/export

so setfown()'s VOP_GETATTR returns non-zero and the leak fires.

Reproduction evidence (baseline kernel 6.5-DEVELOPMENT #0, INVARIANTS ON)

trigger (run as unprivileged maxx, uid 1001) opens /mnt/nfs/f on a soft-mounted local NFS export and calls fchown(fd,-1,-1) in a loop. After the NFS server is killed:

[trigger] iter 17: fchown rc=0 errno=0 (ok)          # getattr still ok (cached/server-up)
[trigger] iter 18: fchown rc=-1 errno=4 (Interrupted system call)   # VOP_GETATTR FAILED -> LEAK (no vput)
# iter 19: fchown -> vget -> vn_lock on the leaked lock -> PANIC
panic: lockmgr: locking against myself    (trace: sys_fchown -> setfown -> vget -> vn_lock)

The guest goes down (vm.sh status β‡’ down); the panic is in boot.log (captured to panic.txt).

Precondition realism

  • The attacker is unprivileged (maxx, not in wheel); only a normal fd into an NFS mount is needed.
  • The trigger condition β€” the NFS server becoming unreachable β€” is an ordinary environmental event (server crash, network partition, admin stopping the service, removable network). No root cooperation is required of the attacker.
  • The default X86_64_GENERIC kernel (INVARIANTS ON) is used; the panic is not an INVARIANTS-only artifact (it is a lockmgr self-deadlock, which fires regardless of INVARIANTS).

Why other failing-getattr paths were ruled out (thoroughness)

Path Result Why it does not trigger here
FIFO on hammer2 root getattr succeeds hammer2_fifo_vops overrides .vop_getattr = hammer2_vop_getattr (the vop_ebadf in the base fifo_vnode_vops is not used for hammer2 FIFOs).
tmpfs / ufs / hammer2 regular getattr never fails they read in-memory metadata and unconditionally return 0.
umount -f of a held mount holder is killed dounmount()'s unmount_allproc_cb (vfs_syscalls.c ~922-947) SIGINT/SIGKILLs every process with an fd into the mount before vnodes go dead; no live caller survives to observe a dead vnode.
revoke(2) on an owned file fd itself revoked vrevoke→fdrevoke marks the file descriptor FREVOKED; fchown fails at holdvnode before reaching setfown.
procfs, reaped target proc getattr still succeeds DragonFly defers proc-struct reclamation; pfs_pfind(pid) keeps returning the (gone-from-ps) proc with a valid p_ucred for many seconds, so procfs_getattr never takes its ENOENT branch.
NFS attribute cache masks the failure defeats with -o acregmin=0,acregmax=0 so every getattr issues a real RPC.

NFS server-death is therefore the canonical reachable failing-getattr trigger.

Exploit chain

This is a denial-of-service / panic primitive, not a memory-corruption primitive β€” there is no uid=0 chain. The leaked object is a vnode lock (not a slab object), so the impact ceiling is local kernel panic / permanent hang (DoS). No escalation primitive is derivable; this is honestly reported as a DoS.

Fix (validated)

fix.diff adds the missing vput(vp) on the early-return path:

        if ((error = VOP_GETATTR(vp, &vattr)) != 0) {
            vput(vp);
            return error;
        }

Validation (single-fix kernel 6.5-DEVELOPMENT #1)

Built from fix.diff only (make -j6 nativekernel KERNCONF=X86_64_GENERIC), installed over /boot/kernel/kernel, rebooted, kern.version β‡’ #1. The identical repro (verify_fix.sh) now:

[trigger] iter 17: fchown rc=0 errno=0 (ok)
[trigger] iter 18: fchown rc=-1 errno=4 (Interrupted system call)   # getattr fails, vput releases lock -> no leak
[trigger] iter 19: fchown rc=-1 errno=4 (Interrupted system call)   # loop KEEPS GOING, no panic

Guest stays UP; serial log has no panic. Contrast with the baseline, which panicked at iteration 19. fix_status = fixed.

PoC changes

The PoC directory was authored from scratch (it was empty on arrival): trigger.c (unprivileged fchown poll-loop), run.sh (NFS repro driver), verify_fix.sh (fix-validation driver), fix.diff, plus investigation probes (probe_fifo.c, probe_procfs2.c, probe_kill.c) that document the ruled-out paths.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. Identical NFS repro (verify_fix.sh) on unpatched 6.5-DEVELOPMENT #0 baseline panics ('lockmgr: locking against myself', guest down) right after first failing VOP_GETATTR (iter 18 rc=-1 EINTR). On single-fix #1 kernel (built from fix.diff only) the SAME failing-getattr condition is reached (iter 18 rc=-1 EINTR) but fchown loop KEEPS RUNNING (iter 19 rc=-1 EINTR, ...) with NO panic and guest stays UP. Fix's vput(vp) releases lock on error path, eliminating self-deadlock.

baseline #0 trigger.out: iter17 rc=0 ; iter18 rc=-1 errno=4 (EINTR, leak) ; iter19 -> panic 'locking against myself' (guest DOWN). patched #1 trigger_fix.out: iter17 rc=0 ; iter18 rc=-1 errno=4 (EINTR) ; iter19 rc=-1 errno=4 ; ... loop continues, NO panic, guest UP, serial log clean.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 21:54:58 UTC 2026 (single-fix kernel, sha256 287220205846303a6bf27333ed6099c1562d99e5ca8a4350110718b67892e6c6)

Confirmed kernel references

Detail

Exploit chain

DoS/panic primitive, NOT memory corruption (leaked object is a vnode lock, not a slab object) -> no uid=0 escalation chain exists or is claimed. Chain: unprivileged fchown(fd) on an fd into a soft NFS mount whose server died -> setfown vget(vp,LK_EXCLUSIVE) then VOP_GETATTR returns EINTR/EIO -> return-without-vput leaks exclusive lock+ref -> next fchown's vget->vn_lock(LK_EXCLUSIVE) on same-thread-held lock -> panic 'lockmgr: locking against myself' (kernel crash = local DoS). If second access from different process it blocks forever (permanent hang). Impact ceiling = local DoS / kernel panic; no escalation file authored.

Evidence (decisive lines)

baseline trigger.out: [trigger] iter 17: fchown rc=0 errno=0 (ok) / [trigger] iter 18: fchown rc=-1 errno=4 (Interrupted system call) <- VOP_GETATTR failed -> setfown leaked lock (no vput) (iter 19 panics). panic.txt: panic: lockmgr: locking against myself / vn_lock() at vn_lock+0xc0 / vget() at vget+0x3e / setfown() at setfown+0x39 / sys_fchown() at sys_fchown+0x8b (serial: 'nfs server 127.0.0.1:/export: not responding', 'nfs send error 61').

PoC changes

Authored entire evidence pack from scratch (dir empty): trigger.c (unprivileged fchown poll-loop), run.sh (NFS repro driver), verify_fix.sh (fix-validation driver), build.sh, fix.diff, VERDICT.md, README.md, manifest.json, plus investigation probes (probe_fifo.c, probe_procfs2.c, probe_kill.c) documenting ruled-out fifo/tmpfs-force-unmount/revoke/procfs paths. Fixed multiple reviewer-env issues: tcsh login-shell quoting (pipe commands to /bin/sh), mount_nfs option syntax, NFS attribute-cache masking.

Verified recommended fix

In setfown() sys/kern/vfs_syscalls.c:3542-3543, add vput(vp) before early 'return error' on VOP_GETATTR failure so exclusive lock and vget reference acquired at line 3541 are released: 'if ((error = VOP_GETATTR(vp, &vattr)) != 0) { vput(vp); return error; }'. New fix (finding dir had no prior proposal); matches obvious correct release, validated by building+booting single-fix kernel. Full git-apply-able diff in findings/poc/DF-2550/fix.diff.

Verdict

REPRODUCED as kernel panic (local DoS). setfown() in sys/kern/vfs_syscalls.c:3541-3543 does vget(vp, LK_EXCLUSIVE) (lock+ref) then VOP_GETATTR(vp); on VOP_GETATTR failure it does 'return error' WITHOUT vput(vp), leaking the exclusive vnode lock and the vget reference. The caller's next vget() of the same vnode (same thread) tries to acquire LK_EXCLUSIVE on its own leaked lock and lockmgr panics: 'lockmgr: locking against myself' with trace sys_fchown->setfown->vget->vn_lock (captured in panic.txt from baseline #0 kernel). Triggered unprivileged: maxx holds an fd on an NFS-mounted file and loops fchown(); when the NFS server is stopped (realistic server-death/partition condition), VOP_GETATTR's RPC errors ('nfs send error 61' / 'not responding'), the leak fires, and the next fchown panics the guest.