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

tmpfs_fhtovp passes an unpinned tmpfs_node to tmpfs_alloc_vp; blocking releases the mount token and reclaim frees the node under the caller (window proven by on-guest panic from fhopen race; stock kernels reach a use-after-free at tmpfs_subr.c:382)

Summary

tmpfs_fhtovp walks tm_nodes_used under the mount token, then calls tmpfs_alloc_vp(mp, NULL, node, ...) with no reference pin - unlike tmpfs_unmount (tn_links pin) and tmpfs_alloc_file (dirent link pin). tmpfs_alloc_vp blocks (getnewvnode :383, vget :432), and blocking releases the mount token, so the found node can concurrently go inactive->reclaim->tmpfs_free_node. VERIFIED stock INVARIANTS guest in ~60s with a 4-thread fhopen() race against creat/unlink/close: 'assertion (vp)!=NULL && (vp)->v_data!=NULL failed at tmpfs.h:493', backtrace tmpfs_fhtovp->tmpfs_alloc_vp->vrele->vop_inactive->tmpfs_inactive - the fhtovp-sourced vnode reached inactive with v_data already NULL. Strictly PAST DF-3072's DOOMED-interval assert (:402 did not fire). On no-INVARIANTS kernels the same window re-reads the freed node at tmpfs_subr.c:382 and can attach it to a live vnode as vp->v_data at :468 - heap UAF/type confusion. Remote: any NFS client against a tmpfs exported via mountctl replaying a stale fh while the file's last reference disappears; local fhopen is root-only. Fix: pin the node (tn_links +/-1 around alloc_vp, mirroring tmpfs_unmount).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3180 Β· 12 files
FileTypeDescriptionSize
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 3.7 KB ↓ raw
build.log β€” 66 B view raw
build.sh β€” 68 B view raw
console_full.txt β€” 2.4 KB view raw
env.txt β€” 413 B view raw
fh_race.c β€” 4.0 KB view raw
fix.diff β€” 690 B view raw
panic.txt β€” 1.1 KB view raw
run.log β€” 48 B view raw
run.sh β€” 91 B view raw
verdict.json β€” 3.5 KB view raw

DF-3180 β€” tmpfs_fhtovp hands an unpinned tmpfs_node to tmpfs_alloc_vp; the node can be reclaimed+freed under it (UAF window)

What

tmpfs_fhtovp (sys/vfs/tmpfs/tmpfs_vfsops.c:434-468) finds a node in tm_nodes_used under the mount token and immediately calls tmpfs_alloc_vp(mp, NULL, node, LK_EXCLUSIVE, vpp) β€” with no reference pin on the node. Compare: * tmpfs_unmount (tmpfs_vfsops.c:288, :347) bumps tn_links around every blocking step, * tmpfs_alloc_file (tmpfs_subr.c:568-577) is protected by the dirent link count taken before its tmpfs_alloc_vp call.

tmpfs_alloc_vp blocks (getnewvnode at tmpfs_subr.c:383; vget at :432), and blocking releases the mount token (documented by the code's own comment at tmpfs_vfsops.c:296-302). While the caller is blocked, the last reference on the vnode can go away: tmpfs_inactive (DOOMED) β†’ tmpfs_reclaim (tmpfs_vnops.c:1844-1865) disassociates the vnode (node->tn_vnode = NULL; vp->v_data = NULL) and β€” for an unlinked file (tn_links == 0) β€” frees the node (tmpfs_free_node β†’ kfree_obj). vget then returns ENOENT for the reclaimed vnode (sys/kern/vfs_lock.c:595-601), and tmpfs_alloc_vp executes vdrop(vp); goto loop; (tmpfs_subr.c:432-435) which re-reads node->tn_vnode at :382 β€” freed memory β€” and can install the stale pointer as vp->v_data = node at :468.

Reachability

  • NFS: for every client RPC on a tmpfs exported via mountctl(MOUNTCTL_SET_EXPORT) (supported β€” tmpfs_mountctl, tmpfs_vnops.c:1879-1904). A malicious client replays a file handle for a file whose last reference is disappearing (classic stale-FH race).
  • Locally: fhopen(2) drives the identical code (root-only).

Reproduce (root-driven analog of the NFS race)

build.sh   # cc -O2 -pthread -o fh_race fh_race.c
run.sh     # ./fh_race /tmp/fhrace 600    (4 racer threads fhopen()ing in a
           #  tight loop while main creates/unlinks/closes the target)

Observed on the stock INVARIANTS guest (within ~60 s):

panic: assertion "(vp) != NULL && (vp)->v_data != NULL" failed at
       /usr/src/sys/vfs/tmpfs/tmpfs.h:493
cpuid = 2
tmpfs_inactive() at tmpfs_inactive+0x129
vop_inactive() at vop_inactive() at vop_inactive+0x4b
vrele() at vrele+0x16e
tmpfs_alloc_vp() at tmpfs_alloc_vp+0x2e6
tmpfs_fhtovp() at tmpfs_fhtovp+0x9c
Debugger("panic")

The fhtovp-sourced vnode reached tmpfs_inactive with v_data == NULL: reclaim had already torn down the node while tmpfs_alloc_vp (called from tmpfs_fhtovp) was still operating on it β€” the exact unpinned-node window. Note this is a different, later manifestation than DF-3072's KKASSERT(!TMPFS_VNODE_DOOMED) at tmpfs_subr.c:402 (that assert fires in the earlier DOOMED interval and did NOT fire here; the race ran past it into reclaim).

Impact

  • INVARIANTS/debug kernels: remote-triggerable panic (NFS client) β€” proven.
  • Stock (no-INVARIANTS) kernels: the two KKASSERTs guarding this window (tmpfs_subr.c:402, tmpfs.h:493) are compiled out; the same race then re-reads the freed node at tmpfs_subr.c:382 and can attach it to a live vnode as vp->v_data (:468) β€” heap use-after-free / type confusion, attacker-timed (traced in VERDICT.md; not demonstrated on a no-INVARIANTS build in this run).

Fix

Pin the node across tmpfs_alloc_vp in tmpfs_fhtovp exactly the way tmpfs_unmount does β€” bump tn_links before the call and drop it after (rejecting ENOENT), or re-lookup under the token after a blocking failure; see fix.diff.

VERDICT.md
↓ download raw

DF-3180 verdict β€” REPRODUCED (panic from the fhtovp window; stock-kernel UAF continuation traced)

Guest: DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS), tmpfs /tmp.

Run (2026-09-07): /root/poc/fh_race /tmp/fhrace 600 β€” 4 threads fhopen()ing the current file handle in a tight loop while the main thread looped { creat, write, getfh, unlink, close } with a 200 us pause after getfh. Guest panicked within ~60 seconds:

panic: assertion "(vp) != NULL && (vp)->v_data != NULL" failed at /usr/src/sys/vfs/tmpfs/tmpfs.h:493 (VP_TO_TMPFS_NODE) cpuid = 2 tmpfs_inactive() at tmpfs_inactive+0x129 vop_inactive() at vop_inactive+0x4b vrele() at vrele+0x16e tmpfs_alloc_vp() at tmpfs_alloc_vp+0x2e6 tmpfs_fhtovp() at tmpfs_fhtovp+0x9c Debugger("panic")

(The panic string is garbled on the serial console by a concurrent CPU printing; see panic.txt for the raw capture and the reconstruction.)

Interpretation: * tmpfs_fhtovp (tmpfs_vfsops.c:453-463) found the node under TMPFS_LOCK and called tmpfs_alloc_vp(mp, NULL, node, ...) with no pin. * alloc_vp took the tn_vnode != NULL path (subr.c:395-443): vhold(vp), vget(vp) β€” vget blocks; meanwhile the main thread's unlink+close sent the vnode through tmpfs_inactive (DOOMED) and tmpfs_reclaim (tmpfs_vnops.c:1852-1859: node->tn_vnode = NULL, vp->v_data = NULL, then tmpfs_free_node because tn_links == 0). * alloc_vp's error path vrele()d the vnode whose v_data was already NULL, driving vop_inactive β†’ tmpfs_inactive β†’ VP_TO_TMPFS_NODE KKASSERT (tmpfs.h:493) β†’ panic. The KKASSERT at tmpfs_subr.c:402 (DF-3072) did not fire: this race ran PAST the DOOMED window, into and through reclaim β€” exactly the deeper window this finding describes.

Stock-kernel continuation (traced, not executed β€” needs a NOINVARIANTS build): with both KKASSERTs compiled out, the same window leaves alloc_vp re-reading node->tn_vnode at tmpfs_subr.c:382 on kfree_obj'd memory and potentially storing the stale node pointer in vp->v_data at :468 β€” heap UAF/type confusion with attacker-controlled timing (each round-trip is client-paced). The vnode in question is vhold-protected so the vnode memory survives; the node (344-byte kmalloc_obj chunk, per-mount zone) is freed and its chunk reusable by any later tmpfs node allocation β€” cross-object confusion is realistic given the per-mount zone has fixed object size.

Exploit chain (theory, memcorrupt bucket): NFS client races stale FH against close → server installs freed node as v_data → chunk recycled as another file's node → client's later ops on the stale vnode mutate a live node's fields (tn_size, tn_mode, tn_uid, union members). Not developed further in this run (requires exported-tmpfs + NOINVARIANTS kernel; the local fhopen analog is root-only, so no unpriv→root chain is claimable from the local surface).

Why this is not a re-report of DF-3072: DF-3072 covers the earlier DOOMED-interval assert at tmpfs_subr.c:402 on INVARIANTS kernels. This finding is vfsops-owned (tmpfs_fhtovp's missing node pin), manifests AFTER reclaim completed (v_data NULL / node freed), and describes the stock-kernel UAF. Fix belongs in tmpfs_vfsops.c (pin tn_links around the alloc_vp call), not in subr.c.

Verdict: reproduced / panic (window proven); stock-kernel UAF: likely.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored (tn_links pin) but not kernel-build-validated in this run; guest kernel-build budget was spent validating DF-3179

['fix.diff']
↓ fix.diffper-fix-DF-3180

Confirmed kernel references

Detail

Exploit chain

NFS-exported tmpfs: client replays stale FH while last ref drops -> server fhtovp finds node -> alloc_vp blocks -> reclaim frees node -> vget ENOENT -> loop re-reads freed node -> stale node attached to live vnode as v_data -> chunk recycled as another file's node -> cross-node type confusion (not executed; requires export + NOINVARIANTS kernel)

Evidence (decisive lines)

['panic.txt (raw serial)', 'console_full.txt', 'VERDICT.md (reconstruction + trace)', 'fix.diff (tn_links pin in tmpfs_fhtovp)']

PoC changes

seeded concept adjusted: statfs include removed; single shared fhandle refreshed per round with 200us post-getfh pause; 4 racer threads; 600s budget (panicked at ~60s)

Verified recommended fix

pin node->tn_links across tmpfs_alloc_vp in tmpfs_fhtovp (mirror tmpfs_unmount), or re-validate the node under the token after blocking

Verdict

REPRODUCED (window + panic): a 4-thread fhopen(2) race against creat/unlink/close on a tmpfs file panicked the stock INVARIANTS guest within ~60s with 'assertion (vp)!=NULL && (vp)->v_data!=NULL failed at tmpfs.h:493' and backtrace tmpfs_fhtovp -> tmpfs_alloc_vp -> vrele -> vop_inactive -> tmpfs_inactive β€” the vnode reached inactive from inside tmpfs_alloc_vp (called by tmpfs_fhtovp with NO node pin) after reclaim had already torn down the node (v_data NULL). This is strictly PAST DF-3072's DOOMED-interval assert (tmpfs_subr.c:402 did not fire): the node had been reclaimed. On stock (no-INVARIANTS) kernels the same window re-reads the freed node at tmpfs_subr.c:382 (vget ENOENT -> vdrop -> goto loop -> node->tn_vnode on kfree_obj'd memory) and can install it as vp->v_data at :468 β€” heap UAF traced but not executed on a NOINVARIANTS build in this run. Remote reachability requires a tmpfs exported via mountctl(MOUNTCTL_SET_EXPORT) (supported: tmpfs_vnops.c:1879) β€” NFS client stale-FH replay; local fhopen analog is root-only, so no local unpriv->root chain claimed.