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)
PoC verification
Evidence pack
findings/poc/DF-3180 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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_testablefix.diff authored (tn_links pin) but not kernel-build-validated in this run; guest kernel-build budget was spent validating DF-3179
['fix.diff']
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.
No comments yet.