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)
PoC verification
Evidence pack
findings/poc/DF-2550 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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β unprivilegedfchownpoll-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-linegit 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.
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 inwheel); 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_GENERICkernel (INVARIANTS ON) is used; the panic is not an INVARIANTS-only artifact (it is alockmgrself-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
fixedVALIDATED. 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.
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.
No comments yet.