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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3184 Β· 29 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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)

  1. Chain stage 1 β€” nodes exist for the walk: tchain child as nobody: 12/13 stats OK; on exit the instrumented walk printed DF3184-WALK pid=2067 ... marked=12 (run.log run.13-era tests). Thousands of marked=12 walks observed (run.14: 1404, run.13: 3124).
  2. Chain stage 2 β€” forced reclaim: mount-cycled scratch procfs; DF3184-FLUSH begin/end per cycle; every unmount succeeded and reclaimed all nodes (marked counts reset to 0 each cycle).
  3. Chain stage 3 β€” timing controllability: dounmount retry-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)
  4. The blocking primitive: vx_get blocking 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 exit1 prefix (before the at_exit walk) 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_get gap for one of its ~12 nodes at the instant vflush_scan is inside the same node's ~3 ΞΌs vgone β€” ≲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 0x80000000 at offset 20 of a freed chunk in the dedicated M_PROCFS slab zone (only struct pfsnode lives 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_put on the VBAD/VS_INACTIVE raced vnode takes the VREF_FINALIZE β†’ vrele β†’ vnode_terminate path and trips KKASSERT(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_testable
baseline no→ patch + rebuild →patched clean

No 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
↓ fix.diffper-fix-DF-3184

Confirmed kernel references

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).