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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3253 Β· 20 files
FileTypeDescriptionSize
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 so bread fails -> h_fn stays uninitialized heap.
  • poc.c β€” N threads racing stat() of the always-rejected file. Sequentially stat can only return EINVAL. Any stat()==0 (printed as RACE WIN, with st_size=0x00BADF00D for 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.

VERDICT.md
↓ download raw

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) β†’ :532 vx_put(vp); return (error); β€” node stays hashed, h_fn is uninitialized kernel heap
  • :538 if (hp->h_fn.fn_magic != FN_MAGIC) β†’ :540 vx_put(vp); return (EINVAL); β€” node stays hashed with the rejected fnode
  • H_INVAL is cleared only at :545 on the success path; vp->v_type is set only at :544 β€” failed creators leave v_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 BEFORE vn_lock, so the creator's vx_put cannot reclaim the node under it,
  • :122-129 post-wake re-walk verifies identity only β€” h_flag & H_INVAL is 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)=40 and wins=19 ... ebadf=19 β€” every stat that did not become a fresh creator received a poisoned vnode (kernel counter INVAL HANDOFF matches; console shows vget 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_setattr utimes path, hpfs_vnops.c:530-544, has no v_type check) on a fnode whose lookup can only ever fail β€” it also sets H_PARCHANGE on the poisoned node (kernel state mutated through the rejected fnode).
  • stat() itself returns EBADF: the poisoned vnode reaches cvn_stat's vp->v_type switch (sys/kern/vfs_vnops.c:894-895 default: return (EBADF)) because v_type was 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 show INVAL HANDOFF #N immediately 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

fixed
baseline reproduced→ patch + rebuild →patched clean

With 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2 (in-guest rebuild, hpfs.ko module swap with fix.diff; tsleep instrument retained so the validation window stayed 10ms)

Confirmed kernel references

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.