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)
PoC verification
Evidence pack
findings/poc/DF-0829 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
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-memorystruct hpfsnode.fn_ealen(au_int16_tat offset 56 in the kernel's C struct layout) is taken from disk with no validation.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_intisu_int8_t fn_int[0x13c](316 bytes). The loop never boundspassedagainstsizeof(fn_int), so anyfn_ealen > 0x13c(or an EA whoseea_namelen+1+ea_vallenis large enough to stride pastfn_int) walks theeappointer past the buffer.sys/vfs/hpfs/hpfs_vnops.c:232-235βHPFSIOCRDEAthen doesc 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 insidefn_inton the first iteration, but thecopyoutlength is the attacker-controlled sum (up to 65540). That reads 65540 bytes starting insidefn_int, continuing past the end ofstruct fnodeand into the live kernel pointersh_vp,h_devvp,h_dev,h_no, β¦ that live immediately afterh_fninstruct hpfsnode(sys/vfs/hpfs/hpfs.h:342-345).- The
copyoutreturn value is not checked (error = 0is 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_tsums; in practicecopyoutfaults at the first unmapped page, returning whatever it copied first). - No privilege check in
hpfs_ioctlβ any local user withO_RDONLYon 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 lyingfn_ealen=0xFFFF. The non-obvious part: the kernel C struct layout (verified with a tinykldloadmodule) has natural-alignment padding, sofn_ealenlives at byte 56 ofstruct fnode(not 52) andfn_flagat byte 59 (not 55). The image also has to provide a minimal dir block +alleaf_tsohpfs_validateparent(called fromhpfs_getattrduringstat) doesn'tEINVALout before the PoC can even open the mountpoint.poc.c/hpfs_ioctl.hβ opens/mnt/hpfsO_RDONLY and issuesHPFSIOCRDEA, then scans the returned buffer for canonical kernel pointers (0xffff_xxxx_xxxx_xxxx).sz.cβ userspace mirror ofstruct fnode/sublock/spblockused 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.
Recommended fix
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
fixedVALIDATED. 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).
Confirmed kernel references
- sys/vfs/hpfs/hpfs_vfsops.c:535
- sys/vfs/hpfs/hpfs_vfsops.c:507
- sys/vfs/hpfs/hpfs_vnops.c:133
- sys/vfs/hpfs/hpfs_vnops.c:155
- sys/vfs/hpfs/hpfs_vnops.c:189
- sys/vfs/hpfs/hpfs_vnops.c:228
- sys/vfs/hpfs/hpfs_vnops.c:232
- sys/vfs/hpfs/hpfs_vnops.c:234
- sys/vfs/hpfs/hpfs.h:196
- sys/vfs/hpfs/hpfs.h:209
- sys/vfs/hpfs/hpfs.h:342
- sys/vfs/hpfs/hpfs.h:343
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.
No comments yet.