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

HPFS EA ioctls walk past fn_int using unvalidated on-disk fn_ealen β€” kernel heap info leak via copyout

Summary

hpfs_vnops.c:155/189/228 while(passed<hp->h_fn.fn_ealen) β€” fn_ealen u16 from disk unvalidated (hpfs_vfsops.c:535 bcopy no validation). fn_int is u8[0x13c]=316 bytes (hpfs.h:209). fn_ealen>0x13c: loop reads struct ea headers past fn_int into adjacent hpfsnode fields (h_vp h_devvp kernel pointers) and kmalloc slab. HPFSIOCRDEA :232-235 copyout(EA_NAME(eap),rdeap->ea_data,rdeap->ea_sz=ea_namelen+1+ea_vallen) attacker-controlled sum up to 65791 = ~65KB kernel heap OOB read to user. HPFSIOCGEANUM/GEASZ leak via kprintf(%s EA_NAME) to dmesg. NO privilege check in hpfs_ioctl any local user with O_RDONLY. Trigger: crafted HPFS image fn_ealen=0xFFFF first EA ea_vallen=0xFFFF mount then ioctl(HPFSIOCRDEA). Impact: KASLR bypass kernel pointers h_vp/h_devvp leaked. Fix: clamp fn_ealen<=sizeof(fn_int) at fnode load + min(fn_ealen,sizeof(fn_int)) in loops + cap copyout size.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0829 Β· 21 files
FileTypeDescriptionSize
poc.c trigger-source unprivileged open+HPFSIOCRDEA trigger; scans leak buffer for kernel pointers 5.8 KB view raw
hpfs_ioctl.h trigger-source local copy of sys/vfs/hpfs/hpfs_ioctl.h for userland build 446 B view raw
mk_hpfs.py trigger-source crafts minimal-but-valid HPFS image with fn_ealen=0xFFFF and first EA ea_vallen=0xFFFE;honours kernel C struct alignment 8.3 KB view raw
evil.hpfs trigger-asset crafted 100 KB HPFS image (output of mk_hpfs.py) 100.0 KB ↓ download
sz.c trigger-source userspace mirror of struct fnode/sublock/spblock used to verify byte offsets 4.3 KB view raw
build.sh build-script cc -Wall -O0 -o poc poc.c 207 B view raw
run.sh run-script mounts setup + ./poc /mnt/hpfs 903 B view raw
VERDICT.md verdict full narrative: mechanism, demo, impact, fix validation 8.2 KB ↓ raw
fix.diff suggested-fix git-apply-able: clamp fn_ealen at fnode load + HPFS_EA_FITS bounds check in all 3 EA ioctl loops 3.1 KB view raw
run.log run-log decisive baseline run (kernel pointers leaked) 790 B view raw
run.2.log run-log fresh-mount run 2 (different h_vp) 153 B view raw
run.3.log run-log fresh-mount run 3 (different h_vp) 153 B view raw
baseline_before.log run-log phase-8 baseline run on unpatched #0 kernel 258 B view raw
leak_sample.txt leak-sample leaked h_vp value across 3 fresh-mount runs (proves real heap addresses) 338 B view raw
fix_build.log build-log full nativekernel build output for the single-fix kernel 5.6 MB ↓ download
fix_run.log run-log PoC re-run on patched kernel: ENOENT, 0 kernel pointers 494 B view raw
fix_kernel_uname.txt environment kern.version of the single-fix kernel (#1) 59 B view raw
env.txt environment uname, vfs.usermount, kldstat 246 B view raw
manifest.json manifest this file 4.1 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
VERDICT.md verdict full narrative: mechanism, demo, impact, fix validation
↓ download raw

DF-0829 β€” HPFS EA ioctls walk past fn_int using unvalidated on-disk fn_ealen

Verdict: REPRODUCED β€” kernel heap info leak (KASLR-bypass class).

A crafted HPFS image whose root fnode lies about fn_ealen causes the extended-attribute ioctl handlers in sys/vfs/hpfs/hpfs_vnops.c (HPFSIOCGEANUM, HPFSIOCGEASZ, HPFSIOCRDEA) to walk past the 316-byte fn_int buffer and into the adjacent struct hpfsnode fields (h_vp, h_devvp, h_dev, h_no, …). HPFSIOCRDEA then copyouts attacker-sized bytes from that out-of-bounds region straight to userspace. There is no privilege check in hpfs_ioctl, so any local user with O_RDONLY on a vnode inside the mount can leak kernel pointers β€” defeating KASLR (relevant on hardened kernels; on this audit guest KASLR is already off, but the leak is real and reproducible).

Threat model (realistic)

Per the audit's realism test, the preconditions here are exactly the "attacker-controlled filesystem image" pattern called out as acceptable: - the HPFS module is loadable on the guest (/boot/kernel/hpfs.ko shipped with the install) and kldload'd by an admin β€” equivalent to an admin turning on any FS support; and - an admin has mounted / made mountable an attacker-controlled image.

Once mounted, the bug is reachable by any unprivileged user via a stock syscall (open + ioctl). No kldload/setuid-root helper/non-default kernel anywhere in the chain. HPFS is optional hpfs (loadable module), not in X86_64_GENERIC, so the realistic shape of this bug is "admin mounted an HPFS volume" β€” the same posture as every other FS-image-parsing finding in this audit.

Mechanism (every hop cited)

  1. sys/vfs/hpfs/hpfs_vfsops.c:535 β€” bcopy(bp->b_data, &hp->h_fn, sizeof(struct fnode)) loads the on-disk fnode verbatim into the in-memory struct hpfsnode. fn_ealen (a u_int16_t at offset 56 in the kernel's C struct layout) is taken from disk with no validation.
  2. sys/vfs/hpfs/hpfs_vnops.c:155, :189, :228 β€” the three EA ioctl handlers all share the same loop shape: c while (passed < hp->h_fn.fn_ealen) { eap = (struct ea *)((caddr_t)hp->h_fn.fn_int + passed); ... passed += sizeof(struct ea) + eap->ea_namelen + 1 + eap->ea_vallen; } fn_int is u_int8_t fn_int[0x13c] (316 bytes). The loop never bounds passed against sizeof(fn_int), so any fn_ealen > 0x13c (or an EA whose ea_namelen+1+ea_vallen is large enough to stride past fn_int) walks the eap pointer past the buffer.
  3. sys/vfs/hpfs/hpfs_vnops.c:232-235 β€” HPFSIOCRDEA then does c rdeap->ea_sz = eap->ea_namelen + 1 + eap->ea_vallen; /* up to 65540 */ copyout(EA_NAME(eap), rdeap->ea_data, rdeap->ea_sz); EA_NAME(eap) = (char*)eap + sizeof(struct ea) is inside fn_int on the first iteration, but the copyout length is the attacker-controlled sum (up to 65540). That reads 65540 bytes starting inside fn_int, continuing past the end of struct fnode and into the live kernel pointers h_vp, h_devvp, h_dev, h_no, … that live immediately after h_fn in struct hpfsnode (sys/vfs/hpfs/hpfs.h:342-345).
  4. The copyout return value is not checked (error = 0 is unconditionally assigned right after), so the ioctl reports success even when the oversized read faults part-way through.

Demonstration

Craft a minimal-but-valid HPFS image (mk_hpfs.py) whose root fnode has fn_ealen = 0xFFFF and a first EA at fn_int[0] with ea_namelen=5, ea_vallen=0xFFFE. Mount it, then issue HPFSIOCRDEA as the unprivileged maxx user:

$ ./poc /mnt/hpfs
[*] opened /mnt/hpfs fd=3, issuing HPFSIOCRDEA (ea_no=0)
[*] ioctl rc=0, returned ea_sz=65540, errno=0 (Success)
=== leaked heap (4096 bytes) ===
  [+0x0000] 0000004141414141     <- EA name "AAAAA" (our own data, in fn_int)
  ...
  [+0x0138] 8f52c78000000000     <- low half of next field past fn_int
  [+0x0140] 16fc2400fffff800     <- high half of h_vp
  ...
[+] plausible kernel pointers in first 4 KB: 3 (first at +0x13c = 0xfffff8008f52c780)
[!!!] DF-0829 CONFIRMED: kernel heap (pointers to h_vp/h_devvp/etc.) leaked

The leaked value at buffer offset 0x13c is exactly hp->h_vp (the struct vnode * set at hpfs_vfsops.c:507 after the bcopy), confirmed by the kernel layout (sizeof(struct hpfsnode)=912, h_fn at offset 56, fn_int at offset 200 within h_fn, h_vp at offset 576 of struct hpfsnode β‡’ distance 576 - (56+200+4) = 316 = 0x13c). Across three fresh-mount runs the value changes (0xfffff8008f52c780, 0xfffff8008f52c480, 0xfffff8008f52c300), proving it is a real heap-address leak and not a constant.

Impact

  • Info leak / KASLR bypass. Leaks 3 plausible kernel pointers per call (h_vp, h_devvp, and one of h_dev/h_no) β€” enough to defeat KASLR on a kernel that has it.
  • OOB read. Up to ~64 KB of kernel heap is exposed per call (bounded only by the u_int16_t sums; in practice copyout faults at the first unmapped page, returning whatever it copied first).
  • No privilege check in hpfs_ioctl β€” any local user with O_RDONLY on a vnode inside an HPFS mount.

No write primitive is implied by this bug β€” it is a pure read OOB. There is therefore no escalation chain to uid=0 to develop; the realistic impact ceiling is kernel-pointer leak / KASLR defeat (correctly classified as Medium in the finding).

PoC changes from the seeded draft

The folder was empty when this run started, so the entire evidence pack was authored from scratch:

  • mk_hpfs.py β€” Python crafter for a minimal-but-valid HPFS image with a lying fn_ealen=0xFFFF. The non-obvious part: the kernel C struct layout (verified with a tiny kldload module) has natural-alignment padding, so fn_ealen lives at byte 56 of struct fnode (not 52) and fn_flag at byte 59 (not 55). The image also has to provide a minimal dir block + alleaf_t so hpfs_validateparent (called from hpfs_getattr during stat) doesn't EINVAL out before the PoC can even open the mountpoint.
  • poc.c / hpfs_ioctl.h β€” opens /mnt/hpfs O_RDONLY and issues HPFSIOCRDEA, then scans the returned buffer for canonical kernel pointers (0xffff_xxxx_xxxx_xxxx).
  • sz.c β€” userspace mirror of struct fnode/sublock/spblock used while debugging byte offsets; kept for reproducibility.
  • fix.diff β€” verified one-liner per-handler clamp + a load-time clamp.
  • build.sh / run.sh β€” exact reproduce commands.

Clamp fn_ealen against sizeof(fn_int) (a) at fnode load (hpfs_vfsops.c:535) so the rest of the kernel never sees a lying value, and (b) defensively inside each ioctl loop (hpfs_vnops.c) so an already-cached lying value cannot be walked either. The full git apply-able diff is in fix.diff (supersedes the finding markdown's proposal β€” see recommended_fix).

Fix validation (Phase 8)

The first fix attempt (load-time clamp only) failed: it capped the loop-iteration count to sizeof(fn_int), but the copyout() inside the loop still read eap->ea_namelen + 1 + eap->ea_vallen = 65540 bytes from inside fn_int on the first iteration β€” i.e. the same leak, just single-iteration. This was caught by actually building and booting the patched kernel and re-running the PoC (which still printed ea_sz=65540 and leaked 3 kernel pointers), exactly the failure mode Phase 8 is designed to catch.

The revised fix.diff adds an HPFS_EA_FITS() check that stops the loop the moment the current EA's header or name/value span would extend past sizeof(fn_int) β€” so every byte the loop reads (and every byte copyout() forwards) is provably inside fn_int.

Kernel kern.version PoC ea_sz Pointers leaked Verdict
baseline (unpatched #0, 2026-07-02) 6.5-DEVELOPMENT #0 65540 3 (h_vp=0xfffff80117fe3480) BUG
single-fix v1 (clamp-only) #1 6.5-DEVELOPMENT #1 65540 3 (still leaking) INSUFFICIENT
single-fix v2 (clamp + EA_FITS) #1 6.5-DEVELOPMENT #1 (rebuild) 0 (ENOENT) 0 FIXED

fix_status: fixed. The patched kernel boots, the PoC cleanly fails with ENOENT (no EA fits in the bounded fn_int), and zero kernel pointers are returned across three independent runs.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. PoC leaks 3 kernel pointers (h_vp=0xfffff80117fe3480) on the unpatched #0 baseline (baseline_before.log); on the single-fix #1 kernel (clamp + HPFS_EA_FITS, sha256 486454a0...) the SAME PoC returns errno=ENOENT with ea_sz=0 and ZERO kernel pointers across 3 independent runs (fix_run.log). The patched kernel boots cleanly and prints 'hpfs_vget: fnode 30 fn_ealen 65535 > 316, clamping' during mount, confirming the load-time clamp is active; the in-loop HPFS_EA_FITS check then rejects the oversized first EA, so HPFSIOCRDEA finds no valid EA and returns ENOENT. Note: the FIRST fix attempt (load-time clamp only) was insufficient -- it capped the loop iteration count but left the copyout() inside the loop reading 65540 bytes from inside fn_int, so the same 3 pointers still leaked; Phase 8 caught this and the fix was revised to add the per-EA bounds check.

BEFORE (unpatched #0): [*] ioctl rc=0, returned ea_sz=65540 ; [+] plausible kernel pointers in first 4 KB: 3 (first at +0x13c = 0xfffff80117fe3480) ; [!!!] DF-0829 CONFIRMED: kernel heap leaked.
AFTER (single-fix #1, three runs): [*] ioctl rc=-1, returned ea_sz=0, errno=2 (No such file or directory) ; [+] plausible kernel pointers in first 4 KB: 0  (3/3 runs identical).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Jul 11 03:17:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

none (read-only OOB primitive). Per Phase 6's valid hard blockers: this is a pure out-of-bounds READ with no write/corruption component, so there is no escalation chain to develop -- the realistic impact ceiling is kernel-pointer leak / KASLR defeat (Medium severity, correctly classified). No victim-object corruption, no function-pointer/refcount/uid overwrite possible. Documented the leak's impact ceiling (3 kernel pointers per call: h_vp, h_devvp, and h_dev/h_no) and moved to fix validation.

Evidence (decisive lines)

baseline run.log:
[*] ioctl rc=0, returned ea_sz=65540, errno=0 (Success)
  [+0x0000] 0000004141414141   <- EA name 'AAAAA' inside fn_int
  [+0x0138] 8f52c78000000000   <- low half of h_vp
  [+0x0140] 16fc2400fffff800   <- high half of h_vp
[+] plausible kernel pointers in first 4 KB: 3 (first at +0x13c = 0xfffff8008f52c780)
[!!!] DF-0829 CONFIRMED: kernel heap (pointers to h_vp/h_devvp/etc.) leaked to userspace via HPFSIOCRDEA
leak_sample.txt (3 fresh-mount runs):
  0xfffff8008f52c780
  0xfffff8008f52c480
  0xfffff8008f52c300

PoC changes

Folder was empty when this run started; the entire evidence pack was authored from scratch. mk_hpfs.py crafts a minimal-but-valid HPFS image whose root fnode has fn_ealen=0xFFFF + first EA ea_vallen=0xFFFE; the non-obvious part (verified with a tiny kld module, sz.c) is that the kernel C struct layout has natural-alignment padding, so fn_ealen lives at byte 56 (not 52) and fn_flag at byte 59 (not 55) -- using the wrong slot makes the kernel see fn_ealen=0 and the loop never runs. The image also provides a minimal 4 KB dir block + alleaf_t + self-parent so hpfs_validateparent (called from hpfs_getattr during stat) doesn't EINVAL out before the PoC can open the mountpoint. poc.c opens /mnt/hpfs O_RDONLY and issues HPFSIOCRDEA, then scans the returned buffer at every byte offset (not just 8-byte aligned, because the kernel pointer fields inside struct hpfsnode are not 8-byte aligned relative to fn_int) for canonical 0xffff_xxxx_xxxx_xxxx kernel addresses.

Verified recommended fix

Two-layer defence in fix.diff: (1) at fnode load (hpfs_vfsops.c:535), clamp hp->h_fn.fn_ealen to sizeof(hp->h_fn.fn_int) so the rest of the kernel never observes an impossible value (with a kprintf for forensics); (2) in each of the three EA ioctl loops (hpfs_vnops.c:155/189/228), add an HPFS_EA_FITS() bounds check that stops the loop the moment the current EA's header or its ea_namelen+1+ea_vallen name/value span would extend past sizeof(fn_int) -- this is the load-bearing fix, because the loop-iteration clamp alone does NOT cap the copyout() size inside the loop (verified: the first fix attempt still leaked 3 kernel pointers). Every byte the loop reads and every byte copyout() forwards is provably inside fn_int. Supersedes the finding markdown's 'clamp fn_ealen<=sizeof(fn_int)' proposal by adding the per-EA bounds check that the proposal omitted and which is required to actually close the leak.

Verdict

REPRODUCED. The HPFS EA-iteration ioctls (HPFSIOCGEANUM/GEASZ/RDEA in sys/vfs/hpfs/hpfs_vnops.c:155/189/228) walk a (struct ea*) pointer across hp->h_fn.fn_int using a per-iteration stride of sizeof(struct ea) + eap->ea_namelen + 1 + eap->ea_vallen, bounded only by hp->h_fn.fn_ealen -- an unvalidated u_int16_t copied verbatim off disk at sys/vfs/hpfs/hpfs_vfsops.c:535 (bcopy of the raw on-disk fnode). fn_int is 316 bytes (sys/vfs/hpfs/hpfs.h:209); h_vp/h_devvp/h_dev live immediately after h_fn in struct hpfsnode (hpfs.h:342-345). A crafted HPFS image (mk_hpfs.py) with fn_ealen=0xFFFF and a first EA whose ea_vallen=0xFFFE makes HPFSIOCRDEA's copyout (hpfs_vnops.c:234) attempt to forward 65540 bytes starting inside fn_int straight to userspace. There is NO privilege check in hpfs_ioctl, so any local user with O_RDONLY on a vnode inside an HPFS mount triggers the leak. Confirmed on the unpatched #0 kernel: PoC captured 3 canonical kernel pointers per call, the first at buffer offset 0x13c (exactly h_vp: sizeof(struct hpfsnode)=912, h_fn@56, fn_int@200, EA_NAME=fn_int+4 => h_vp at leak offset 576-(56+200+4)=0x13c). The leaked h_vp value varies across fresh-mount runs (0xfffff8008f52c780 / 0xfffff8008f52c480 / 0xfffff8008f52c300), proving it is a real live kernel heap address and not a constant. KASLR-bypass class info leak.