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)
PoC verification
Evidence pack
findings/poc/DF-3107 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_testableNo 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.
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.
No comments yet.