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)
PoC verification
Evidence pack
findings/poc/DF-3250 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 andfhopen()the same (cold) ext2 inode βfhopenβext2_fhtovpβext2_vgetdirectly, 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=1200forces 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=30751then 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 modulemodsrc.tgzis NOT included; instrument.diff + fix.diff regenerate itfix.diffβ the verified minimal fixpanic.txtβ baseline panic (console)build.sh/run.shβ exact commandsenv.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).
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)
ext2_alloc_vnode()(sys/vfs/ext2fs/ext2_vfsops.c:1262) setsvp->v_data = ipat :1297, beforeext2_ihashins(ip)at :1313 (inverted w.r.t. the safeffs_vget()ordering,ffs_vfsops.c:1120-1129, which assignsvp->v_dataonly after a successful insert).ext2fs_inode_hash_lock(:1275-1282) does not cover theext2_ihashget()miss inext2_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 insideext2_ihashins(ext2_ihash.c:131) detects the duplicate and returnsEBUSYβ no instruction-level race is needed to get here, only a loser whose [hash-miss β lock-check] gap straddles the winner's insertβunlock.- 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 setsVREF_FINALIZE(sys/kern/vfs_lock.c:763-764);vreile()finalizes synchronously;ext2_inactive()seesi_mode == 0βvrecycle()(ext2_inode.c:527-528) βvgone_vxlocked()βext2_reclaim()βfree(ip, M_EXT2NODE)(ext2_inode.c:569) β the first free, inside thevx_put()call. Return to the handler βfree(ip)β the second free β slab free-chunk check panics. - Because
v_datawas published,ext2_reclaim()also runsvrele(ip->i_devvp)β the macroi_ump->um_devvp(sys/vfs/ext2fs/inode.h:174) β with no matchingvref()(that only happens on success atext2_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'sipwalks (i_flag/IN_HASHED/i_devvpmacro deref) still apply to the loser's ordering. The additional unbalancedvrele(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 sameVFS_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
fixedA/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']
Confirmed kernel references
- sys/vfs/ext2fs/ext2_vfsops.c:1297
- sys/vfs/ext2fs/ext2_vfsops.c:1313
- sys/vfs/ext2fs/ext2_vfsops.c:1318
- sys/vfs/ext2fs/ext2_vfsops.c:1319
- sys/vfs/ext2fs/ext2_vfsops.c:1275
- sys/vfs/ext2fs/ext2_vfsops.c:1325
- sys/vfs/ext2fs/ext2_inode.c:562
- sys/vfs/ext2fs/ext2_inode.c:569
- sys/vfs/ext2fs/inode.h:174
- sys/kern/vfs_lock.c:763
- sys/kern/vfs_subr.c:1498
- sys/vfs/ufs/ffs_vfsops.c:1120
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.
No comments yet.