# DF-0791 — Subnode dive in `ntfs_ntlookupfile` reads 8 bytes OOB

## Verdict: REPRODUCED (deterministic harness + source trace); FIX VALIDATED (harness + disassembly)

**Status:** reproduced
**Impact:** `leak` — 8-byte heap OOB read (CWE-125) of the index-entry subnode VCN
into a kernel-local `cn_t` (read-only primitive; no write, no escalation). The live
directory-lookup path that hosts the dive is **sibling-blocked** on this kernel by a
`lockmgr: locking against itself` panic that fires at the top of `ntfs_ntlookupfile`
(`ntfs_ntget`, +0x57) **before** the INDEX_ROOT walk / dive — so the dive's OOB read is
**latent** on the running kernel; the primitive is proven deterministically by the
guard-page harness (the accepted reproduction when a sibling panic blocks the live path).
**Confidence:** certain (source + disassembly + harness).
**Severity:** Medium (matches finding).

---

## The bug (confirmed by source trace)

**File:** `sys/vfs/ntfs/ntfs_subr.c:1006-1011` (function `ntfs_ntlookupfile`)

```c
 888:  rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);          // blsize = ir_size
 ...
 900:  for (; !(iep->ie_flag & NTFS_IEFLAG_LAST) && (rdsize > aoff);
 901:      aoff += iep->reclen,                              // <-- only entry-START guarded
 902:      iep = (struct attr_indexentry *) (rdbuf + aoff))
        { ... NTFS_UASTRICMP ... if (res > 0) break; ... }

 1006: /* Dive if possible */
 1007: if (iep->ie_flag & NTFS_IEFLAG_SUBNODE) {
 1010:     cn = *(cn_t *) (rdbuf + aoff +                    // aoff = u_int32_t
 1011:             iep->reclen - sizeof(cn_t));              // reclen = u_int16_t off disk
                                                             // sizeof(cn_t) = 8 (u_int64_t)
        ... ntfs_readattr(... ntfs_cntob(cn) ...) ...
```

`iep->reclen` is a raw `u_int16_t` read straight off disk with **no bounds
validation** before the subnode-VCN dereference. The walk loop (`:900-902`) only
guards the **entry start** (`rdsize > aoff`), never the **entry end** or the
trailing 8-byte VCN. Two malformed shapes:

| Shape | Crafted `reclen` | Effect |
|---|---|---|
| **overshoot** | large (e.g. `0xFFFF`) | `aoff+reclen-8` blows past `rdbuf` → 8-byte heap OOB read into `cn` (CWE-125). With `0xFFFF` the read lands ~65 KB past a 4 KB buffer. |
| **under-sized** | `< 8` | the finding's summary claims a `size_t` underflow; in practice `aoff >= sizeof(attr_indexroot)=32`, so `aoff+reclen-8` stays `>= 0` and reads a **wrong but in-bounds** offset (a semantic bug, not OOB). The harness confirms `tiny` (reclen=2) reads in-bounds. The real OOB is the overshoot. |

The leaked bytes land in the kernel-local `cn`, then used as a cluster offset
(`ntfs_cntob(cn)`) for the next `ntfs_readattr` — i.e. the OOB read influences
control flow / disk reads, it is not directly exfiltrated to userspace.

## Reachability

`ntfs_ntlookupfile` is reached from `ntfs_lookup` (`sys/vfs/ntfs/ntfs_vnops.c:712`,
`vop_old_lookup`) on **any** name lookup inside an NTFS directory:

```
stat /mnt/ntfs/<name>  -> namei -> VOP_LOOKUP(rootdir) -> ntfs_lookup
    -> ntfs_ntlookupfile -> INDEX_ROOT walk -> (res>0) break -> SUBNODE dive
```

`ntfs_ntlookupattr` (DF-0786's path) is **not** entered for a plain name lookup
(no `:attr` spec), so DF-0786 does not gate this path.

**However**, on this guest kernel a **sibling lockmgr self-lock** fires first:
`stat`/lookup of any name in a directory whose INDEX_ROOT holds a malformed entry
panics with `panic: lockmgr: locking against itself` at
`ntfs_ntlookupfile+0x57` — disassembly shows `+0x57` is the inlined
`ntfs_ntget` → `LOCKMGR(&ip->i_lock, LK_EXCLUSIVE)` (the very first action of the
function, `addl $0x1,0x70(%r14)` usecount++ then `lea 0x48(%r14),%rdi` =
`&i_lock`, then `callq lockmgr`), which is **before** the `ntfs_ntvattrget(INDEX_ROOT)`
at `+0x75` and long before the dive. The dive is therefore **latent** on the live
kernel. (A clean-image lookup does not panic, so the self-lock is triggered by the
malformed-image node state; it is a separate NTFS directory-lookup defect, not
DF-0791.) Per the run brief, a deterministic code-level harness is the accepted
reproduction when a sibling panic blocks the live path.

## Reproduction — deterministic harness (`harness.c`)

Mirrors the exact kernel walk + dive against a buffer placed at the end of a
writable page immediately before a `PROT_NONE` guard page, so any overshoot faults
deterministically (SIGSEGV):

```
=== BUGGY dive (kernel behaviour on default GENERIC #0) ===
mode=clean  apply_fix=0 -> rc=0  dive read OK, cn=0xaaaa007a007a007a (in bounds)
mode=oob    apply_fix=0 -> rc=2  SIGSEGV -> OOB READ past rdbuf (dive offset 65559 >= rdsize 128)
mode=tiny   apply_fix=0 -> rc=0  dive read OK (reclen=2 -> offset 26, in bounds: not OOB)

=== FIXED dive (proposed fix rejects malformed entries) ===
mode=clean  apply_fix=0 -> rc=0  dive read OK (in bounds)
mode=oob    apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL in kernel
mode=tiny   apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=2) -> EINVAL in kernel
```

- `oob` (reclen=0xFFFF) → SIGSEGV: the dive reads 65 KB past the buffer. **Proves the OOB read.**
- `tiny` (reclen=2) → reads in-bounds at offset 26: the "reclen<8 underflow" variant is **not** an OOB given `aoff>=32`; the fix still rejects it (`reclen < sizeof(cn_t)`).
- With `apply_fix=1`, both malformed shapes are rejected with EINVAL before the dereference.

## Escalation assessment (no chain — read-only primitive)

This is a **pure read** (CWE-125). No write to attacker-chosen kernel memory: the only
effect is reading adjacent heap into a `cn_t` used as a disk offset. Per Phase 6, a
read-only primitive has **no escalation chain** to `uid=0`; the correct deliverable is
the characterized impact ceiling: heap info-leak / influence-on-control-flow, and DoS
when the OOB-derived offset faults. No `uid0` chain is applicable.

## Fix — `fix.diff`

Adds a bounds check inside the SUBNODE dive, **before** the VCN dereference:

- `iep->reclen >= sizeof(cn_t)` — the trailing 8-byte VCN must fit inside the entry.
- `aoff <= rdsize && iep->reclen <= rdsize - aoff` — the entry (incl. VCN) must fit
  inside the valid data region (overflow-safe: checked via subtraction). Since
  `rdsize <= blsize` for sane images this also keeps the read inside the `kmalloc(blsize)`
  allocation.
- On violation: `error = EINVAL`, `kprintf` a diagnostic, `goto fail` (clean return;
  `rdbuf` freed by the existing `fail:` label).

## Fix validation (Phase 8)

NTFS is `optional ntfs` (`sys/conf/files`) → ships as `/boot/kernel/ntfs.ko`, a loadable
module, **not** compiled into GENERIC. So the fix only required rebuilding `ntfs.ko`
(`KERNCONF=X86_64_GENERIC make` in `sys/vfs/ntfs/`, warm obj) and installing it — no
kernel rebuild or reboot.

- **Compiles:** `make` rc=0; 43 text symbols (same as stock).
- **Bounds check present:** disassembly of the patched `ntfs_ntlookupfile` shows, before
  the cn read, three checks all branching to the EINVAL path (`0x16`):
  `cmp %edi,-0x7c(%rbp); jb` (aoff>rdsize), `cmp $0x7,%ax; jbe` (reclen<8),
  `cmp %edx,%esi; ja` (reclen>rdsize-aoff); the error path does `mov $0x16,%r12d`
  (EINVAL) + `kprintf("...malformed index entry...")` + goto fail. The string
  `ntfs_ntlookupfile: malformed index entry (reclen %u, aoff %u, rdsize %u)` is in the
  patched module.
- **Harness before/after:** unpatched logic → OOB read (SIGSEGV); fixed logic → EINVAL
  (no OOB read). Clean before/after at the code/harness level.
- **Live dive path:** sibling-blocked (the lockmgr self-lock at `+0x57` fires before the
  dive on **both** the unpatched and patched kernels, so a live before/after of the dive
  itself is not exercisable on this guest). The fix is therefore validated at the
  harness + disassembly level; it demonstrably closes the vulnerable code path.

## Files

- `harness.c` — deterministic guard-page replication of the dive read (clean/oob/tiny × fix).
- `gen_ntfs_0791.py` — crafted NTFS image generator (extends DF-0786 `gen_ntfs.py`);
  root-dir (ino 5) INDEX_ROOT holds one SUBNODE entry "zzzzzz" with `reclen=0xFFFF`.
- `ntfs_0791.img` — generated crafted image (256 KB).
- `build.sh` / `run.sh` — exact build/run.
- `fix.diff` — standalone `git apply`-able fix.
- `build.log`, `run.log`, `fix_build.log`, `fix_run.log`, `panic.txt`, `env.txt`,
  `manifest.json`, `VERDICT.md`.

## How to reproduce

```sh
# 1. deterministic harness (no root needed)
scp -F dfbsd-qemu/config -q findings/poc/DF-0791/{harness.c,build.sh,run.sh} dfbsd-maxx:poc/DF-0791/
ssh -F dfbsd-qemu/config dfbsd-maxx 'cd poc/DF-0791 && sh build.sh && sh run.sh'
# expect: mode=oob apply_fix=0 -> SIGSEGV (OOB READ); apply_fix=1 -> EINVAL

# 2. fix validation (rebuild ntfs.ko module, install, confirm bounds check)
scp -F dfbsd-qemu/config -q findings/poc/DF-0791/fix.diff dfbsd:/root/
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && \
    cd sys/vfs/ntfs && KERNCONF=X86_64_GENERIC make && cp ntfs.ko /boot/kernel/ntfs.ko'
strings /boot/kernel/ntfs.ko | grep "malformed index"   # confirm fix present
```
