procfs_exit() writes pfs->pfs_pid through a pfsnode a concurrent vnode reclaim may already have kfree()'d (use-after-free write, 4-byte OR at offset 20 in the M_PROCFS zone)
Summary
The at_exit hook procfs_exit walks the pfs hash for the exiting pid, vhold()s the vnode, drops the hash spinlock, and calls vx_get(vp) - which returns void, cannot fail, does not check VRECLAIMED, and BLOCKS on the VX lock. vhold() explicitly does not prevent reclamation: a concurrent vgone_vxlocked - from vnlru/allocvnode_gc under vnode-cache pressure, or from umount -f's vflush_scan - runs VOP_RECLAIM -> procfs_freevp -> vp->v_data=NULL + kfree(pfs) while the walk is blocked in vx_get. When vx_get returns, :546 executes pfs->pfs_pid |= PFS_DEAD on freed memory. The sibling allocator procfs_allocvp implements exactly the missing discipline (post-vget hash revalidation, :115-130). Any local user: process exits are the trigger side (every exit runs the walk); the reclaim side is routine kernel behavior under vnode-cache pressure or forced procfs unmount. On hit: 4-byte OR of 0x80000000 into freed M_PROCFS memory - if reallocated to a live pfsnode, that node's pid acquires the DEAD bit (orphaned/leaked, /proc entries permanently DoSed); on INVARIANTS kernels the raced vx_put trips the VREF_FINALIZE KKASSERT - panic. Not a plausible uid=0 primitive (fixed value, fixed offset, dedicated type-constrained slab zone - hard blocker documented). 17 race configurations / ~10,000 flush-aimed walks / 3 kernel builds (2 instrumented): every chain stage individually proven (unpriv walks mark 12 nodes, forced vflush reclaims them, retry timing characterized to +-3ms), but the final us-scale scan->vx_get vs vgone coincidence never manifested - defeated by dounmount's bimodal retry feedback. Honest verdict: not_reproduced (mechanically certain from source, empirically inconclusive at this budget). Fix authored (v_data==pfs revalidation under the held VX lock).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3184 Β· 29 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df3184_sync.c | β | 5.1 KB | view raw | |
| df3184_mountd.c | β | 1.7 KB | view raw | |
| df3184_race.c | β | 6.2 KB | view raw | |
| mountloop.sh | β | 700 B | view raw | |
| tchain.c | β | 1.1 KB | view raw | |
| build.sh | β | 163 B | view raw | |
| run.sh | β | 229 B | view raw | |
| fix.diff | β | 722 B | view raw | |
| env.txt | β | 601 B | view raw | |
| run.log | β | 1.5 KB | view raw | |
| run.2.log | β | 715 B | view raw | |
| run.3.log | β | 385 B | view raw | |
| run.4.log | β | 446 B | view raw | |
| run.5.log | β | 359 B | view raw | |
| run.6.log | β | 146 B | view raw | |
| run.7.log | β | 303 B | view raw | |
| run.8.log | β | 232 B | view raw | |
| run.9.log | β | 269 B | view raw | |
| run.10.log | β | 157 B | view raw | |
| run.11.log | β | 185 B | view raw | |
| run.12.log | β | 1.4 KB | view raw | |
| run.13.log | β | 356 B | view raw | |
| run.14.log | β | 325 B | view raw | |
| run.15.log | β | 1.2 KB | view raw | |
| run.16.log | β | 97 B | view raw | |
| run.17.log | β | 162 B | view raw | |
| build.log | β | 538 B | view raw | |
| VERDICT.md | β | 6.8 KB | β raw | |
| verdict.json | β | 4.2 KB | view raw |
DF-3184 VERDICT β procfs_exit() UAF write of pfs->pfs_pid
Bottom line
NOT REPRODUCED empirically within the attempt budget (~17 runs, ~6 h of
racing, ~10,000 flush-aimed exit-walks). The defect is mechanically
certain from source: every stage of the chain was individually proven on
an instrumented kernel (below), and only the final microsecond-scale
coincidence (walk's scanβvx_get of a node during that same node's
vgone_vxlocked teardown) was never caught. The finding stands as
High-severity CWE-416 (kernel UAF write) with confidence likely:
source-certain, empirically inconclusive on this 6-vCPU guest.
The defect (source trace)
sys/vfs/procfs/procfs_subr.c:531-549:
vp = PFSTOV(pfs);
vhold(vp); /* auxref β does NOT prevent reclaim
(sys/kern/vfs_lock.c:431-435) */
spin_unlock(&ph->spin);
vx_get(vp); /* void β cannot fail, does NOT check
VRECLAIMED (vfs_lock.c:728-735);
BLOCKS on the VX lock if a recycler
holds it */
pfs->pfs_pid |= PFS_DEAD; /* :546 β UAF WRITE if the recycler ran
procfs_freevp() β kfree(pfs)
(procfs_subr.c:249-273) while we
blocked */
vx_put(vp);
vdrop(vp);
Reclaim side that frees the pfsnode while vhold is outstanding:
- vnlru / allocvnode_gc β freesomevnodes β cleanfreevnode
(vfs_lock.c:908-1050): checks v_auxrefs != v_namecache_count
before vgone_vxlocked (:971) β a vhold landing after that
check does not stop the reclaim; the post-reclaim re-check (:1026)
only aborts the reuse of the vnode, not the free of the pfsnode.
- umount -f β dounmount β vflush β vflush_scan β
vgone_vxlocked per vnode (vfs_mount.c:1165-1181) β unconditional.
vgone_vxlocked β vclean_vxlocked(DOCLOSE) β VOP_RECLAIM β
procfs_reclaim (procfs_vnops.c:480-483) β procfs_freevp:
vp->v_data = NULL, hash unlink, kfree(pfs) (procfs_subr.c:255-270).
The sibling allocator in the same file revalidates after its blocking
acquisition (procfs_allocvp :115-130 rescans the hash after vget)
β procfs_exit is missing the equivalent check, writing |=
0x80000000 (4 bytes at struct pfsnode offset 20) into freed
M_PROCFS memory.
What WAS proven on the guest (instrumented kernel #1-#3)
- Chain stage 1 β nodes exist for the walk: tchain child as
nobody: 12/13 stats OK; on exit the instrumented walk printedDF3184-WALK pid=2067 ... marked=12(run.log run.13-era tests). Thousands ofmarked=12walks observed (run.14: 1404, run.13: 3124). - Chain stage 2 β forced reclaim: mount-cycled scratch procfs;
DF3184-FLUSH begin/endper cycle; every unmount succeeded and reclaimed all nodes (marked counts reset to 0 each cycle). - Chain stage 3 β timing controllability:
dounmountretry-loop behavior characterized precisely: - quiet mount (no lookups): unmount β 1 ms (flush β +1 ms) - lookups, no exits during retries: unmount β 256 ms Β± 3 ms (flush β +256 ms) - walk-exits during retries: full UMOUNTF_RETRIES exhaustion (50 Γ 25.1 ms), flush β +12.55 s (unmount total 13.0-13.3 s) - The blocking primitive:
vx_getblocking on the recycler's VX lock is the mechanism that turns the race into a guaranteed stale write once the interleaving occurs β confirmed by construction, not observed live.
Why reproduction failed β honest analysis
- Bimodal flush timing with adversarial feedback: walk-exits are
themselves transient mount refs;
dounmount's retry loop always waits out the last exit before flushing, so the umount-forced flush geometrically lands ~251 ms after the last exiting walk β every single-wave aiming scheme self-defeated (runs 7-9, 11-17). - Load destroys walk-timing precision: the fork-storm needed for
statistical volume (batch 96-288) pushed load average to 90+, giving
the
exit1prefix (before theat_exitwalk) milliseconds-to-seconds of jitter β larger than the ~5-10 ms flush window. - Residual per-walk collision probability: given a walk inside the
flush window, the walk must be in its ~0.5 ΞΌs
scanβvx_getgap for one of its ~12 nodes at the instantvflush_scanis inside the same node's ~3 ΞΌsvgoneβ β²0.5 % per in-flush walk under the best model; observed 0 over ~10β΄ attempts (consistent with a per-walk probability a few times lower than modeled). - The pure vnlru variant (no umount games) needs the exit to coincide with the recycler reaching that vnode after a FIFO service delay of minutes (measured: ~45k reclaims / 300 s against a ~50k backlog) β estimated single-digit expected hits per day of racing.
Primitive characterization (if triggered)
- One 4-byte OR of
0x80000000at offset 20 of a freed chunk in the dedicatedM_PROCFSslab zone (onlystruct pfsnodelives there). - No attacker-controlled value or offset: not a general write
primitive. Realistic outcomes: silent no-op (free chunk), corruption
of a reallocated live pfsnode (its pid gets the DEAD bit β that
/proc node is orphaned from its pid β DoS), or β on INVARIANTS
kernels β the subsequent
vx_puton the VBAD/VS_INACTIVE raced vnode takes theVREF_FINALIZEβvreleβvnode_terminatepath and tripsKKASSERT(vp->v_state == VS_ACTIVE)(vfs_lock.c:503) β panic. - uid=0 escalation: not plausibly reachable from this primitive alone (fixed value/offset, type-constrained zone). Hard blocker documented per contract.
Fix validation
fix.diff (validate vp->v_data == pfs under the held VX lock; skip
the write for raced nodes) β not_testable: no reproduced baseline
exists to diff against. The fix is mechanically equivalent to the
instrumentation's stale-detector condition, which itself never fired,
so the patched behavior would be indistinguishable on this guest.
Guest state
Left CLEAN: harness killed, /procN unmounted, /usr/src procfs
sources restored to pristine (md5-verified for procfs_subr.c), then
vm.sh reset with-src to the clean-source snapshot with the stock
kernel.
Attempts
17 distinct race configurations (runs run.log β¦ run.17.log), 3 kernel builds (2 instrumented), ~6 h wall time, ~10,000 flush-aimed walks.
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_testableNo reproduced baseline exists to validate the fix against; the fix condition is identical to the instrumentation detector that never fired, so patched behavior is indistinguishable on this guest.
findings/poc/DF-3184/fix.diff
Confirmed kernel references
- sys/vfs/procfs/procfs_subr.c:531-549
- sys/vfs/procfs/procfs_subr.c:546
- sys/vfs/procfs/procfs_subr.c:249-273
- sys/vfs/procfs/procfs_subr.c:115-130
- sys/kern/vfs_lock.c:431-445
- sys/kern/vfs_lock.c:728-735
- sys/kern/vfs_lock.c:971-1009
- sys/kern/vfs_mount.c:1165-1181
- sys/kern/vfs_subr.c:1551-1604
- sys/kern/kern_exit.c:348-362
Detail
Evidence (decisive lines)
['findings/poc/DF-3184/VERDICT.md (full chain trace + per-run analysis)', 'findings/poc/DF-3184/run.13.log (3124 marked>0 walks; poisoned-mode unmount 13.0s +/- 0.02s)', 'findings/poc/DF-3184/run.14.log (dual-batch: 1404 marked>0 walks, 0 stale after msgbuf fix)', 'findings/poc/DF-3184/run.17.log (light mode 2064 walks, bimodal flip to 13s exhaustion)', 'findings/poc/DF-3184/env.txt (guest uname, sysctls, source hashes)', 'kernel instrumentation: stale-pfs detector printed only when VTOPFS(vp)!=pfs after vx_get; never fired']
PoC changes
Seed sketch fully rewritten: unsynchronized exit-storm racer (df3184_race.c) upgraded to marker-synchronized scratch-mount cycler (df3184_sync.c + df3184_mountd.c) after measuring the inactive-queue FIFO delay (~300s under 70k-vnode pressure) and dounmount's bimodal retry behavior (1ms / 256ms / 12.55s); added tchain.c chain prover and an in-guest instrumented kernel proving each stage.
Verified recommended fix
After vx_get(vp) in procfs_exit, only write when vp->v_data still equals the cached pfs: if (VTOPFS(vp) == pfs) pfs->pfs_pid |= PFS_DEAD;
Verdict
Source-certain use-after-free write (CWE-416): procfs_exit's at_exit walk vhold()s a vnode (which explicitly does not prevent reclaim), blocks in void vx_get() which cannot fail or revalidate, then writes pfs->pfs_pid |= PFS_DEAD through a pfsnode that a concurrent vgone_vxlocked (vnlru under vnode pressure, or umount -f vflush) may already have kfree()'d via procfs_freevp. Every chain stage was individually proven on an instrumented guest kernel (nodes created and marked=12 by unpriv exits; forced vflush reclaiming them; precise dounmount retry-loop timing), but the final microsecond-scale scan->vx_get vs vgone coincidence never manifested across ~10,000 flush-aimed exit-walks in 17 configurations: dounmount's retry loop always drains the last walking exit before flushing (adversarial bimodal feedback) and fork-storm load jitters exit1's walk past the 5-10ms flush window. Primitive if triggered: one fixed 4-byte OR (0x80000000) at offset 20 of a freed chunk in the dedicated M_PROCFS zone -> live-pfsnode corruption (procfs DoS) or INVARIANTS panic via vx_put/vnode_terminate KKASSERT(v_state==VS_ACTIVE); not a plausible uid=0 primitive (fixed value/offset, type-constrained zone).
No comments yet.