# DF-0787 — Unbounded attribute walk in `ntfs_loadntnode`: OOB read + infinite loop

## Verdict: REPRODUCED (live kernel panic on default GENERIC #0 + userspace guard-page harness) → FIX VALIDATED

## The bug (confirmed by source trace + live panic)

**File:** `sys/vfs/ntfs/ntfs_subr.c:305-320` (function `ntfs_loadntnode`)
**Class:** CWE-835 (infinite loop) + CWE-125 (OOB read) — both stem from the same missing bound check.
**Severity (per finding):** Medium. Confirmed: realistic impact ceiling is **kernel panic / DoS** (no write primitive).

```c
263:    mfrp = kmalloc(ntfs_bntob(ntmp->ntm_bpmftrec), M_TEMP, M_WAITOK);   // 4096-byte alloc
...
305:    off = mfrp->fr_attroff;                                              // uint16 from disk — UNCHECKED
306:    ap  = (struct attr *) ((caddr_t)mfrp + off);                         // ptr formed before any bound
...
310:    while (ap->a_hdr.a_type != -1) {                                     // deref of possibly-OOB ap
311:        error = ntfs_attrtontvattr(ntmp, &nvap, ap);                     // allocates a fresh ntvattr each iter
...
318:        off += ap->a_hdr.reclen;                                         // uint32 from disk — UNCHECKED
319:        ap  = (struct attr *) ((caddr_t)mfrp + off);                     // ptr may now be OOB
320:    }
```

The walk reads `fr_attroff` (uint16) and each attribute's `reclen` (uint32) directly from the on-disk
MFT record with **no bound check** against the in-memory record size (`ntfs_bntob(ntm_bpmftrec)`).
Three crafted-record shapes all exploit the same gap:

| Variant | Crafted bytes | Effect in kernel |
|---|---|---|
| `loop`     | first attr's `reclen = 0`          | `off` never advances; while-loop spins forever, each iteration leaking a `struct ntvattr` |
| `oob_a`    | `fr_attroff = 0x0FF0` (past attr list, inside 4 KiB alloc) | first `ap->a_hdr.a_type` deref reads bytes that are not on-disk attributes; in our image the tail is zero, so `reclen=0` and it falls into the same loop |
| `oob_r`    | first attr's `reclen = 0x1000`     | after iteration 1, `off = attroff + 4096`, deref past `mfrp` allocation → reads adjacent kernel heap |

## Reachability (mount-time, BEFORE the DF-0786 lockmgr panic)

`sibling finding DF-0786` documented that the live directory-LOOKUP path on a mounted NTFS
volume panics with `lockmgr: locking against itself` inside `ntfs_ntget()`. **That does not
block this finding.** The two bugs fire on different paths:

- DF-0786 fires during `ntfs_lookup` → `ntfs_ntlookupfile` → `ntfs_ntget` on an
  **already-locked** ntnode (i.e. *after* mount, during path resolution).
- DF-0787 fires during the **mount-time** load of the system MFT records:

```
sys/vfs/ntfs/ntfs_vfsops.c:393-403   ntfs_mountfs() loops over {NTFS_MFTINO, NTFS_ROOTINO, NTFS_BITMAPINO}
sys/vfs/ntfs/ntfs_vfsops.c:396          VFS_VGET(mp, NULL, pi[i], ...)
sys/vfs/ntfs/ntfs_vfsops.c:807          .vfs_vget = ntfs_vget
sys/vfs/ntfs/ntfs_vfsops.c:794-796      ntfs_vget -> ntfs_vgetex(... VG_DONTLOADIN not set ...)
sys/vfs/ntfs/ntfs_vfsops.c:717-718      if (!(ip->i_flag & IN_LOADED)) ntfs_loadntnode(ntmp, ip)
sys/vfs/ntfs/ntfs_subr.c:253            ntfs_loadntnode() runs the buggy walk
```

For freshly-created system MFT ntnodes the `lockinit()` at `ntfs_subr.c:391` makes the lock
brand-new, so `ntfs_ntget()`'s `LOCKMGR(LK_EXCLUSIVE)` succeeds on the first try. The lockmgr
panic never fires here. **Mounting the crafted image is sufficient to trigger DF-0787.**

The threat model is the standard one for filesystem-image bugs: `vfs.usermount=0` (verified on
this guest) means the attacker needs root to issue the `mount_ntfs` (e.g. an admin auto-mounting
an untrusted USB stick / image, or a crafted image placed where root will mount it). The PoC
trigger is one command: `mount_ntfs -o ro <crafted.img> /mnt`.

## Reproduction — live kernel (default GENERIC `#0`)

Trigger (as root):
```
vnconfig -c vn0 /root/ntfs_loop.img
mount_ntfs -o ro /dev/vn0 /mnt/ntfs
```

**Result:** kernel panic. `mount(2)` never returns; the guest drops to the DDB prompt.
Identical panic for `ntfs_loop.img`, `ntfs_oob_a.img`, `ntfs_oob_r.img`. Serial-log signature:

```
panic: NTFS vattr: malloc limit exceeded
cpuid = 5
Trace beginning at frame 0xfffff80117fe2e30
_kmalloc() at _kmalloc+0xb09 0xffffffff806578c9
_kmalloc() at _kmalloc+0xb09 0xffffffff806578c9
ntfs_attrtontvattr() at ntfs_attrtontvattr+0x35 0xffffffff82602685
ntfs_loadntnode() at ntfs_loadntnode+0x178 0xffffffff82604278
ntfs_vgetex() at ntfs_vgetex+0x1ec 0xffffffff826010ac
ntfs_vget() at ntfs_vget+0x29 0xffffffff82601219
Debugger("panic")
Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip)
db>
```

**Why the panic message says "malloc limit exceeded" and not "infinite loop":** because the
unbounded walk calls `ntfs_attrtontvattr()` on each iteration, which `kmalloc()`s a fresh
`struct ntvattr` (and for resident attrs, a separate `va_datap` buffer) and `LIST_INSERT_HEAD`s
it into `ip->i_valist` (`ntfs_subr.c:311-316`). The walk never frees them. Eventually the
per-malloc-type slab ceiling (`kern_slaballoc.c:863-878`) trips and panics. The root cause is
the unbounded walk — the slab panic is the symptom.

## Reproduction — userspace guard-page harness (deterministic complement)

`harness.c` replicates the exact walk against a 4096-byte record placed at the end of a
writable page with a `PROT_NONE` guard page after it. Output (run as unprivileged `maxx`):

```
--- BUGGY walk (kernel behaviour on default GENERIC #0) ---
mode=clean        apply_fix=0    -> rc=0  clean exit (end-of-attributes reached)
mode=loop         apply_fix=0    -> rc=1  ITERATION CAP HIT -> would be infinite loop in kernel
mode=oob_attroff  apply_fix=0    -> rc=1  ITERATION CAP HIT -> would be infinite loop in kernel
mode=oob_reclen   apply_fix=0    -> rc=2  SIGSEGV -> OOB read past record (into adjacent slab in kernel)
```

- `loop` and `oob_attroff` confirm the **infinite-loop** branch (reclen==0 / walk into zero-tail).
- `oob_reclen` confirms the **OOB-read** branch (next iter deref past the allocation, caught by
  the guard page as SIGSEGV — in the kernel this reads adjacent slab heap until a page boundary).

## Escalation assessment (no chain possible — read/loop only)

This is a **read-only / control-flow** primitive. There is no write to attacker-chosen kernel
memory: the only writes are kernel-internal `struct ntvattr` allocations the attacker cannot shape.
Per Phase 6 of the procedure, a pure read/loop primitive has no escalation chain to `uid=0`; the
correct deliverable is the characterized impact ceiling, which is:

- **Definite kernel panic / DoS** (the demonstrated outcome on default GENERIC, INVARIANTS ON)
- **Theoretical info-leak** via the `oob_reclen` path: bytes read past `mfrp` flow through
  `ntfs_attrtontvattr` → `vap->va_datap` (`ntfs_subr.c:561-564`) and could be exposed to
  userspace via a subsequent `read()` on the mounted file. On default GENERIC this is masked
  because the walk always reaches the slab-allocation panic first.

No `uid=0` is achievable; this is honestly reported as `impact=panic`.

## Fix — `fix.diff`

Binds the walk by the in-memory MFT record size and rejects malformed attributes with `EINVAL`
before dereferencing them:

- Validate `fr_attroff` is in `[sizeof(struct filerec), recsz)` before forming the first `ap` ptr.
- Inside the loop: require `off + sizeof(struct attrhdr) <= recsz` (the header must fit).
- Require `ap->a_hdr.reclen ∈ [sizeof(struct attrhdr), recsz - off]` (rejects 0, tiny, and overflow).
- On any violation: set `error = EINVAL`, break; the existing `if (error) goto out` path then
  frees `mfrp` and returns cleanly. Mount fails with `EINVAL` — no panic, no leak.

The fix is **minimal and targeted at the root cause** (the missing bound). It does not change
the on-disk format, the structure of `ntfs_attrtontvattr`, or the happy path for valid images.

This **supersedes** the finding markdown's sketch ("Fix: validate off<recsize and reclen>=sizeof(attrhdr)
and off+reclen<=recsize and off+reclen>off") — same intent, expressed as a single hunk with
proper error handling and a `kprintf` for diagnostics.

## Fix validation (Phase 8) — VALIDATED

Built `ntfs.ko` standalone (`KERNCONF=X86_64_GENERIC make` in `sys/vfs/ntfs/`) using the warm
obj at `/usr/obj/usr/src/sys/X86_64_GENERIC`. The full `make nativekernel` was also attempted
first and observed to produce the same patched `ntfs.ko`; the standalone module build is faster
and is the recommended path since ntfs is `optional ntfs` (a module, not compiled into GENERIC).

### Before (unpatched `ntfs.ko`, kernel `#0`)
```
mount_ntfs -o ro /dev/vn0 /mnt/ntfs   # ntfs_loop.img
-> panic: NTFS vattr: malloc limit exceeded      [guest dead, db> prompt]
```
(Same panic for `ntfs_oob_a.img` and `ntfs_oob_r.img`.)

### After (patched `ntfs.ko`, kernel `#0` — only the module was rebuilt)
```
ntfs_loop.img  -> mount_ntfs: /dev/vn0: Invalid argument   MOUNT_RC=71
ntfs_oob_a.img -> mount_ntfs: /dev/vn0: Invalid argument   MOUNT_RC=71
ntfs_oob_r.img -> mount_ntfs: /dev/vn0: Invalid argument   MOUNT_RC=71
dmesg: ntfs_loadntnode: failed to load attr ino: 5
       ntfs_vget: CAN'T LOAD ATTRIBUTES FOR INO: 5
guest: UP, no panic, ntfs.ko auto-loaded then auto-unloaded cleanly
```

### Regression check (clean NTFS image still mounts)
```
mount_ntfs -o ro /dev/vn0 /mnt/ntfs   # clean.img
-> MOUNT_RC=0   (mount succeeds; subsequent ls hangs on the pre-existing
                 DF-0786 lockmgr bug, which is unrelated to this fix)
```

The fix closes the bug on the default GENERIC kernel without regressing valid images.

## PoC changes

- `gen_ntfs_0787.py` — crafted-image generator that builds on the proven DF-0786 NTFS scaffolding
  and corrupts only the ino-5 (root dir) MFT record. Three modes: `loop` (reclen=0), `oob_a`
  (fr_attroff past attr list), `oob_r` (reclen=4096). All other MFT records and the boot sector
  are byte-identical to the proven-mountable DF-0786 image, so `ntfs_procfixups` still passes
  and the buggy walk is actually reached.
- `harness.c` — userspace replication of `ntfs_loadntnode:305-320` against a guard-page-backed
  4096-byte record. Accepts a `clean|loop|oob_attroff|oob_reclen` mode and an optional
  `apply_fix` flag that runs the proposed fixed walker for direct before/after comparison.
- `build.sh` / `run.sh` — exact reproducible build & run. `run.sh live` (as root) reproduces
  the kernel panic; `run.sh` (as maxx) runs the deterministic harness.
- `fix.diff` — standalone, `git apply`-able unified diff fixing the bug at the root cause.

## How to reproduce (for a teammate)

```sh
# 1. userspace harness (deterministic, no root)
ssh dfbsd-maxx 'mkdir -p poc/DF-0787'
scp -F dfbsd-qemu/config -q findings/poc/DF-0787/{harness.c,build.sh,run.sh} dfbsd-maxx:poc/DF-0787/
ssh -F dfbsd-qemu/config dfbsd-maxx 'cd poc/DF-0787 && sh build.sh && sh run.sh'

# 2. live kernel reproduction (root, EXPECTS PANIC — guest dies, must reset after)
scp -F dfbsd-qemu/config -q findings/poc/DF-0787/gen_ntfs_0787.py <host>
python3 gen_ntfs_0787.py loop /tmp/ntfs_loop.img
scp -F dfbsd-qemu/config -q /tmp/ntfs_loop.img dfbsd:/root/
./dfbsd-qemu/vm.sh run_root 'vnconfig -c vn0 /root/ntfs_loop.img && mount_ntfs -o ro /dev/vn0 /mnt/ntfs'
./dfbsd-qemu/vm.sh log    # see panic: NTFS vattr: malloc limit exceeded
./dfbsd-qemu/vm.sh reset with-src

# 3. fix validation (rebuild ntfs.ko, install, re-run same PoC)
scp -F dfbsd-qemu/config -q findings/poc/DF-0787/fix.diff dfbsd:/root/
./dfbsd-qemu/vm.sh run_root 'cd /usr/src && patch -p1 < /root/fix.diff && cd sys/vfs/ntfs && make && cp ntfs.ko /boot/kernel/ntfs.ko'
# (re-run step 2; mount now fails cleanly with EINVAL, guest stays UP)
```
