# DF-3250 VERDICT

**reproduced** — unprivileged-class kernel heap double-free (use-after-free
family) triggered by racing concurrent ext2 inode instantiations; verified as
an instant, deterministic panic on the stock INVARIANTS guest kernel, then
**fix-validated**: with `fix.diff` applied to the ext2fs module the exact
same race fired the same EBUSY collision branch and the guest survived.

## Mechanism (path:line, all verified on the guest)

1. `ext2_alloc_vnode()` (`sys/vfs/ext2fs/ext2_vfsops.c:1262`) sets
   `vp->v_data = ip` at **:1297**, *before* `ext2_ihashins(ip)` at **:1313**
   (inverted w.r.t. the safe `ffs_vget()` ordering, `ffs_vfsops.c:1120-1129`,
   which assigns `vp->v_data` only after a successful insert).
2. `ext2fs_inode_hash_lock` (:1275-1282) does not cover the
   `ext2_ihashget()` miss in `ext2_vget()` (:1350) and its check-then-set
   on a plain int is non-atomic, so two threads can both run the creator
   path for the same `(dev, ino)`; the token inside `ext2_ihashins`
   (`ext2_ihash.c:131`) detects the duplicate and returns `EBUSY` — no
   instruction-level race is needed to get here, only a loser whose
   [hash-miss → lock-check] gap straddles the winner's insert→unlock.
3. The EBUSY handler (:1313-1320) executes
   `vp->v_type = VBAD; vx_put(vp); free(ip, M_EXT2NODE);`.
   `vx_put()` on a VBAD last-ref vnode 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, M_EXT2NODE)` (**ext2_inode.c:569**) — the *first* free, inside
   the `vx_put()` call. Return to the handler → `free(ip)` — the *second*
   free → slab free-chunk check panics.
4. Because `v_data` was published, `ext2_reclaim()` also runs
   `vrele(ip->i_devvp)` — the macro `i_ump->um_devvp`
   (`sys/vfs/ext2fs/inode.h:174`) — with no matching `vref()` (that only
   happens on success at `ext2_vfsops.c:1415`): each hit also steals a
   reference from the mounted device vnode.

Sibling defect (same broken lock, observed live): the wakeup protocol
`if (lock < 0) wakeup(); lock = 0;` (:1323-1325) races a sleeper setting
`lock = -1` between the check and the store → lost wakeup → the sleeper's
non-PCATCH `tsleep(..., "e2vget", ...)` blocks **forever** (permanent
D-state syscall hang, unkillable). Encountered repeatedly during PoC
operation; the driver/supervisor handle it by stall-detection + respawn.

## Run log

| run | module | result |
|-----|--------|--------|
| 1-3 | stock ext2fs.ko, open()-based racers (race_e2.c) | no hit — same-directory lookups serialize on the parent dir vnode lock; two-directory hardlink variant verified parallelism but cached vnodes are revived by vget (only VOP_RECLAIM makes an inode cold) |
| 4 | instrumented ext2fs.ko (counters only, no behavior change), fhopen-based pair + supervisor | **panic** after 32,757 raced cold creations: `DF3250[ihash]: EBUSY! dev=0xfffff8004f2bb780 ino=30751 (ins=32757 ebusy=1)` → `panic: memory chunk 0xfffff80119113620 is already free!` with stack `_kfree ← ext2_alloc_vnode ← ext2_vget ← ext2_fhtovp` (panic.txt) |
| 5 | same module + `fix.diff` (v_data after insert), identical harness, 2 pairs, >8 min | EBUSY fired again (`ino=23506, ins=829996, ebusy=1`, console + fix_dmesg.txt), **no panic**, guest alive, race continued to 1,176,322 creations / 4,569 lock-contention pairings (fix_counters.txt) |

The A/B on runs 4 vs 5 (same instrumentation, only `fix.diff` differing) is
the fix validation: the identical triggering event panics before and is
handled after.

## Exploitability notes

* On the stock kernel the double free is caught by the slab allocator's
  free-chunk check (panic). On configurations without that check the
  sequence is a classic exploitable double free: the chunk returns to the
  magazine while the reclaimed vnode's free-list state briefly referenced
  it; a second allocation hands the same address to a different type while
  `ext2_reclaim`'s `ip` walks (`i_flag`/`IN_HASHED`/`i_devvp` macro deref)
  still apply to the loser's ordering. The additional unbalanced
  `vrele(um_devvp)` enables a premature device-vnode death under repeat.
* The lost-wakeup hang is itself an unprivileged DoS (permanent, system
  grows D-state threads; each wedged thread is unkillable).
* Reachability without root: any two concurrent lookups of the same
  not-in-cache ext2 file (open/stat via two hardlinked names avoids the
  parent-dir lock serialization — race_e2.c). NFS `READDIRPLUS`/filehandle
  paths drive the same `VFS_VGET`.
* Measured trigger rate: 1 EBUSY per ~33k (baseline) / ~830k (fix run,
  luck-dependent) raced cold-inode creations — minutes of hammering with
  the provided harness on the 6-vCPU guest.

## Bottom line

High-severity kernel memory-corruption bug (double free / UAF family) with a
verified unprivileged-class trigger, deterministic panic, live-observed
permanent-hang sibling flaw, and a one-line-ordering fix validated A/B on
the same guest. Recommended upstream: apply the ordering fix (fix.diff),
and additionally replace the hand-rolled `ext2fs_inode_hash_lock` protocol
with atomics or fold serialization into the existing `ext2_ihash_token`
(closes the lost-wakeup hang and the non-atomic check-then-set).

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