hpfs_hphashvget hands out half-initialized H_INVAL hpfsnodes published by hpfs_vget's error paths β unprivileged race returns vnodes the kernel itself rejected (utimes()=0 successes on always-rejected fnodes) and leaves the mount un-unmountable ('panic: unmount: dangling vnode'); DF-3251 ext2 twin
Summary
hpfs_vget publishes the freshly kmalloc'd (unzeroed) hpfsnode into the global inode hash BEFORE bread/FN_MAGIC validation; both error paths vx_put() and return WITHOUT unhashing, leaving an H_INVAL-marked node in the hash whose h_fn is uninitialized kernel heap (bread failure) or an already-rejected fnode (bad magic). hpfs_hphashvget finds such a node, blocks in vget() on the creator's VX lock - its reference is taken first so the failing creator's vx_put cannot reclaim under it - and after the identity re-walk returns the poisoned vnode as a successful lookup WITHOUT ever checking h_flag & H_INVAL. hpfs_lookup passes it to namei; getattr computes va_size from fn_size; setattr's utimes path mutates the rejected inode. VERIFIED on guest (uid 1001, crafted image, 10ms cold-bread-window instrument): 40/40 and 19/19 steady-state lookups received poisoned vnodes, stat() aborts EBADF at the VNON switch (the handoff signature), utimes()==0 full-syscall successes on the always-rejected fnode, and 2/4 runs panicked at umount ('unmount: dangling vnode'). Race shape empirically established: namei's per-directory lock serializes same-dir lookups, so the trigger image uses two directories whose dirents share one victim fnode. Fix validated (creator-side hphashrem on both error paths = load-bearing + hash-side H_INVAL retry): 0 handoffs, 960/960 EINVAL, clean umount. Unpriv + crafted-HPFS precondition; rejected-metadata integrity bypass + uninit-heap-driven metadata + timing DoS; no user->root chain (read-side primitive).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3253 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.7 KB | β raw | |
| VERDICT.md | β | 7.0 KB | β raw | |
| poc.c | β | 5.2 KB | view raw | |
| craft_df3253.py | β | 5.9 KB | view raw | |
| build.sh | β | 92 B | view raw | |
| run.sh | β | 149 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| instrument_applied.diff | β | 683 B | view raw | |
| stats_applied.diff | β | 1.2 KB | view raw | |
| instrument_df3253.diff | β | 984 B | view raw | |
| run.baseline3.badmagic.log | β | 1.8 KB | view raw | |
| run.unhashonly.badmagic.log | β | 255 B | view raw | |
| panic.console.txt | β | 2.1 KB | view raw | |
| panic.txt | β | 506 B | view raw | |
| panic2.console.txt | β | 2.0 KB | view raw | |
| panic2.txt | β | 441 B | view raw | |
| panic3.console.txt | β | 2.1 KB | view raw | |
| env.txt | β | 249 B | view raw | |
| verdict.json | β | 5.4 KB | view raw | |
| manifest.json | β | 2.2 KB | view raw |
DF-3253 β hpfs_hphashvget hands out half-initialized H_INVAL hpfsnodes (publish-before-validate race)
What
hpfs_vget (sys/vfs/hpfs/hpfs_vfsops.c:456-552) publishes the freshly
kmalloc'd hpfsnode into the global inode hash β hpfs_hphashins(hp) at
hpfs_vfsops.c:525 β BEFORE validating it:
525: hpfs_hphashins(hp); /* published */
527: LOCKMGR(&hpfs_hphash_lock, LK_RELEASE);
529: error = bread(hpmp->hpm_devvp, dbtodoff(ino), FNODESIZE, &bp);
530: if (error) { kprintf(...); vx_put(vp); return (error); } /* still hashed */
535: bcopy(bp->b_data, &hp->h_fn, sizeof(struct fnode));
538: if (hp->h_fn.fn_magic != FN_MAGIC) {
539: kprintf("hpfs_vget: MAGIC DOESN'T MATCH\n");
540: vx_put(vp); return (EINVAL); /* still hashed */
Both error paths drop the vnode and return without unhashing the node.
The node is marked H_INVAL (set at :506, cleared only at :545 after
validation), and in the bread-failure case h_fn is uninitialized kernel
heap (kmalloc at :488, never zeroed).
hpfs_hphashvget (sys/vfs/hpfs/hpfs_hash.c:104-139, the audited file)
finds such a node, blocks in vget(vp, LK_EXCLUSIVE) on the creator's VX
lock, and β when the failing creator vx_put()s β wakes up and returns the
poisoned vnode as a successful lookup without ever checking
h_flag & H_INVAL (its re-walk at :122-129 verifies identity only).
vget() takes its reference before blocking (sys/kern/vfs_lock.c:589), so
the creator's vx_put cannot reclaim the node under the waiter.
hpfs_lookup (hpfs_vnops.c:1056) returns the poisoned vp to namei;
hpfs_getattr (hpfs_vnops.c:463) then exposes
vap->va_size = hp->h_fn.fn_size β bytes from the fnode the kernel itself
just rejected (badmagic) or from uninitialized kernel heap (bread failure)
β as a successful stat() of a file that can NEVER be looked up
sequentially (every non-racing lookup fails EINVAL).
Threat model: crafted HPFS image (attacker controls fnode magic / image truncation makes bread fail) + any local user racing lookups on the mounted fs. Unprivileged trigger (uid 1001). Same class as DF-3251 (ext2 publish-before-validate twin); hpfs_hash.c is the validation omission site.
PoC
craft_df3253.py <out.img> <badmagic|trunc>β minimal crafted HPFS image (root fnode @0x20 -> dirblk @0x40 -> dirent "ZFILE" -> fnode @0x58). badmagic: fnode magic = 0xDEADBEEF, fn_size = 0x00BADF00D (marker). trunc: image ends before 0x58 sobreadfails -> h_fn stays uninitialized heap.poc.cβ N threads racingstat()of the always-rejected file. Sequentially stat can only return EINVAL. Anystat()==0(printed asRACE WIN, withst_size=0x00BADF00Dfor badmagic, or varying heap garbage for trunc) is the poisoned handoff.
Run (exact)
./build.sh # compile poc + mount helper in guest ./run.sh badmagic # or: ./run.sh trunc # custom: NTHR=8 ITERS=300 REPS=3 ./run.sh badmagic
Expected output (instrumented kernel, see VERDICT.md)
UTIMES WIN tid2 pathA: utimes()=0 on a fnode the kernel ALWAYS rejects (poisoned handoff confirmed) DF-3253 SUMMARY: wins=19 einval=37 ebadf(poisoned handoffs seen by stat)=19 enoent=0 other=0 DF-3253 VERDICT: POISONED H_INVAL HPFSNODE HANDED OUT BY hpfs_hphashvget β RACE CONFIRMED
Note: stat() itself cannot return 0 on the stock failure paths β the failed
creator never sets v_type, so the handed-off VNON vnode reaches
cvn_stat's type switch (sys/kern/vfs_vnops.c:894 default: return (EBADF))
and stat fails EBADF (the normal failure is EINVAL from vget; EBADF is
the handoff signature). The syscall-success proof is utimes()==0
(hpfs_setattr's utimes path has no v_type check), which also mutates the
rejected inode (H_PARCHANGE). Kernel console shows
DF3253H: *** INVAL HANDOFF #N *** per handoff; ~half the runs panic at the
cleanup umount (panic: unmount: dangling vnode). With fix.diff applied:
wins=0 ... ebadf=0 einval=960, UMOUNT_OK.
Why an instrumented kernel
On the stock kernel the post-publish window is bread(cache-hit)+bcopy β
sub-microsecond β while every failed lookup costs two serial-console
kprintfs (~60ms), so the waiter almost never lands inside the window under
console-lock serialization (0 wins in ~750 real iterations). The real-world
window is the bread itself: a cold fnode read on rotating media / a stalled
device is 5-20ms. The guest kernel is therefore instrumented
(instrument_applied.diff, guest-only, never in the audit sys/ tree) with
a 10ms tsleep inside the two failure paths β labeled DF3253 β reproducing
cold-I/O latency. The fix validation runs on the SAME instrumented kernel:
the window stays 10ms, the fix must still yield zero handoffs.
DF-3253 β VERDICT
Status: REPRODUCED (poisoned H_INVAL hpfsnode handoff via hpfs_hphashvget,
unprivileged, crafted HPFS image) β plus a demonstrated secondary consequence:
kernel panic (unmount: dangling vnode) on the subsequent umount in 2 of 4
racing runs. Fix validated on the same instrumented kernel: handoffs 0,
960/960 lookups fail EINVAL, umount clean.
Root cause (line-accurate)
hpfs_vget (sys/vfs/hpfs/hpfs_vfsops.c:456-552):
- :488
hp = kmalloc(sizeof(struct hpfsnode), M_HPFSNO, M_WAITOK)β not zeroed - :506
hp->h_flag = H_INVAL; - :525
hpfs_hphashins(hp);β published into the global inode hash - :527 lockmgr release
- :529
error = bread(...)β fails (truncated image) β :532vx_put(vp); return (error);β node stays hashed,h_fnis uninitialized kernel heap - :538
if (hp->h_fn.fn_magic != FN_MAGIC)β :540vx_put(vp); return (EINVAL);β node stays hashed with the rejected fnode H_INVALis cleared only at :545 on the success path;vp->v_typeis set only at :544 β failed creators leavev_type == VNON.
hpfs_hphashvget (sys/vfs/hpfs/hpfs_hash.c:104-139 β the audited file):
- :111-113 a concurrent lookup finds the published node,
- :116
vget(vp, LK_EXCLUSIVE)blocks on the creator's VX lock β the waiter's reference is taken at sys/kern/vfs_lock.c:589 BEFOREvn_lock, so the creator'svx_putcannot reclaim the node under it, - :122-129 post-wake re-walk verifies identity only β
h_flag & H_INVALis never checked β and the half-initialized vnode is returned as a successful lookup result at :135.
hpfs_lookup (hpfs_vnops.c:1056) hands it to namei without further checks.
hpfs_getattr (hpfs_vnops.c:463) then computes va_size = hp->h_fn.fn_size
from the rejected/uninitialized fnode.
Race shape (empirically established)
namei holds the directory vnode lock across VOP_LOOKUP, so lookups within ONE
directory are serialized and can never race (measured: 1451 publishes, 0
waiter hash-hits with a single-directory PoC). The handoff requires two
concurrent VFS_VGET()s for the SAME fnode through DIFFERENT directories β
provided directly by a crafted image with two dirents (A/ZFILE, B/WFILE)
both pointing at fnode 0x58. Nothing in HPFS cross-locks those two paths.
Demonstration (guest, stock INVARIANTS kernel #2 + labeled instruments)
Instruments (guest-only, never in the audit tree): 10ms tsleep in the two
failure paths (simulating cold-fnode bread latency; real-world 5-20ms on
rotating/stalled media) + counters/prints in hphashvget (DF3253H) and at
publish (DF3253P).
PoC: 8 threads Γ 120 stat()s alternating between the two paths, as uid
1001 on a vn(4)-mounted crafted image (mount by root via mount_rw).
Baseline results (3 independent runs):
DF-3253 SUMMARY: wins=40 einval=0 ebadf(poisoned handoffs seen by stat)=40andwins=19 ... ebadf=19β every stat that did not become a fresh creator received a poisoned vnode (kernel counterINVAL HANDOFFmatches; console showsvget OKβ*** INVAL HANDOFF #N ***).UTIMES WIN tidN pathX: utimes()=0 on a fnode the kernel ALWAYS rejects(27 printed in one run) β full syscall SUCCESS (hpfs_setattrutimes path, hpfs_vnops.c:530-544, has no v_type check) on a fnode whose lookup can only ever fail β it also setsH_PARCHANGEon the poisoned node (kernel state mutated through the rejected fnode).stat()itself returns EBADF: the poisoned vnode reachescvn_stat'svp->v_typeswitch (sys/kern/vfs_vnops.c:894-895default: return (EBADF)) becausev_typewas never initialized β i.e. the EBADF is itself the signature that a VNON vnode was returned by a SUCCESSFUL lookup (the normal failure is EINVAL from vget).- Panic: 2 of 4 racing runs ended with
panic: unmount: dangling vnode(dounmount β vflush unable to drain the poisoned/mutated vnodes) β local kernel DoS. Console evidence:panic.console.txt,panic2.console.txt(both showINVAL HANDOFF #Nimmediately before the panic), plus one clean-umount baseline (run.baseline3).
Uninitialized-heap nuance (trunc variant, bread fails at :529): same publish
window and same handoff mechanics (identical code path); h_fn holds stale
kernel heap that hpfs_getattr reads (fn_size, fn_parent walk). Direct
byte disclosure to userspace is blocked on this path by the VNON abort in
cvn_stat; the heap bytes still drive kernel-side metadata interpretation
(validateparent walk, va_size/va_bytes computation). Not separately
re-run β the badmagic variant exercises the identical two error paths.
Fix validation (same instrumented kernel #2, module swap only)
fix.diff = (a) hpfs_hphashrem(hp) on BOTH post-publish error paths in
hpfs_vget (load-bearing half β validated alone: forced full module rebuild,
0 handoffs) + (b) H_INVAL re-check/retry in hpfs_hphashvget after the
identity re-walk (defense-in-depth).
| build | poisoned handoffs (EBADF) | utimes wins | umount |
|---|---|---|---|
| baseline (instr) run1 | 40 | 40 | panic |
| baseline run3 | 19 | 19 | OK |
| unhash-only fix | 0 (960/960 EINVAL) | 0 | OK |
Identical PoC, identical 10ms window: with the fix the previously observed behavior is GONE β every lookup fails EINVAL (the only legitimate outcome), no handoffs (kernel counter 0), unmount clean.
Honest limits
- The stat()==0-with-attacker-size observable is NOT achievable on the stock failure paths (v_type is never set on failure β cvn_stat aborts EBADF); the demonstrated syscall-success observable is utimes()==0 on the rejected fnode, which is strictly stronger as an integrity signal.
- The dangling-vnode panic reproduced 2/4 times (timing-dependent vnode lifecycle after handoff + PARCHANGE mutation); root-cause of which vnode dangles was not further dissected (ddb session not interactive through this harness).
- Attack preconditions: crafted HPFS image mounted (root or usermount) + local unprivileged user racing lookups; the 10ms validation window is instrument-supplied in the demo but is the natural bread latency of any cold/stalled device (and grows without bound on a slow/erroring reader).
Impact
Unprivileged local user on a system with an attacker-influenced HPFS mount: (1) successful VFS lookups returning half-initialized/rejected inodes β rejected-metadata integrity bypass (utimes/metadata mutations on inodes the kernel itself refuses) and uninitialized-kernel-heap-driven metadata walks; (2) kernel panic at umount (local DoS). No userβroot chain demonstrated (read-side/type-confusion primitive; no controlled write). Severity Medium (matches the DF-3251 ext2 twin class), bucket base:vfs.
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
fixedWith fix.diff (creator-side unhash validated alone via forced full module rebuild): identical PoC, identical 10ms window β 0 poisoned handoffs (kernel counter 0), 0 EBADF, 960/960 lookups fail EINVAL, umount clean (previously 19-40 handoffs and 2/4 umount panics).
['findings/poc/DF-3253/fix.diff', 'findings/poc/DF-3253/run.unhashonly.badmagic.log', 'findings/poc/DF-3253/VERDICT.md#fix-validation']
Confirmed kernel references
- sys/vfs/hpfs/hpfs_hash.c:104
- sys/vfs/hpfs/hpfs_hash.c:116
- sys/vfs/hpfs/hpfs_hash.c:122
- sys/vfs/hpfs/hpfs_vfsops.c:525
- sys/vfs/hpfs/hpfs_vfsops.c:529
- sys/vfs/hpfs/hpfs_vfsops.c:538
- sys/vfs/hpfs/hpfs_vnops.c:463
- sys/vfs/hpfs/hpfs_vnops.c:530
- sys/vfs/hpfs/hpfs_vnops.c:1056
- sys/kern/vfs_lock.c:589
- sys/kern/vfs_vnops.c:894
Detail
Exploit chain
crafted HPFS image (two dirents -> same bad-magic fnode 0x58) -> root/usermount mounts it -> unprivileged user races lookups via the two directories (per-directory dvp locks do not serialize cross-directory vgets) -> waiter blocks in vget on creator's VX lock (its ref taken first, vfs_lock.c:589) -> failing creator vx_puts -> waiter receives poisoned H_INVAL node as successful lookup -> hpfs_getattr reads h_fn (rejected fnode / uninit heap in bread-failure variant) -> utimes()=0 mutates the rejected inode (H_PARCHANGE) -> subsequent umount panics 'unmount: dangling vnode' (kernel DoS, 2/4 runs). No user->root chain: read-side/type-confusion primitive only.
Evidence (decisive lines)
["run.baseline3.badmagic.log: 'DF-3253 SUMMARY: wins=19 einval=37 ebadf(poisoned handoffs seen by stat)=19' + 'UTIMES WIN ... utimes()=0 on a fnode the kernel ALWAYS rejects'", "run.unhashonly.badmagic.log: 'DF-3253 SUMMARY: wins=0 einval=960 ... ebadf=0' + UMOUNT_OK (fix validation)", "panic.txt / panic2.txt: 'DF3253H: *** INVAL HANDOFF #N ***' immediately followed by 'panic: unmount: dangling vnode' (dounmount backtrace)", 'VERDICT.md: full run matrix (40/19/0 handoffs), fix attribution, race-shape analysis']
PoC changes
Seed concept (from the DF-3251 twin pattern) reworked entirely: (1) single-directory racing is impossible β namei holds the dvp lock across VOP_LOOKUP (measured 1451 publishes / 0 waiter hash-hits) β so the image now has two directories whose dirents share the victim fnode; (2) stat()==0 is unreachable on the stock failure paths (v_type never set -> cvn_stat aborts EBADF, sys/kern/vfs_vnops.c:894) β the EBADF is kept as the handoff signature and utimes()==0 (hpfs_setattr has no v_type check) as the syscall-success proof; (3) unbuffered stdout + background/poll runner because serial-console kprintf spam costs ~100ms per failed lookup; (4) guest instrumented with a labeled 10ms tsleep in the two failure paths simulating cold bread latency (never applied to the audit sys/ tree).
Verified recommended fix
hpfs_vget: hpfs_hphashrem(hp) on both post-publish error paths (bread failure + bad FN_MAGIC) before vx_put; hpfs_hphashvget: retry instead of returning nodes still marked H_INVAL (fix.diff, validated).
Verdict
REPRODUCED: hpfs_hphashvget (sys/vfs/hpfs/hpfs_hash.c:104-139) hands half-initialized H_INVAL hpfsnodes to concurrent lookups because hpfs_vget publishes into the inode hash (hpfs_hphashins, hpfs_vfsops.c:525) before bread/FN_MAGIC validation (:529-541) and both error paths vx_put() without unhashing. On the guest (stock INVARIANTS kernel + 10ms cold-I/O-window instrument), an unprivileged user (uid 1001) racing stat() through two crafted directories whose dirents share one always-rejected fnode received poisoned vnodes in 40/40 and 19/19 steady-state lookups (kernel counter 'INVAL HANDOFF' matches), got full utimes()=0 syscall successes on the rejected fnode (hpfs_setattr has no v_type check), and 2 of 4 runs panicked the kernel at the subsequent umount ('unmount: dangling vnode'). stat() itself aborts EBADF at cvn_stat's VNON switch β the EBADF is the observable signature of the handed-off VNON vnode. Fix (hpfs_hphashrem on both post-publish error paths + H_INVAL retry in hphashvget) validated on the same instrumented kernel: 0 handoffs, 960/960 lookups EINVAL, umount clean.
No comments yet.