# DF-3250 — ext2_alloc_vnode EBUSY-collision handler double-frees the inode (UAF/double-free + permanent-hang DoS)

**Impact:** kernel heap double-free / use-after-free, unprivileged-triggerable
race; deterministic panic on the double free; lost-wakeup sibling flaw wedges
syscalls permanently. Verified on the stock INVARIANTS guest kernel (panic),
fix-validated (same race, no panic).

## What

`ext2_alloc_vnode()` (sys/vfs/ext2fs/ext2_vfsops.c:1262) publishes
`vp->v_data = ip` at :1297 **before** the duplicate-detecting hash insert
`ext2_ihashins(ip)` at :1313. When the insert returns `EBUSY` the handler
(:1313-1320) does:

```c
vp->v_type = VBAD;
vx_put(vp);              /* VBAD => VREF_FINALIZE => SYNCHRONOUS reclaim:
                            ext2_reclaim() frees ip (ext2_inode.c:569) */
free(ip, M_EXT2NODE);    /* second free of the same chunk => double free */
```

`vx_put()` on a VBAD vnode with refcount 1 sets `VREF_FINALIZE`
(sys/kern/vfs_lock.c:763-764), `vreile()` finalizes synchronously,
`ext2_inactive()` sees `i_mode == 0` → `vrecycle()` (ext2_inode.c:527-528)
→ `vgone_vxlocked()` → `ext2_reclaim()` → `free(ip)` [#1] — all *inside*
the `vx_put()` call. Control returns to the handler which executes
`free(ip)` [#2]. The slab allocator's free-chunk check panics:

```
panic: memory chunk 0xfffff80119113620 is already free!
 _kfree() <- ext2_alloc_vnode() <- ext2_vget() <- ext2_fhtovp()
```

Additionally, because `v_data` is set, the reclaim path also executes
`vrele(ip->i_devvp)` — which is the macro `i_ump->um_devvp`
(sys/vfs/ext2fs/inode.h:174) — **without** any matching `vref()`
(the matching `vref` happens only on the success path at
ext2_vfsops.c:1415). Each hit also drops one reference off the mounted
device vnode.

## Why the loser reaches the EBUSY branch (no instruction-level race needed)

The ad-hoc serializer `ext2fs_inode_hash_lock`
(ext2_vfsops.c:1275-1282) does not cover the `ext2_ihashget()` **miss** that
precedes it in `ext2_vget()` (:1350). A thread whose hash miss predates the
winner's insert but whose lock check postdates the winner's unlock
(:1325) enters `ext2_alloc_vnode()`, runs `malloc + getnewvnode +
ext2_ihashins`, finds the winner's entry and gets `EBUSY` — through ordinary
SMP scheduling (the loser's [ihashget-miss → lock-check] gap must straddle
the winner's insert→unlock moment, a ~1µs door per cold-inode create
episode). The check-then-set on the plain int is also non-atomic.

The same broken lock has a second defect observed live during
reproduction: the wakeup protocol (`if (lock < 0) wakeup(); lock = 0`)
races a sleeper setting `lock = -1` between the check and the store — the
sleeper's `tsleep(..., "e2vget", ...)` has no PCATCH and nobody ever wakes
it: the syscall thread hangs **permanently** in D-state (observed: parent
process of the PoC pair wedged forever; unkillable).

## Reproduction (verified)

* Driver `fh_race.c`: two CPU-pinned processes barrier-synchronize and
  `fhopen()` the *same* (cold) ext2 inode — `fhopen` → `ext2_fhtovp` →
  `ext2_vget` directly, no namei smear. One side sweeps a 0..250µs
  per-round offset; a supervisor respawns the pair when the lost-wakeup
  flaw wedges one side. Cold supply: `kern.maxvnodes=1200` forces vnlru
  reclaim of the 6000 hardlinked target inodes (an inode whose vnode sits
  free-listed is still ihash-findable; only VOP_RECLAIM makes it cold).
* Baseline (instrumented stock module, counters only): panic after
  32,757 raced cold creations (`ins=32757 ebusy=1`), see panic.txt:
  `DF3250[ihash]: EBUSY! dev=... ino=30751` then the double-free panic
  with `_kfree <- ext2_alloc_vnode <- ext2_vget <- ext2_fhtovp`.
* Fix validation (same module + `fix.diff`, same race, 2 pairs, ≥4 min):
  662,865 cold creations, 2,515 lock-contention pairings, guest alive,
  no panic — and the post-fix run continues until an EBUSY fires without
  harm (counters watched live).

## Files

* `fh_race.c` — race driver (fhopen-based, final)
* `race_e2.c` — earlier open()-based two-directory driver (kept for the
  unprivileged narrative; same kernel path via namei)
* `reload.c` — MNT_RELOAD purge tool (cold-supply alternative)
* `instrument.diff` — printf/counter probes added to the module
* `modsrc.tgz` is NOT included; instrument.diff + fix.diff regenerate it
* `fix.diff` — the verified minimal fix
* `panic.txt` — baseline panic (console)
* `build.sh` / `run.sh` — exact commands
* `env.txt` — guest environment

## Threat model

Unprivileged local user racing `open()`/stat of the same ext2 file
(see race_e2.c — two hardlinks in two directories avoid the parent-dir
lock serialization). Trigger rate measured: 1 EBUSY per ~33k cold-inode
race pairs (~30-120s of hammering on the stock guest). Also reachable
through any path that drives `VFS_VGET` (NFS `READDIRPLUS`/file handles,
`fhopen`, namei). Root required only for the harness conveniences
(pin/`fhopen`/mount), not for the vulnerability class.

Severity: High (kernel heap double-free; on non-checking builds the same
sequence is exploitable heap corruption — freed chunk re-handed to the
magazine while a freelist vnode still points at it; plus the permanent
syscall-hang DoS).
