# 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 `copyout`s
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.

## 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.

