# DF-0788 — Heap info leak via resident attribute data offset in `ntfs_attrtontvattr`

## Verdict: REPRODUCED (info leak / OOB heap read); FIX VALIDATED

**Status:** reproduced
**Impact:** `leak` (heap OOB read; up to ~152 bytes past MFT record buffer per trigger;
deterministically proven via harness; live mount trigger confirms reachability)
**Confidence:** certain
**Severity:** Medium (mount-time attacker-image heap disclosure; bounded by record/allocation geometry)

---

## Root cause (confirmed by source trace)

`ntfs_attrtontvattr()` in `sys/vfs/ntfs/ntfs_subr.c` converts a raw on-disk
NTFS attribute into an in-memory `struct ntvattr`.  For **resident** attributes
(not stored in cluster runs), it trusts two attacker-controlled `u_int16_t`
fields from the crafted image without any bounds check:

```c
/* sys/vfs/ntfs/ntfs_subr.c — resident path */
557:    vap->va_datalen = rap->a_r.a_datalen;            /* u16 from disk, NO bound check */
558:    vap->va_allocated = rap->a_r.a_datalen;
...
561:    vap->va_datap = kmalloc(vap->va_datalen, M_NTFSRDATA, M_WAITOK);
563:    memcpy(vap->va_datap,
564:           (caddr_t) rap + rap->a_r.a_dataoff,        /* u16 from disk, NO bound check */
               rap->a_r.a_datalen);                       /* u16 from disk, NO bound check */
```

`rap` is a `struct attr *` pointing **into** the MFT record buffer:

```c
/* sys/vfs/ntfs/ntfs_subr.c:263 */
263:    mfrp = kmalloc(ntfs_bntob(ntmp->ntm_bpmftrec), M_TEMP, M_WAITOK);  /* 1024 or 4096 bytes */
```

The attribute walk in `ntfs_loadntnode()` (lines 305–320) calls
`ntfs_attrtontvattr()` for each attribute.  Because `a_r.a_dataoff` is a raw
`u_int16_t` read from disk with **zero validation**, a crafted image can set
`a_dataoff` past the record buffer boundary.  The `memcpy` at line 563 then
reads `a_datalen` bytes starting at `rap + a_dataoff` — past the `mfrp`
allocation into **adjacent M_TEMP slab heap**.  The leaked bytes are stored in
`vap->va_datap` and are later exfiltrable to userspace via:

```c
/* sys/vfs/ntfs/ntfs_subr.c:1594 — resident read path */
1594:       uiomove(vap->va_datap + roff, rsize, uio);    /* copies leaked heap to userspace */
```

The same class of unvalidated-offset bug also affects:
- `a_hdr.a_nameoff` (line 536): reads `a_namelen` wchars from `rap + a_nameoff`
- `a_nr.a_dataoff` (line 553): non-resident run data offset (DF-0789 territory)

## Reachability (confirmed)

The vulnerable `memcpy` fires **at mount time** during `ntfs_mountfs()`:

```
sys/vfs/ntfs/ntfs_vfsops.c:393-403  ntfs_mountfs() calls VFS_VGET() for
    NTFS_MFTINO(0), NTFS_ROOTINO(5), NTFS_BITMAPINO(6) in sequence.
  → ntfs_vgetex() → ntfs_loadntnode()
  → attribute walk (lines 305-320)
  → ntfs_attrtontvattr(ap)         ← DF-0788 OOB read fires here
```

DF-0788 fires inside `ntfs_attrtontvattr()` on the **first** attribute, before
the DF-0787 unbounded-walk issue (which lives in `off += reclen` on
**subsequent** iterations).

**Threat model:** root-mountable crafted NTFS image (admin mounts or makes
mountable a malicious image; `vfs.usermount=0` by default).  The OOB read
happens silently at mount time — no panic from the leak itself (adjacent slab
is mapped), leaking adjacent M_TEMP kernel heap into the attribute buffer.

## Reproduction evidence

### 1. Deterministic userspace harness (`harness.c`)

Mirrors the exact `ntfs_attrtontvattr` resident `memcpy` logic with a guard page
(`PROT_NONE`) immediately after the record buffer.  Any OOB read faults
deterministically (SIGSEGV), proving the primitive regardless of slab layout.

```
mode=clean        a_dataoff=0x0018 a_datalen=32  -> rc=0  memcpy within bounds (clean)
mode=oob_dataoff  a_dataoff=0x03c8 a_datalen=64  -> rc=2  SIGSEGV -> OOB READ past record
mode=oob_dataoff apply_fix                        -> rc=1  FIX REJECTED malformed attr (EINVAL)
```

### 2. Live mount trigger (crafted NTFS image)

`gen_ntfs_0788.py` builds an NTFS image where ino 5 (root dir) has a resident
`$INDEX_ROOT` attribute with `a_r.a_dataoff = 0x0F80` (3968).  Since
`rap` is at `mfrp+72` and `a_datalen=208`, the read target is
`mfrp[72+3968] + 208 = mfrp[4248] > 4096` — **152 bytes past the buffer**.

**Unpatched #0 kernel:** `mount_ntfs` **succeeds** (rc=0) — the OOB `memcpy`
completes, reading 152 bytes of adjacent heap into `vap->va_datap`.  The
subsequent `ls /mnt/ntfs` hits the **sibling DF-0786** lockmgr panic
(`ntfs_ntreaddir`), which is a *different* bug in the readdir path — it does
NOT block the DF-0788 leak, which already fired during mount.

```
vn0: MBR magic not found; assume a COMPATIBILITY_SLICE (s0)
panic: lockmgr: locking against itself          <-- DF-0786 sibling, NOT DF-0788
ntfs_ntreaddir() at ntfs_ntreaddir+0x58
ntfs_readdir() at ntfs_readdir+0xee
```

## Impact characterization

- **Primitive:** heap OOB read (info leak).  Up to `a_datalen` bytes (max 65535)
  read from `rap + a_dataoff` into a freshly `kmalloc`'d buffer.
- **Realistic ceiling:** The leaked bytes land in `vap->va_datap` and are
  exfiltrable to userspace via `ntfs_readntvattr_plain():1594` for resident
  `$DATA` attributes.  On this specific crafted image, the leaked bytes
  corrupted the `$INDEX_ROOT` data, and the readdir path hit the DF-0786
  sibling panic before producing userspace output — but the **leak itself
  already completed** during mount (mount rc=0 = memcpy succeeded).
- **Not an escalation primitive:** This is a pure read-only OOB.  No write, no
  corruption of kernel state (the leaked bytes go into a userspace-bound
  buffer).  No `uid=0` chain is applicable (read-only info leak class).
- **KASLR relevance:** KASLR is OFF on this guest; but on a hardened system,
  this leak could disclose kernel heap pointers/contents to defeat KASLR or
  ASLR.

## Fix

`fix.diff` validates all three unvalidated offsets (`a_nameoff`, resident
`a_dataoff`, non-resident `a_dataoff`) against the attribute's own declared
`a_hdr.reclen` before any dereference.  If `offset + length > reclen`, the
function returns `EINVAL` and the caller (`ntfs_loadntnode`) breaks the
attribute walk and propagates the error, causing mount to fail cleanly.

Key checks added (see `fix.diff`):
- `reclen < sizeof(struct attrhdr)` → reject (malformed header)
- `a_nameoff + a_namelen*sizeof(wchar) > reclen` → reject
- `a_nr.a_dataoff >= reclen` → reject (non-resident)
- `a_r.a_dataoff + a_r.a_datalen > reclen` → reject (resident, the DF-0788 fix)

## Fix validation (Phase 8)

| Kernel | ntfs.ko | Image | Result |
|--------|---------|-------|--------|
| #0 unpatched | original (Jun 29) | crafted `ntfs_0788.img` | **mount SUCCEEDS** (rc=0) — OOB read happens |
| #1 patched | rebuilt (Jul 10) | crafted `ntfs_0788.img` | **mount FAILS** `Invalid argument` (EINVAL rc=71) — OOB read prevented |
| #1 patched | rebuilt (Jul 10) | clean `ntfs_clean.img` | **mount SUCCEEDS** (rc=0) — no regression on valid images |

The ntfs module (`ntfs.ko`) is loadable, not built into the kernel.  The fix
was applied to `/usr/src/sys/vfs/ntfs/ntfs_subr.c`, the module rebuilt via
`nativekernel` (which builds modules), and installed at `/boot/kernel/ntfs.ko`.

**Before/after contrast:**
- Baseline (#0): `mount_ntfs -o ro /dev/vn0 /mnt/ntfs` → rc=0, mount active
  (OOB read silently completed)
- Patched (#1): `mount_ntfs -o ro /dev/vn0 /mnt/ntfs` → rc=71
  `Invalid argument` (ntfs_attrtontvattr rejected malformed attribute)
- Patched (#1) + clean image: rc=0 (valid images unaffected)

## PoC changes

Authored from scratch (no prior PoC existed for DF-0788):
- `harness.c` — deterministic OOB-read proof mirroring `ntfs_attrtontvattr`
  resident memcpy with guard page
- `gen_ntfs_0788.py` — crafted NTFS image generator (extends DF-0786's
  `gen_ntfs.py` scaffolding, corrupts ino 5's `$INDEX_ROOT` `a_dataoff`)
- `build.sh` / `run.sh` — reproducible build/run scripts
- `fix.diff` — git-apply-able fix validating offsets against `a_hdr.reclen`
