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

ext2_alloc_vnode EBUSY-collision handler double-frees the inode (kernel heap double-free/UAF, deterministic panic) β€” v_data published before ext2_ihashins, synchronous reclaim frees the handler's inode first; plus permanent lost-wakeup D-state hang in the same hand-rolled ext2fs_inode_hash_lock

Summary

ext2_alloc_vnode sets vp->v_data = ip at ext2_vfsops.c:1297 BEFORE ext2_ihashins at :1313 (inverted vs ffs_vget's safe ordering). When ihashins returns EBUSY the handler does vx_put(vp) on the VBAD last-ref vnode - VREF_FINALIZE forces a SYNCHRONOUS reclaim: ext2_inactive -> vrecycle -> ext2_reclaim -> free(ip) at ext2_inode.c:569 (plus an UNBALANCED vrele of i_devvp, no matching vref) - and then the handler's own free(ip) at :1319 frees the same chunk again. The door: ext2fs_inode_hash_lock does not cover the ext2_ihashget miss in ext2_vget and its check-then-set on a plain int is non-atomic, so a loser straddling the winner's insert->unlock reaches the EBUSY branch through ordinary SMP scheduling. VERIFIED on stock INVARIANTS guest: 'panic: memory chunk 0xfffff80119113620 is already free!' (_kfree <- ext2_alloc_vnode <- ext2_vget <- ext2_fhtovp) after 32,757 raced cold creations (two CPU-pinned procs, TSC phase sweep, hardlink pairs bypass parent-dir lock, kern.maxvnodes=1200 forces cold cycling); fix.diff (v_data after insert) A/B validated: same EBUSY branch fired again (ino=23506) and the guest survived 1.17M+ further creations. Same lock's wakeup also produced a live-observed permanent lost-wakeup (tsleep no PCATCH never woken -> unkillable D-state hang, independent unpriv DoS). On shipped INVARIANTS kernel = instant panic; where slab double-free check absent = exploitable heap corruption (deterministic same-address double hand-off + unbalanced devvp refcount drop; documented for exploit development).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3250 Β· 14 files
FileTypeDescriptionSize
README.md β€” 5.1 KB ↓ raw
VERDICT.md β€” 5.6 KB ↓ raw
fh_race.c β€” 3.8 KB view raw
race_e2.c β€” 3.3 KB view raw
reload.c β€” 567 B view raw
instrument.diff β€” 3.3 KB view raw
fix.diff β€” 972 B view raw
build.sh β€” 190 B view raw
run.sh β€” 241 B view raw
panic.txt β€” 882 B view raw
fix_counters.txt β€” 122 B view raw
fix_dmesg.txt β€” 373 B view raw
verdict.json β€” 6.4 KB view raw
manifest.json β€” 1.3 KB view raw

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:

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

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

A/B validated on the same guest: baseline module panicked deterministically on the first EBUSY (ins=32757); fixed module (fix.diff, one hunk moving vp->v_data assignment after ext2_ihashins) hit the same EBUSY branch (ino=23506, ins=829996, ebusy=1 printed to console) and survived - guest alive after 1.17M+ further creations with 4569 lock-contention pairings.

['fix.diff', 'fix_counters.txt', 'fix_dmesg.txt', 'VERDICT.md run table']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (stock kernel; ext2fs.ko rebuilt from fixed sources, module-level)

Confirmed kernel references

Detail

Exploit chain

two CPU-pinned processes barrier-synchronize fhopen()/open() of the same cold ext2 inode (hardlinked names in two directories avoid parent-dir lock serialization; kern.maxvnodes pressure forces VOP_RECLAIM so inodes cycle cold) -> both ext2_vget creators miss ext2_ihashget -> ext2fs_inode_hash_lock does not cover the miss and is non-atomic -> loser passes the check after winner's unlock -> ext2_ihashins returns EBUSY -> vx_put(VBAD, last ref) sets VREF_FINALIZE -> synchronous vrecycle -> ext2_reclaim frees ip #1 (+ unbalanced vrele(um_devvp)) -> handler free(ip) #2 -> slab free-chunk panic. On non-checking builds the second free is exploitable heap corruption (chunk double-handed to the magazine); the unbalanced device-vnode vrele enables premature devvp death under repeat.

Evidence (decisive lines)

["panic.txt: console capture - 'DF3250[ihash]: EBUSY! dev=0xfffff8004f2bb780 ino=30751 (ins=32757 ebusy=1)', 'ext2_alloc_vnode: ihashins collision, retrying inode 30751', 'panic: memory chunk 0xfffff80119113620 is already free!', stack _kfree <- ext2_alloc_vnode <- ext2_vget <- ext2_fhtovp", "fix_dmesg.txt / fix_counters.txt: post-fix run, same EBUSY event ('ino=23506 ... ebusy=1') with guest alive; debug.df3250_alloc=1176322, debug.df3250_slept=4569, debug.df3250_ebusy=1, no panic", 'fh_race.c: fhopen-based race driver (pinned pair, per-round phase sweep 0..250us, barrier stall detection for the lost-wakeup wedge)', 'race_e2.c: unprivileged open()-path driver via two hardlinked names in two directories', 'instrument.diff: counter/probe patch to ext2fs.ko used for both baseline and fix runs', 'VERDICT.md: full A/B narrative and path:line trace']

PoC changes

Original concept (open() race) rewrote three times: (1) same-directory opens serialize on the parent dir vnode lock - replaced by hardlinked names in two directories; (2) cached-but-inactive vnodes stay in the ext2 ihash and are revived by vget, so 'cold' supply requires forced VOP_RECLAIM (kern.maxvnodes pressure and/or MNT_RELOAD purge via direct mount(2), reload.c; mount(8) rejects -o reload for ext2fs); (3) final driver uses fhopen() (root-gated convenience; VFS_FHTOVP -> ext2_vget directly, no namei jitter), CPU pinning via usched_set (root-only), per-round TSC phase sweep, and a supervisor that respawns the pair when the kernel-side lost-wakeup flaw (same broken lock) wedges a thread permanently. Kernel instrumentation (instrument.diff): counters + printf on the EBUSY branch only, no behavior change; used identically for baseline and fix runs.

Verified recommended fix

Assign vp->v_data only after ext2_ihashins() succeeds (mirror ffs_vfsops.c:1120-1129 ordering) so the EBUSY handler's vx_put-triggered synchronous reclaim sees v_data == NULL and neither walks nor frees the handler-owned inode.

Verdict

Unprivileged-class kernel heap double-free in ext2_alloc_vnode's EBUSY-collision handler, triggered by racing concurrent ext2 inode instantiations: vp->v_data is published before ext2_ihashins (ext2_vfsops.c:1297 vs :1313), so the handler's vx_put() synchronously reclaims through ext2_reclaim which frees the inode (ext2_inode.c:569), and the handler's own free(ip) at :1319 frees it again - verified as 'panic: memory chunk ... is already free!' (_kfree <- ext2_alloc_vnode <- ext2_vget <- ext2_fhtovp) after 32,757 raced cold creations on the stock INVARIANTS guest. Sibling flaw in the same hand-rolled ext2fs_inode_hash_lock: lost wakeup wedges syscalls permanently in D-state (observed live). Fix validated A/B: with v_data assigned only after a successful insert (fix.diff), the same EBUSY branch fired again (ino=23506) and the guest survived 1.17M+ further creations.