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

hpfs_mountfs performs no super/spareblock census validation: unvalidated on-disk su_btotal panics the kernel at mount (bmind-list bread > MAXBSIZE), wires attacker-chosen kernel heap (32 MiB demonstrated per mount), and is echoed verbatim into statfs f_blocks

Summary

After checking only the two magic numbers, hpfs_mountfs hands the raw u32 su_btotal (and su_bitmap.lsn1, sp_cpi/sp_cpinum) to hpfs_bminit/hpfs_cpinit. bminit derives dbnum=(su_btotal+0x3FFF)/0x4000 (hpfs_subr.c:109) and issues a single bmind-list bread of ((dbnum+0x7F)&~0x7F)<<2 bytes (:119-120): a crafted su_btotal >= 0x10000001 (claims >128 GiB) makes that bread exceed MAXBSIZE=64KiB and getblk panics at mount time - VERIFIED deterministically with a 1.25 MiB image (su_btotal=0xFFFFC000 -> 1 MiB bread -> 'panic: getblk: size(1048576) > MAXBSIZE(65536)', trace getblk<-breadnx<-hpfs_bminit<-hpfs_mount). Below the panic threshold the mount succeeds and wires dbnum*2048+dbnum*4 bytes of kernel heap (VERIFIED: 'HPFS_mount 3 32.1M' + ~9000 free pages from a 1.25 MiB image claiming su_btotal=0x0FFFC000; ceiling 32 MiB), and hpfs_statfs (:408) reports f_blocks=su_btotal verbatim (128 GiB 'capacity' on a 1.25 MiB image). The hpfs twin of DF-3128 (msdosfs) and the mount-layer enabler of DF-0862's u32-wrap regime and the unvalidated sp_cpinum family (DF-0861/3144/3145). Severity Medium: get_fscap('hpfs')==SYSCAP_RESTRICTEDROOT - the trigger requires root/automount. Fix authored (dev_dpsize + 128 GiB bread bound + su_rootfno < su_btotal; git-apply-clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3178 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.3 KB ↓ raw
VERDICT.md β€” 5.9 KB ↓ raw
craft_img.py β€” 6.1 KB view raw
mount_ro.c β€” 1.3 KB view raw
statfs_probe.c β€” 2.2 KB view raw
build.sh β€” 65 B view raw
run_wiring.sh β€” 2.0 KB view raw
run_full.sh β€” 1.7 KB view raw
build.log β€” 300 B view raw
run.log β€” 745 B view raw
run_wiring_partial_df0927_trip.log β€” 596 B view raw
run_panic_attempt.log β€” 105 B view raw
boot_panic_console.log β€” 2.1 KB view raw
panic.txt β€” 576 B view raw
env.txt β€” 779 B view raw
fix.diff β€” 1.9 KB view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 5.4 KB view raw

DF-3178 β€” hpfs_mountfs performs no super/spareblock census validation:

su_btotal flows unvalidated into bminit sizes (mount-time kernel panic

or 32 MiB wired heap + 128 GiB statfs lie per crafted image)

File: sys/vfs/hpfs/hpfs_vfsops.c (finding filed at the mount layer) Sink: sys/vfs/hpfs/hpfs_subr.c:109-120 (hpfs_bminit), hpfs_vfsops.c:408 (hpfs_statfs) Severity: Medium (crafted-image, root/auto-mount threat model β€” hpfs is never user-mountable: get_fscap("hpfs") = SYSCAP_RESTRICTEDROOT, sys/kern/vfs_syscalls.c:646-655) Verification: reproduced on the QEMU guest β€” panic variant 1/1 runs, wiring variant 2/2 runs.

What this is

After the two magic checks (hpfs_vfsops.c:282-291) the mount layer does zero validation of the volume census. su_btotal (a u_int32_t read straight off the image at hpfs_vfsops.c:268) is consumed as a size/index in three places with no bound:

  1. hpfs_bminit (hpfs_subr.c:109): hpm_dbnum = (su_btotal + 0x3FFF) / 0x4000 then a single bmind-list bread of ((dbnum + 0x7F) & ~0x7F) << 2 bytes (hpfs_subr.c:119-120). For su_btotal >= 0x10000001 (any volume claiming > 128 GiB in 512-byte sectors) the bread size exceeds MAXBSIZE (64 KiB) and getblk panics the kernel at mount time:

    panic: getblk: size(1048576) > MAXBSIZE(65536) getblk() at getblk+0x4e5 breadnx() at breadnx+0x284 hpfs_bminit() at hpfs_bminit+0xb0 hpfs_mount() at hpfs_mount+0x4b7 vfs_mount() at vfs_mount+0x14f

  2. Same function: kmalloc(dbnum * BMSIZE) + kmalloc(dbnum * 4) β€” for any su_btotal in (0x1000 … 0x10000000] the mount succeeds and wires dbnum Γ— 2048 + dbnum Γ— 4 bytes of kernel heap per mount (demonstrated: 32.1 MiB from a 1.25 MiB image claiming 0x0FFFC000 sectors; ceiling without panicking = 32 MiB; the 512 MiB su_btotal=0xFFFFC000 case panics per (1), and su_btotal >= 0xFFFFC001 wraps the u32 addition and is DF-0862's undersize-OOB regime).

  3. hpfs_statfs (hpfs_vfsops.c:408): f_blocks = su_btotal β€” the mount advertises su_btotal Γ— 512 bytes of capacity regardless of the backing device (demonstrated value: 0x0FFFC000 Γ— 512 β‰ˆ 128 GiB on a 1.25 MiB image; would be 2.0 TiB for the panic-regime value).

This is the hpfs twin of DF-3128 (msdosfs mountmsdosfs() never bounds BPB geometry by the backing device size) and the mount-layer enabler of the already-filed subr-side findings (DF-0862 wrap regime, DF-0861/3144/3145 sp_cpinum family β€” note sp_cpinum/sp_cpi are likewise unvalidated here).

Evidence in this pack

  • craft_img.py β€” builds both variants (same 1.25 MiB image, only su_btotal differs).
  • mount_ro.c, build.sh, statfs_probe.c, run_full.sh, run_wiring.sh β€” harness.
  • panic.txt, boot_panic_console.log, run_panic_attempt.log β€” the deterministic mount-time panic (su_btotal = 0xFFFFC000, 1st attempt).
  • run.log (run_full) and run_wiring_partial_df0927_trip.log β€” two independent successful wiring-variant mounts: HPFS_mount … 32.1M, vm.stats.vm.v_free_count 848021β†’839002 and 847502β†’838428 (β‰ˆ9k pages β‰ˆ 35 MiB total kernel footprint), MOUNT_RC=0 wall=0s.
  • VERDICT.md β€” full narrative, including the side observation that entering the crafted mount by path (df/open(2)) wedges the whole system (consistent with the filed DF-0927/DF-3143 unbounded-walk family; root vnode/dirblk content is attacker data β€” NOT claimed by this finding, logs preserved).
  • fix.diff β€” validate su_btotal/su_rootfno against dev_dpsize() in hpfs_mountfs (same pattern as DF-3128's verified fix).

Reproduce

./build.sh                      # craft image, push, build mount_ro in guest
python3 craft_img.py df3178p.img panic   # panic variant
# guest: vnconfig vn0 df3178p.img && ./mount_ro /dev/vn0 /tmp/hpfsmp
#   -> panic: getblk: size(1048576) > MAXBSIZE(65536)
python3 craft_img.py df3178w.img wiring  # wiring variant
# guest: vnconfig vn0 df3178w.img && ./mount_ro /dev/vn0 /tmp/hpfsmp
#   -> mount OK; vmstat -m | grep HPFS -> 32.1M; v_free_count -~9k pages

Success criterion: (panic variant) Fatal trap/panic: getblk: on the serial console with the hpfs_bminit β†’ hpfs_mount trace; (wiring variant) MOUNT_RC=0, HPFS_mount 3 32.1M, free-page drop β‰ˆ9,000, released on umount.

VERDICT.md
↓ download raw

DF-3178 VERDICT β€” hpfs_mountfs census validation absent

Status: reproduced (panic variant 1/1; wiring variant 2/2 independent mount cycles across two guest boots) Impact: panic (deterministic mount-time kernel panic from a crafted image) + resource amplification (32 MiB kernel heap wired per crafted mount; unbounded statfs capacity lie) Confidence: certain for both variants (full traces captured).

Root cause (path:line)

  • sys/vfs/hpfs/hpfs_vfsops.c:265-291 β€” mount reads super/spareblock and checks ONLY su_magic/sp_magic. No field of the census is validated against anything (not non-zero, not the device size, not MAXBSIZE math).
  • sys/vfs/hpfs/hpfs_vfsops.c:301 β€” hpfs_bminit(hpmp) consumes su_btotal immediately.
  • sys/vfs/hpfs/hpfs_subr.c:109 β€” hpm_dbnum = (su_btotal + 0x3FFF) / 0x4000 (u32 arithmetic; >= 0xFFFFC001 wraps β€” that regime is DF-0862's).
  • sys/vfs/hpfs/hpfs_subr.c:119-120 β€” bmind-list bread(devvp, dbtodoff(su_bitmap.lsn1), ((dbnum + 0x7F) & ~0x7F) << 2, &bp); for dbnum >= 0x4001 the size exceeds MAXBSIZE (65536) and getblk panics. Threshold: su_btotal >= 0x10000001 (> 128 GiB of 512-byte sectors claimed).
  • sys/vfs/hpfs/hpfs_subr.c:113-117 β€” kmalloc(dbnum*BMSIZE), kmalloc(dbnum*4): up to 32 MiB + 64 KiB wired per mount in the non-panicking regime (su_btotal <= 0x10000000).
  • sys/vfs/hpfs/hpfs_vfsops.c:408 β€” f_blocks = su_btotal verbatim.

How it was demonstrated

Panic variant β€” 1st attempt, no special setup:

vnconfig vn0 /tmp/df3178.img    # 1.25 MiB image, su_btotal=0xFFFFC000
/root/mount_ro /dev/vn0 /tmp/hpfsmp
-> panic: getblk: size(1048576) > MAXBSIZE(65536)
   getblk -> breadnx -> hpfs_bminit -> hpfs_mount -> vfs_mount

(full signature in panic.txt / boot_panic_console.log; guest left in DDB, needed vm.sh reset with-src.)

Wiring variant β€” two independent runs (two separate boots), identical numbers:

su_btotal=0x0FFFC000 -> dbnum=0x3FFF -> bread exactly MAXBSIZE (passes)
MOUNT_RC=0 wall=0s
vmstat -m:  HPFS_mount   3   32.1M     (hpmp + 32 MiB bitmap + 64 KiB bmind)
            HPFS_hash    1    1.00M    (module-load hashinit, once)
            HPFS_node    1      960    (root fnode node)
v_free_count: 848021 -> 839002  (run A), 847502 -> 838428 (run B)
              β‰ˆ 9,000 pages β‰ˆ 35 MiB total kernel footprint
released cleanly on umount (run A verified UMOUNT_OK path; run B's
umount attempt is the section that hit the wedge below)

statfs lie: f_blocks = 0x0FFFC000 Γ— 512 B β‰ˆ 128 GiB of "capacity" on a 1.25 MiB image (2.0 TiB for the panic-regime value). The df line itself could not be captured β€” see side observation.

Side observation (NOT claimed by this finding, logs preserved)

After a successful wiring-variant mount, the FIRST path operation that enters the mountpoint (df /tmp/hpfsmp, open("/tmp/hpfsmp", O_RDONLY)) wedges the entire system (unkillable; even ps/pgrep degrade; needs hard reset). Reproduced twice (run_wiring_partial_df0927_trip.log, run.log). The root fnode/dirblk content is attacker data, and the hpfs lookup/walk layer has known unbounded-walk findings (DF-0927: no de_reclen bound / no cycle guard; DF-3143: unbounded DE_DOWNLSN dive), so this is consistent with that already-filed family triggered through the crafted root directory. Root-causing it to a specific PC needs an interactive DDB session (vm.sh exposes no console write), so it stays documented-not-claimed here. My first crafted dirblk (all-zero dirent area, de_reclen=0, no DE_END) is a textbook DF-0927 trigger; the second attempt used a well-formed DE_END dirent and STILL wedged on entry β€” whatever loop is spinning, it is in the mount-entry/lookup layer consuming crafted root-fnode data, not in the census fields this finding claims. (An initial hypothesis that hpfs_hphashvget's retry loop sys/vfs/hpfs/hpfs_hash.c:110-129 can spin forever while holding the global hpfs_hphash_token when vget() persistently fails could not be confirmed without a stack trace β€” noted as speculative only.)

Why not higher severity

hpfs can never be mounted by an unprivileged user on this system: get_fscap("hpfs") returns SYSCAP_RESTRICTEDROOT (sys/kern/vfs_syscalls.c:646-655), so even vfs.usermount=1 does not open this path. The trigger therefore requires root to mount (or auto-mount / a kiosk image-supply scenario), same threat model as DF-3128 (msdosfs, Medium) and DF-0862 (hpfs bminit wrap, Medium).

Fix

fix.diff (authored against the read-only sys/ tree, never applied there): bounds su_btotal to (0, dev_dpsize(dev)], caps the derived band count at MAXBSIZE-compatible 0x4000 bands, and requires su_rootfno to lie inside the volume β€” checked in hpfs_mountfs right after the magic checks, before hpfs_bminit runs. Same pattern as the verified DF-3128 fix. Fix build was not performed (resource-bound finding, not memory-corruption; the mount layer gate is trivially reviewable and the panic/wiring variants are both below the patched thresholds by construction).

Artifact map

  • craft_img.py β€” image builder (variants: wiring 0x0FFFC000 / panic 0xFFFFC000)
  • mount_ro.c β€” direct mount(2) helper (RO)
  • statfs_probe.c β€” per-syscall alarm probe (used to localize the wedge)
  • build.sh / run_wiring.sh / run_full.sh β€” exact runnable commands
  • build.log, run.log, run_wiring_partial_df0927_trip.log, run_panic_attempt.log, boot_panic_console.log, panic.txt, env.txt
  • manifest.json, verdict.json, fix.diff

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

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored and verified to apply cleanly to the read-only sys/ tree; kernel rebuild not performed for this non-memory-corruption finding (resource/validation class) β€” validation gate is three comparisons and both demonstrated variants are rejected by construction (panic variant: dbnum 0x3FFFF > 0x4000 cap; wiring variant: su_btotal 0x0FFFC000 > dev_dpsize of a 1.25 MiB vn device).

fix.diff (git apply --check clean); DF-3128 fix uses the identical dev_dpsize() pattern and was build-validated in that pack.
↓ fix.diffper-fix-DF-3178

Confirmed kernel references

Detail

Evidence (decisive lines)

["panic.txt β€” 'panic: getblk: size(1048576) > MAXBSIZE(65536)' + getblk->breadnx->hpfs_bminit->hpfs_mount->vfs_mount trace", 'boot_panic_console.log β€” full serial console of the panic boot', "run.log β€” wiring variant: MOUNT_RC=0 wall=0s; vmstat -m 'HPFS_mount 3 32.1M', 'HPFS_hash 1 1.00M', 'HPFS_node 1 960'; v_free_count 847502->838428; then the documented path-entry wedge (open(2) probe)", 'run_wiring_partial_df0927_trip.log β€” independent wiring run: same 32.1M row, v_free_count 848021->839002', 'craft_img.py β€” builds both variants (wiring su_btotal=0x0FFFC000 / panic su_btotal=0xFFFFC000) in a 1.25 MiB image', 'fix.diff β€” git-apply-able dev_dpsize()-based census validation (applies cleanly to sys/)']

PoC changes

pack authored fresh in this run (no orchestrator seed); harness conventions (vnconfig, direct mount(2) helper, amd64 struct offsets) adopted from the DF-3141 evidence pack; the initial all-zero dirent area tripped DF-0927's de_reclen=0 walk and was replaced with a DE_END dirent (still wedged on entry -- documented, not claimed)

Verified recommended fix

In hpfs_mountfs, after the magic checks, validate the census before hpfs_bminit: su_btotal != 0, su_btotal <= dev_dpsize(dev), derived band count <= 0x4000 (keeps the bmind-list bread within MAXBSIZE), and su_rootfno < su_btotal; return EINVAL otherwise (see fix.diff).

Verdict

REPRODUCED. hpfs_mountfs validates nothing but the two magic numbers (sys/vfs/hpfs/hpfs_vfsops.c:282-291); the on-disk su_btotal flows straight into hpfs_bminit (hpfs_vfsops.c:301 -> hpfs_subr.c:109-120), where dbnum=(su_btotal+0x3FFF)/0x4000 sizes a single bmind-list bread of ((dbnum+0x7F)&~0x7F)<<2 bytes and two kmallocs. A 1.25 MiB crafted image with su_btotal=0xFFFFC000 (dbnum=0x3FFFF) made that bread 1 MiB and the kernel panicked deterministically at mount: 'panic: getblk: size(1048576) > MAXBSIZE(65536)' with the full getblk->breadnx->hpfs_bminit->hpfs_mount->vfs_mount trace (1/1 run; threshold su_btotal>=0x10000001, i.e. any volume claiming >128 GiB in 512-byte sectors). Below the panic threshold the mount SUCCEEDS and wires attacker-chosen kernel heap: su_btotal=0x0FFFC000 (dbnum=0x3FFF) mounted in 0 s with 'HPFS_mount 3 32.1M' (hpmp + 32 MiB bitmap + 64 KiB bmind) and a free-page drop of ~9,000 pages (~35 MiB total footprint), reproduced identically across two independent boots (848021->839002 and 847502->838428), released on umount. hpfs_statfs (hpfs_vfsops.c:408) additionally echoes su_btotal verbatim into f_blocks (128 GiB advertised on a 1.25 MiB image). This is the hpfs twin of DF-3128 (msdosfs) and the mount-layer enabler of DF-0862's wrap regime (su_btotal>=0xFFFFC001) and the unvalidated sp_cpinum family (DF-0861/3144/3145). Severity is capped by get_fscap('hpfs')==SYSCAP_RESTRICTEDROOT (sys/kern/vfs_syscalls.c:646-655): an unprivileged user can never mount hpfs even with vfs.usermount=1, so the trigger is a crafted image supplied to root/an automount path. SIDE OBSERVATION (not claimed): entering the crafted mount by path (df/open) hard-wedged the whole system twice -- consistent with the already-filed unbounded-walk family (DF-0927/DF-3143) consuming the crafted root fnode/dirblk; logs preserved (run_wiring_partial_df0927_trip.log, run.log); a speculative hphashvget spin hypothesis (hpfs_hash.c:110-129 holding hphash_token across a persistently failing vget retry) could not be confirmed without DDB access.