# DF-0789 — `ntfs_runtovrun` walks attacker run buffer with no length bound — OOB read

## Verdict: REPRODUCED (harness + live kernel) → FIX VALIDATED

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

**File:** `sys/vfs/ntfs/ntfs_subr.c:582-634` (function `ntfs_runtovrun`)
**Class:** CWE-125 (Out-of-bounds Read) + CWE-835 (infinite loop, if adjacent memory has no zero byte)
**Severity (per finding):** Medium. Confirmed: realistic impact ceiling is **kernel panic / DoS** (no write primitive).

```c
582: int
583: ntfs_runtovrun(cn_t **rcnp, cn_t **rclp, u_long *rcntp, u_int8_t *run)
584: {
...
595:    while (run[off]) {                        // NO length bound — walks until zero byte
596:        off += (run[off] & 0xF) + ((run[off] >> 4) & 0xF) + 1;
597:        cnt++;
598:    }
599:    cn = kmalloc(cnt * sizeof(cn_t), M_NTFSRUN, M_WAITOK);  // cnt is attacker-influenced
600:    cl = kmalloc(cnt * sizeof(cn_t), M_NTFSRUN, M_WAITOK);
...
605:    while (run[off]) {                        // decode loop — same missing bound
...
629:    }
```

`ntfs_runtovrun` takes NO length parameter. Both the count loop (`:595-598`) and the
decode loop (`:605-629`) walk `run[off]` until they encounter a zero byte. If the
on-disk run list has no zero terminator, the walk reads past the attribute's data
extent, past the MFT record buffer (`kmalloc(4096, M_TEMP)`), into adjacent kernel
heap — an OOB read. On default GENERIC (INVARIANTS ON), adjacent freed slab chunks
are poisoned with `0xdeadc0de` (all nonzero), so the walk never terminates →
infinite loop / eventual slab-exhaustion panic.

The disabled `ntfs_parserun` at `:1745-1780` shows the correct pattern: it takes
a `len` parameter and bounds-checks at `:1760` (`(sz & 0xF) > 8 || (*off)+(sz&0xF) > len`)
and `:1770`.

## Caller / reachability

`ntfs_runtovrun` is called from `ntfs_attrtontvattr` at `:551-553`:
```c
551:    error = ntfs_runtovrun(&(vap->va_vruncn), &(vap->va_vruncl),
552:                               &(vap->va_vruncnt),
553:                               (caddr_t) rap + rap->a_nr.a_dataoff);
```

`ntfs_attrtontvattr` is called from the attribute walk in `ntfs_loadntnode:311`.
For non-resident attributes (`vap->va_flag & NTFS_AF_INRUN` at `:544`),
`ntfs_runtovrun` decodes the on-disk run list into in-memory cluster arrays.

Reachability at mount time:
```
ntfs_mountfs (ntfs_vfsops.c:393-403)
  → VFS_VGET(NTFS_MFTINO=0)      — ino 0 is first system node loaded
  → ntfs_vgetex (ntfs_vfsops.c:718)
  → ntfs_loadntnode (ntfs_subr.c:253) — reads MFT record from disk (system node)
  → ntfs_attrtontvattr (ntfs_subr.c:311) — for ino 0's non-resident $DATA
  → ntfs_runtovrun (ntfs_subr.c:551)    — OOB walk on malformed run list
```

ino 0 ($MFT) is a system node (`ino < NTFS_SYSNODESNUM=11`), so its MFT record is
read directly from the boot-sector MFT cluster via `bread()` (`ntfs_subr.c:265-281`),
NOT via the $DATA run list. The run list is decoded AFTER the record is in memory,
so `ntfs_runtovrun` fires before any lookup. The sibling DF-0786 lockmgr panic
(fires during directory LOOKUP) does NOT block this finding.

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

`harness.c` replicates `ntfs_runtovrun:582-634` against a run-list buffer placed
at the end of a writable page with a `PROT_NONE` guard page after it. Output:

```
=== BUGGY walk (kernel behaviour on default GENERIC #0) ===
mode=clean          apply_fix=0    -> rc=0  clean exit (terminator found within buffer)
mode=oob_short      apply_fix=0    -> rc=0  clean exit (terminator found within buffer)
mode=oob_fill       apply_fix=0    -> rc=2  SIGSEGV -> OOB read past run-list buffer
mode=oob_infinite   apply_fix=0    -> rc=2  SIGSEGV -> OOB read past run-list buffer

=== FIXED walk (proposed fix: bound by runlen) ===
mode=clean          apply_fix=1    -> rc=0  clean exit (terminator found within buffer)
mode=oob_short      apply_fix=1    -> rc=0  clean exit (terminator found within buffer)
mode=oob_fill       apply_fix=1    -> rc=-1 FIX REJECTED input (EINVAL)
mode=oob_infinite   apply_fix=1    -> rc=0  clean exit (bounded walk)
```

- `oob_fill` (200-byte buffer, all 0x11): the walk runs off the end into the guard
  page → SIGSEGV. This is the OOB read past the allocation.
- The fixed walk rejects `oob_fill` with EINVAL because entries straddle the buffer end.

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

### Compound image (`ntfs_0789.img`)
Fills ino 0's entire MFT record (bytes 136-4095) with 0x11 (no terminator):
```
vnconfig -c vn0 ntfs_0789.img
mount_ntfs -o ro /dev/vn0 /mnt/ntfs
→ panic: NTFS vattr: malloc limit exceeded
  _kmalloc() at _kmalloc+0xb09
  _kmalloc() at _kmalloc+0xb09
  ntfs_attrtontvattr() at ntfs_attrtontvattr+0x35
  ntfs_loadntnode() at ntfs_loadntode+0x178
  ntfs_vgetex() → ntfs_vget()
  Debugger("panic")
```
This compounds DF-0789 (inner `ntfs_runtovrun` OOB walk) with DF-0787 (outer
attribute-walk OOB after `reclen=4024` advances past the record). The panic is
from the outer walk's repeated `struct ntvattr` allocations exhausting the
`M_NTFSNTVATTR` slab (`kern_slaballoc.c:877`). (On a second run, a different
manifestation appeared: `vm_object_hold_shared` assertion failure — both are
OOB-read consequences whose exact form depends on adjacent slab content.)

### Isolated image (`ntfs_0789_isolated.img`)
Keeps the outer attribute walk properly bounded (reclen=88, proper end marker)
but corrupts only the 24-byte run-list data within the attribute (0x22, no
terminator). Isolates DF-0789 from DF-0787:
```
mount_ntfs -o ro /dev/vn0 /mnt/ntfs
→ MOUNT_RC=0   (mount SUCCEEDS — corrupt run list silently accepted)
```
`ntfs_runtovrun` walks past the 24-byte run-list extent into the end-of-attributes
marker (0xFF bytes) and zeros, reads garbage, returns SUCCESS. Since system nodes
(ino<11) are read directly from disk, the corrupt run data doesn't affect mount.
The corruption is latent — it would surface if a non-system MFT record (ino≥11)
were accessed.

## 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 `cn[]`/`cl[]`
arrays filled from OOB-read garbage bytes. Per Phase 6, a pure read/loop primitive
has no escalation chain to `uid=0`; the correct deliverable is the characterized
impact ceiling:
- **Kernel panic / DoS** (demonstrated: compound image panics at mount time)
- **Latent corruption** (isolated image: mount succeeds with corrupt run data)
- **Theoretical info-leak**: OOB bytes read from adjacent slab flow into the
  run-list arrays and could be exposed via subsequent file reads on the mounted
  volume. On default GENERIC this is masked by the panic.

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

## Fix — `fix.diff`

Threads a `size_t runlen` parameter into `ntfs_runtovrun`. The caller
`ntfs_attrtontvattr` passes `rap->a_hdr.reclen - rap->a_nr.a_dataoff` (the
run-list extent within the attribute). Both loops check `off < runlen` before
reading, and each entry's total byte consumption is validated against `runlen`
before advancing (mirrors the disabled `ntfs_parserun:1760/1770`).

Changes:
1. `ntfs_subr.h:87` — add `size_t runlen` to declaration
2. `ntfs_subr.c:551-553` — caller computes and passes `runlen` (with underflow guard)
3. `ntfs_subr.c:582` — function signature gains `size_t runlen`
4. `ntfs_subr.c:601` (count loop) — `while (off < runlen && run[off])`, with
   `off + adv > runlen → EINVAL` straddle check
5. `ntfs_subr.c:621` (decode loop) — same bound + per-field `off + sz > runlen → EINVAL`
6. Empty-run-list rejection: `cnt == 0 → EINVAL`

Minimal and targeted at the root cause (the missing bound). Does not change the
on-disk format or the happy path for valid images.

This **supersedes** the finding markdown's sketch ("thread runlen parameter,
bound both loops off<runlen") — same intent, expressed as a complete, tested
implementation with proper error handling and diagnostics.

## Fix validation (Phase 8) — VALIDATED

Built standalone `ntfs.ko` (`make KERNCONF=X86_64_GENERIC` in `sys/vfs/ntfs/`)
using the warm obj. 0 compile errors. Installed to `/boot/kernel/ntfs.ko`.

### Before (unpatched `ntfs.ko`, kernel `#0`)
```
mount_ntfs ntfs_0789_isolated.img → MOUNT_RC=0   (mount SUCCEEDS — corrupt data accepted)
```

### After (patched `ntfs.ko`, kernel `#0` — only module rebuilt)
```
mount_ntfs ntfs_0789_isolated.img → mount_ntfs: /dev/vn0: Invalid argument  MOUNT_RC=71
dmesg: ntfs_runtovrun: malformed run list at offset 20
       ntfs_loadntnode: failed to load attr ino: 0
guest: UP, no panic
```

### Regression check (clean NTFS image)
```
mount_ntfs ntfs_clean.img → CLEAN_MOUNT_RC=0   (mount succeeds; no regression)
```

### Compound image note
The compound image (`ntfs_0789.img`) still panics on the patched kernel because
the sibling DF-0787 outer-walk bug is NOT fixed by this diff. The DF-0789 fix
closes `ntfs_runtovrun`'s OOB walk (validated with the isolated image), but the
outer walk still advances past the record via `off += reclen` and reads OOB.
Applying DF-0787's fix in addition would close that path too.

## PoC changes

- `harness.c` — userspace replication of `ntfs_runtovrun:582-634` against a
  guard-page-backed buffer. Modes: `clean`, `oob_short`, `oob_fill`,
  `oob_infinite`. Optional `apply_fix` flag runs the proposed fixed walker.
- `gen_ntfs_0789.py` — crafted-image generator with two modes:
  - `compound` (`ntfs_0789.img`): fills ino 0's entire MFT record with 0x11
    (no terminator), triggering both DF-0789 and DF-0787.
  - `isolated` (`ntfs_0789_isolated.img`): corrupts only the 24-byte run-list
    data within a properly-bounded attribute, isolating DF-0789 from DF-0787.
  Uses nonzero fixup replacement values (0x1111) so sector-boundary bytes don't
  introduce zeros into the run-list region.
- `build.sh` / `run.sh` — exact reproducible build & run.
- `fix.diff` — standalone, `git apply`-able unified diff fixing the bug.
