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

ufs_checkpath uses vp->v_mount after vput() drops intermediate ancestors to refcount 0 β€” read of unreferenced/reclaimable vnode on every deep rename (FreeBSD caches mp; ext2_checkpath shares the bug)

Summary

The '..' ancestor walk ends each iteration with vput(vp) followed immediately by VFS_VGET(vp->v_mount, ...). For intermediate ancestors (VFS_VGET'd inside the loop, refcount 1), the vput performs the 1->0 transition - in DFly vrele that leaves the vnode cached-but-unreferenced (reclaimable at any instant by any concurrent allocvnode/vnlru) or, on the VREF_FINALIZE path, synchronously reclaimed - and the next instruction reads vp->v_mount from that vnode. Deterministic once per rename of a directory into a target >=3 levels deep; a recycled-and-reinstalled vnode yields VFS_VGET on the WRONG mount (cross-filesystem ancestor confusion in the rename guard). FreeBSD's ufs_checkpath caches mp before the loop precisely for this; DFly's ext2_checkpath has the identical defect. Bounded stress (20,000 deep renames + 60,000 vnode churn) did not win the few-instruction window; filed as lifetime/correctness violation with FreeBSD-divergence precedent. Fix: cache the mount pointer before the loop (row diff) + mirror in ext2_checkpath.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3107 Β· 8 files
FileTypeDescriptionSize
stress.sh β€” 1.8 KB view raw
run.sh β€” 125 B view raw
build.sh β€” 61 B view raw
stress.log β€” 798 B view raw
env.txt β€” 215 B view raw
README.md β€” 2.4 KB ↓ raw
verdict.json β€” 3.0 KB view raw
manifest.json β€” 891 B view raw

DF-3107 β€” ufs_checkpath reads vp->v_mount from a zero-ref/reclaimable vnode

What

ufs_checkpath() (sys/vfs/ufs/ufs_lookup.c:1114) walks the target directory's .. chain upward. Each loop iteration ends with:

1160:       vput(vp);
1161:       error = VFS_VGET(vp->v_mount, NULL, dirbuf.dotdot_ino, &vp);

For every intermediate ancestor the vnode was VFS_VGET'd inside this loop and is held with refcount 1, so the vput() at :1160 drops it to refcount 0 β€” in DragonFly's vrele() (sys/kern/vfs_lock.c:345) the 1β†’0 transition leaves the vnode cached-but-unreferenced (instantly reclaimable by any concurrent allocvnode/vnlru), or, with VREF_FINALIZE, runs vnode_terminate() (VOP_RECLAIM) synchronously β€” and the very next instruction reads vp->v_mount from that vnode.

Every rename of a directory into a target β‰₯ 3 levels deep (e.g. mv S /a/b/c/S_moved β€” target dir c, ancestor b) performs this use-after-vrele deterministically; only the consequence (the vnode being recycled and reinstalled by another filesystem between the vput and the read) is racy. A recycled vnode yields VFS_VGET on the wrong mount (cross-filesystem ancestor confusion in the rename guard), and a terminated vnode yields a mount pointer read from a reclaimed object.

FreeBSD's ufs_checkpath caches mp = ITOV(target)->v_mount before the loop and calls VFS_VGET(mp, ...) for exactly this reason. DragonFly's ext2_checkpath (sys/vfs/ext2fs/ext2_lookup.c:1235-1236) carries the identical bug.

Reproduce attempt

stress.sh: 20,000 deep renames on a vn-backed UFS mount (each rename does the zero-ref read on the intermediate ancestor b) while a churn loop performs 60,000 file create/delete cycles for vnode pressure.

Result: no crash, no anomalous dmesg, all renames succeeded β€” the read returned the stale-but-valid mount pointer every time; the recycle race (a window of a few instructions, requiring another CPU to allocate and reinstall that exact vnode in it) was not won. not_reproduced at the effect level; the defect itself is code-certain.

Fix

Cache the mount pointer before the loop, exactly like FreeBSD:

    struct vnode *vp;
+   struct mount *mp;
    ...
    vp = ITOV(target);
+   mp = vp->v_mount;
    ...
        vput(vp);
-       error = VFS_VGET(vp->v_mount, NULL, dirbuf.dotdot_ino, &vp);
+       error = VFS_VGET(mp, NULL, dirbuf.dotdot_ino, &vp);

(The same one-liner applies to ext2_checkpath.)

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

No effect reproduces on an unpatched kernel (race not won), so a fix build cannot be meaningfully validated by observation; the two-line mount-cache fix is recommended from the code path and FreeBSD precedent.

per-fix-DF-3107

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3107/stress.log: 20000 renames + 60000 churn cycles, no crash, guest up', 'sys/vfs/ufs/ufs_lookup.c:1160-1161 (vput then vp->v_mount)', 'sys/kern/vfs_lock.c:345 (vrele 1->0 leaves vnode cached/unreferenced; VREF_FINALIZE path terminates synchronously)']

PoC changes

Original sketch used /tmp for the renames - /tmp is hammer2 on this guest, so ufs_checkpath is never called; rewrote the stress to build the hierarchy on a vn-backed UFS mount. Guest /bin/sh lacks $SECONDS; converted time-based loops to counters.

Verified recommended fix

Cache mp = ITOV(target)->v_mount before the loop in ufs_checkpath and use VFS_VGET(mp, ...) after vput(vp); mirror in ext2_checkpath.

Verdict

The defect is code-certain and occurs deterministically at the instruction level: ufs_checkpath vput()s each intermediate ancestor from refcount 1 to 0 (ufs_lookup.c:1160) and immediately reads vp->v_mount (ufs_lookup.c:1161) - a use of an unreferenced, instantly-reclaimable (or already-reclaimed, under VREF_FINALIZE) vnode, once per rename of a directory into a target >= 3 levels deep. FreeBSD caches the mount pointer before the loop for exactly this reason; DragonFly (and its ext2_checkpath twin at ext2_lookup.c:1235) did not take that fix. The adverse effect requires another CPU to recycle and reinstall that exact vnode between the vput and the read; a bounded stress (20,000 deep renames on a UFS mount + 60,000 vnode churn cycles on the stock guest kernel) did not win the window: no crash, no anomalous dmesg, all renames succeeded. Filed as a correctness/lifetime bug (Medium) rather than a demonstrated exploit.