# DF-0874 — Unbounded attr_indexentry walk in `ntfs_readdir` inner loop

**Verdict: REPRODUCED (OOB read / DoS) — fix VALIDATED on default GENERIC.**

## The bug

`ntfs_readdir` (`sys/vfs/ntfs/ntfs_vnops.c:585-586`) walks `struct attr_indexentry`
records in the root directory's `$INDEX_ROOT:$I30` buffer (`fp->f_dirblbuf`)
using the **attacker-controlled on-disk `ie_reclen`** as the stride, with
**no check that the walking pointer stays inside the buffer**:

```c
for (; !(iep->ie_flag & NTFS_IEFLAG_LAST);
    iep = NTFS_NEXTREC(iep, struct attr_indexentry *))
{   ... vop_write_dirent(iep->ie_number, ..., convname(iep->ie_fname)) ...   }
```

where `NTFS_NEXTREC(s, type) = (type)(((caddr_t)s) + (s)->reclen)` (`ntfs.h:273`)
and `reclen` is a `u_int16_t` read straight off the disk image.

The **sibling** walk in `ntfs_ntreaddir` (`sys/vfs/ntfs/ntfs_subr.c:1176-1178`)
**is** bounded — `for (; !(iep->ie_flag & LAST) && (rdsize > aoff); aoff += iep->reclen, ...)` —
and uses that bounded walk to *find* the num-th permitted entry, returning a pointer
into `f_dirblbuf`. `ntfs_readdir` then restarts an **unbounded** walk from that
returned pointer. That is the hole: the returned entry's `reclen` is never validated
against the buffer, so a crafted image drives the pointer into kernel heap.

## Trigger & impact

Precondition (realistic, per AGENT.md threat model): an admin has mounted (or made
mountable) a crafted NTFS image. `mount_ntfs` auto-loads `ntfs.ko`; the unprivileged
user then `readdir`s the mount point. `ntfs` is `optional ntfs` (`sys/conf/files`)
and ships as `/boot/kernel/ntfs.ko` — **not** compiled into GENERIC.

Three crafted variants, all confirmed on the default GENERIC kernel
(`6.5-DEVELOPMENT #0`, INVARIANTS ON):

| image | entry[0].reclen | effect on unpatched #0 |
|-------|-----------------|------------------------|
| `ntfs_reclen0.img` | `0x0000` | **DoS / flood**: walk never advances (`NTFS_NEXTREC = iep+0`), re-emits entry[0] as **257 duplicate dirents** until the uio buffer fills. Deterministic, visible on GENERIC. |
| `ntfs_oob.img` (=`hybrid_panic`) | `0xFFF0` | **OOB read**: walk leaps to `f_dirblbuf+0x10022` (~64 KB past the 4 KB buffer) and dereferences `ie_flag` there. On GENERIC the OOB byte is mapped kmem; if its bit-2 (LAST) is clear the walk continues and copies `ie_number`/`ie_fname` residue to userspace (info leak); if set it stops silently. |
| `ntfs_leak.img` | `0x0060` | **OOB walk through residue**: stride 96 B, processes successive "entries" from heap residue past the 122-byte valid data. On GENERIC the INVARIANTS slab poison (`0xdeadc0de`, bit-2 set) usually terminates the walk before exfiltration; on a non-INVARIANTS kernel the residue is zeros/live objects and the leak is observable. |

This is a **read-only** primitive (CWE-125 OOB read) — no write, so there is no
privilege-escalation chain. The realistic impact ceiling is **kernel heap info leak
/ DoS**, matching the Medium severity.

## Reproduction (decisive evidence)

**Live GENERIC (#0 unpatched), `reclen=0` image** — the walk emits 257 duplicate
dirents (`..` + 256× `HHHH`, `d_ino=0x48484848`), proving the stride is unvalidated
and never bounds-checked (full 259-line log in `run_reclen0.out`):

```
dirent[0]: d_ino=0x5 d_name='..'
dirent[1]: d_ino=0x48484848 d_name='HHHH'
dirent[2]: d_ino=0x48484848 d_name='HHHH'
  ... (256 duplicates) ...
dirent[256]: d_ino=0x48484848 d_name='HHHH'
[+] readdir returned 257 entries
```

**Deterministic code-level harness** (`harness.c`) — replicates the exact
`vnops.c:585` (unbounded) and `subr.c:1176` (bounded) walks against the same
crafted INDEX_ROOT data with a guard page, proving the vnops walk reads past the
buffer while the subr walk does not:

```
$ ./harness 0xFFF0 0 1   # reclen=0xFFF0, no LAST, INVARIANTS poison
  [subr.c:1176 BOUNDED walk]  walked to off=32 (rdsize=122): IN-BOUNDS (safe)
  [vnops.c:585 UNBOUNDED walk] steps=2 max_off=65552 oob_derefs=1
    => BUG CONFIRMED: ntfs_readdir dereferenced index entries 1 time(s) PAST
       the INDEX_ROOT valid data (rdsize=122) using attacker reclen=0xfff0.
```

## The fix (`fix.diff`)

Bounds the `ntfs_readdir` inner walk against `fp->f_dirblbuf` / `fp->f_dirblsz`
(mirroring the bounded walk already in `ntfs_ntreaddir`), and rejects a `reclen`
smaller than the fixed entry header (prevents the `reclen==0` stall/DoS):

```c
for (; (caddr_t)iep >= fp->f_dirblbuf &&
     (caddr_t)iep + __offsetof(struct attr_indexentry, ie_fname) <=
     fp->f_dirblbuf + fp->f_dirblsz;
     iep = NTFS_NEXTREC(iep, struct attr_indexentry *))
{
    if (iep->ie_flag & NTFS_IEFLAG_LAST)
        break;
    if (iep->reclen < __offsetof(struct attr_indexentry, ie_fname)) {
        error = EINVAL;
        goto done;
    }
    ... existing body unchanged ...
```

## Fix validation (Phase 8)

Built the patched `ntfs.ko` standalone (`cd sys/vfs/ntfs && make`, rc=0,
`846888df…`), hot-swapped `/boot/kernel/ntfs.ko`, `kldload`'d it (kernel #0
**unchanged** — ntfs is a module). Re-ran the SAME triggers:

| image | unpatched #0 | patched ntfs.ko |
|-------|--------------|-----------------|
| `ntfs_reclen0.img` (reclen=0) | **257 entries** (flood) | **EINVAL, 0 entries** ✅ |
| `ntfs_oob.img` (reclen=0xFFF0) | 2 entries + OOB read at +0xFFF0 | 2 entries, **OOB advance prevented** ✅ |
| `ntfs_leak.img` (reclen=0x60) | 2 entries + OOB walk | **EINVAL** (malformed entry rejected) ✅ |
| `ntfs_clean.img` (regression) | 4 correct entries | **4 correct entries** (`.. HELLO WORLD FOO`) ✅ |

Full before/after contrast in `fix_run.log`; full module build log in `fix_build.log`.

## Notes

- The DragonFly NTFS driver has a pre-existing lockmgr panic
  (`lockmgr: locking against myself` in `ntfs_mountfs→vflush→vx_get`, the
  DF-0786 sibling) that fires on *some* crafted images during mount cleanup.
  It is unrelated to this bug. A hybrid base (DF-0873's known-mountable image
  with the DF-0874 evil `$INDEX_ROOT` swapped in) mounts cleanly and reaches
  `ntfs_readdir`, so the live path is exercised without the sibling panic.
- `reclen=0` gives the cleanest live-GENERIC demonstration because it makes the
  unbounded walk *visible* (dirent flood) without depending on heap-residue
  content. The `reclen=0xFFF0` / `0x60` OOB-read variants fire on GENERIC but
  the INVARIANTS slab poison (`0xdeadc0de`, bit-2 = LAST) typically masks
  observable exfiltration; the harness proves the OOB deref occurs regardless.
- No escalation chain: this is a pure OOB **read** (no write primitive), so the
  impact ceiling is info-leak / DoS, consistent with the Medium rating.
