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

nfs_nget_nonblock revalidation restart retains a stale vp across vput(vp); goto loop β€” any execution of the NFSTOV(np) != vp branch double-vputs the same vnode (refcount underflow β†’ premature vnode recycling / assert panic); the adjacent retry path's own comment states the rule this restart forgot

Summary

In nfs_nget_nonblock() the post-vget rescan restart (:265-278) does vput(vp); goto loop at :271-273 without resetting vp. On the restarted scan, the next matching node (or the list-exhausted cleanup at :284-287) vputs the stale vp AGAIN - double-vput -> vrele refcount underflow -> vnode enters cached state while still lock-held/dirty -> assert panic or use-after-free on reuse. The branch requires a stale bucket view (preemption of the token-holding scanner inside the restart window plus reclaim+recreation by a third thread) - the same window class as DF-3194 but narrower. Trigger surface: concurrent getdents racing vnode recycle (called only from nfs_readdirplusrpc). No standalone PoC - filed on inspection; the one-line fix (vp=NULL before goto) compiled and booted as hunk 3 of DF-3194's fix.diff in the DF-3195 validation kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3196 Β· 2 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
verdict.json β€” 2.5 KB view raw

DF-3196 β€” nfs_nget_nonblock revalidation restart keeps a stale vp across vput(vp); goto loop β†’ double-vput / refcount underflow (latent; preemption-window race)

What this is

In nfs_nget_nonblock() (sys/vfs/nfs/nfs_node.c), after a successful vget(vp, LK_EXCLUSIVE|LK_NOWAIT) the scan restarts with goto loop (:269). If, on the restarted scan, the first matching node's vnode differs from the carried vp (:271), the code does:

if (NFSTOV(np) != vp) {
        vput(vp);          /* :272 β€” ref dropped ...          */
        goto loop;         /* :273 β€” ... but vp NOT reset      */
}

vp stays non-NULL across the restart, so the next matching node either (a) triggers vput(vp) a second time (double-vput β†’ vrele refcount underflow β†’ vnode freed/cached while still referenced β†’ use-after-free potential, in practice a vrele assert/panic), or (b) if the list then exhausts, the if (vp) { vput(vp); ... } cleanup at :284-287 vputs the stale vp again β€” deterministically, once the branch is entered. Contrast the retry path two pages down, whose comment states the rule explicitly: "vp state is retained on retry/loop so we must NULL it out here or fireworks may ensue" (:344-348) β€” the :272-273 restart forgot it.

Reachability of :271 itself requires the bucket to mutate between the NOWAIT-vget restart and the rescan β€” possible only because lwkt_gettoken holds no critical section and lwkt_switch() releases all tokens (sys/kern/lwkt_thread.c:636-638): a preempted scanner can return to a bucket whose matching node was reclaimed and re-created (different vnode) by another thread. This is the same class of stale-view window as DF-3194, narrower (needs preemption inside a ~2-instruction window plus a re-creation by a third thread).

nfs_nget_nonblock is called from nfs_readdirplusrpc (sys/vfs/nfs/nfs_vnops.c:2891) β€” i.e. the trigger surface is concurrent getdents on NFS directories racing vnode reclaim/recreation, an unprivileged local operation (with server cooperation for fh stability).

Status

Not dynamically verified β€” the preemption window is far narrower than DF-3194's (which itself could not be triggered in ~620k attempts on the QEMU guest). Verdict: untested, severity Low, confidence likely (the defect is directly visible in the code; the fix is the one-line vp = NULL; mirror of :348).

Fix

Included as hunk 3 of findings/poc/DF-3194/fix.diff:

        if (NFSTOV(np) != vp) {
            vput(vp);
+           /* vp is stale after vput(): NULL it or the restart
+            * double-vputs the same vnode (refcount underflow). */
+           vp = NULL;
            goto loop;
        }

That fix compiled and booted as part of the DF-3195 validation kernel.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff hunk 3 compiled and booted as part of the DF-3195 validation kernel; behavioral comparison impossible (baseline race not triggerable in-guest).

findings/poc/DF-3194/fix.diff hunk 3; DF-3195 pack run.fixed.log ran on that kernel.
per-fix-DF-3196

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3196/README.md (full analysis)', 'findings/poc/DF-3194/fix.diff hunk 3 (the fix)', 'sys/vfs/nfs/nfs_node.c:271-273 vs :344-348']

PoC changes

n/a (no standalone PoC; would require the same racing harness as DF-3194 plus readdirplus traffic)

Verified recommended fix

vp = NULL; before the goto loop at nfs_node.c:273 (hunk 3 of DF-3194's fix.diff).

Verdict

Latent defect visible by inspection: nfs_nget_nonblock()'s revalidation restart (nfs_node.c:271-273) executes vput(vp) and goto loop without resetting vp, so any execution that takes that branch double-vputs the stale vnode (second vput either at the next matching node or at the :284 cleanup) -> refcount underflow -> premature vnode recycling / vrele assert panic. The branch requires a stale bucket view (preemption of the token-holding scanner inside the restart window plus re-creation of the matching node by a third thread) - the same window class as DF-3194 but narrower; not dynamically triggerable on the QEMU guest within this run (the wider DF-3194 window already failed to fire in ~620k races). The one-line fix (vp = NULL before goto loop, mirroring the :344-348 comment's own rule) is included as hunk 3 of DF-3194's fix.diff and was compiled+booted in the DF-3195 validation kernel.