# DF-0791 — VERDICT

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

| field | value |
|---|---|
| **status** | reproduced |
| **reproduced** | true (primitive proven deterministically; live dive path sibling-blocked) |
| **impact** | `leak` — 8-byte heap OOB read (CWE-125) of the subnode VCN into a kernel-local `cn_t` |
| **confidence** | certain |
| **severity** | Medium (matches finding) |

---

## 1. Root cause (confirmed by source trace)

`ntfs_ntlookupfile()` in `sys/vfs/ntfs/ntfs_subr.c` walks an NTFS directory's
B-tree index looking up a name. When a comparison indicates the target name
sorts after the current entry (`NTFS_UASTRICMP > 0` → `break`), the code checks
whether that entry has a subnode (`NTFS_IEFLAG_SUBNODE`) and, if so, reads the
child pointer (a `cn_t` = `u_int64_t`, 8 bytes) stored in the **last 8 bytes of
the entry**:

```c
/* sys/vfs/ntfs/ntfs_subr.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,
 902:      iep = (struct attr_indexentry *) (rdbuf + aoff))
        { ... if (res > 0) break; ... }

 1006: /* Dive if possible */
 1007: if (iep->ie_flag & NTFS_IEFLAG_SUBNODE) {
 1010:     cn = *(cn_t *) (rdbuf + aoff + iep->reclen - sizeof(cn_t));
 1012:     rdsize = blsize;
 1014:     error = ntfs_readattr(ntmp, ip, NTFS_A_INDX, "$I30",
 1015:                     ntfs_cntob(cn), rdsize, rdbuf, NULL);
```

`iep->reclen` is a raw `u_int16_t` read directly off the crafted image
(`sys/vfs/ntfs/ntfs.h:173`) with **zero validation** before the dereference. The
walk-loop guard (`:900`, `rdsize > aoff`) only checks the **entry start** is
in-bounds; it never checks the **entry end** (`aoff + reclen`) or the trailing
8-byte VCN. Consequences:

- **Overshoot** (`reclen` large, e.g. `0xFFFF`): `aoff + reclen - 8` lands far
  past `rdbuf` (a `kmalloc(blsize)` allocation) → **8-byte heap OOB read**
  (CWE-125). With `reclen=0xFFFF` the read is ~65 KB past a 4 KB buffer.
- **Under-sized** (`reclen < 8`): the finding summary speculates a `size_t`
  underflow to ~`SIZE_MAX`. In the INDEX_ROOT walk `aoff` starts at
  `sizeof(struct attr_indexroot) = 32` and only grows, 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 8 bytes land in the kernel-local `cn`, used as
`ntfs_cntob(cn)` (a cluster offset) for the following `ntfs_readattr`. So the
read influences control flow / disk reads; it is **not** directly exfiltrated to
userspace. No write primitive ⇒ no escalation chain (read-only class).

## 2. Reachability

`ntfs_ntlookupfile` is the `vop_old_lookup` implementation, reached from
`ntfs_lookup` (`sys/vfs/ntfs/ntfs_vnops.c:712`) on **any** name lookup inside an
NTFS directory. `ntfs_ntlookupattr` (DF-0786's path) is entered only for an
attribute-qualified name (`name:attr`), so a plain `stat /mnt/ntfs/<name>` does
**not** hit DF-0786. Threat model: a root-mountable crafted NTFS image
(`mount_ntfs` is `SYSCAP_RESTRICTEDROOT`, `vfs.usermount=0` verified) — the
standard filesystem-image model (admin mounts / makes mountable an untrusted
image; the lookup itself is unprivileged).

## 3. Live-path assessment — sibling-blocked

On this guest kernel (`6.5-DEVELOPMENT #0`), a **sibling lockmgr self-lock**
fires at the very top of `ntfs_ntlookupfile`, **before** the INDEX_ROOT walk and
the dive:

```
panic: lockmgr: locking against itself
lockmgr_exclusive() at lockmgr_exclusive+0x3e0
ntfs_ntlookupfile() at ntfs_ntlookupfile+0x57    ; <- inlined ntfs_ntget
ntfs_lookup() at ntfs_lookup+0x63
```

Disassembly (`ntfs.ko`, `ntfs_ntlookupfile` @ `0x4bd0`) pins `+0x57` to the
inlined `ntfs_ntget`:

```
4c15: addl $0x1,0x70(%r14)   ; ip->i_usecount++
4c1a: lea  0x48(%r14),%rdi   ; &ip->i_lock
4c22: callq 4c27             ; -> lockmgr(LK_EXCLUSIVE)   [+0x57]
4c40: callq (ntfs_ntvattrget, NTFS_A_INDXROOT=0x90)       ; [+0x75] INDEX_ROOT fetch is AFTER
```

So the self-lock happens at the first `LOCKMGR(&ip->i_lock, LK_EXCLUSIVE)`, before
the INDEX_ROOT is even fetched — long before the dive at `+0x2c0`. The dive (and
DF-0791's OOB read) is therefore **latent** on the running kernel. It reproducibly
fires for the malformed image (3/3 patched attempts, plus unpatched attempts); a
clean-image lookup does **not** panic, confirming the self-lock is a separate
malformed-image/node-state directory-lookup defect, not DF-0791.

Per the run brief: *"If a sibling panic blocks the live path, a deterministic
code-level harness reproducing the unvalidated subnode-pointer read is
acceptable."* — the harness below is that reproduction.

## 4. 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). Output (unprivileged `maxx`):

```
=== 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 (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=oob    apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL
mode=tiny   apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=2) -> EINVAL
```

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

## 5. Escalation assessment

Pure read (CWE-125). No write to attacker-chosen kernel memory. Per Phase 6 a
read-only primitive has **no escalation chain** to `uid=0`; the deliverable is the
characterized ceiling: heap info-leak / control-flow influence / DoS when the
OOB-derived offset faults. No `uid0` chain is applicable (valid hard blocker:
read-only primitive).

## 6. 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 fits inside the entry.
- `aoff <= rdsize && iep->reclen <= rdsize - aoff` — the entry (incl. VCN) fits
  inside the valid data region (overflow-safe via subtraction; since `rdsize <=
  blsize` for sane images this also keeps the read inside the `kmalloc(blsize)`
  allocation).
- On violation: `error = EINVAL`, `kprintf` diagnostic, `goto fail`.

Minimal and targeted at the root cause (the missing bounds check). Does not touch
the lockmgr sibling bug, the on-disk format, or the happy path for valid images.

## 7. Fix validation (Phase 8)

NTFS is `optional ntfs` (`sys/conf/files`) → `/boot/kernel/ntfs.ko`, a loadable
module (not in GENERIC). Fix = rebuild `ntfs.ko` only (no kernel rebuild/reboot).

- **Applies + compiles:** `patch -p1` hunk succeeded at line 1007; `make` rc=0;
  43 text symbols (same as stock).
- **Bounds check present (disassembly):** before the cn read, three checks all
  branch to the EINVAL path (`mov $0x16,%r12d` = EINVAL 22) + `kprintf`:
  `cmp %edi,-0x7c(%rbp); jb` (aoff>rdsize), `cmp $0x7,%ax; jbe` (reclen<8),
  `cmp %edx,%esi; ja` (reclen>rdsize-aoff). String
  `ntfs_ntlookupfile: malformed index entry (reclen %u, aoff %u, rdsize %u)` present.
- **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:** the sibling lockmgr self-lock fires at `+0x57` on **both**
  unpatched and patched kernels (same panic, same offset, verified on the patched
  module at `0x4a60+0x57=0x4ab7`), so a live before/after of the dive itself is
  not exercisable on this guest. The fix is validated at the harness +
  disassembly level; it demonstrably closes the vulnerable code path.

## 8. PoC changes

Authored from scratch (no prior PoC existed for DF-0791):
- `harness.c` — deterministic guard-page replication of the dive (clean/oob/tiny × fix).
- `gen_ntfs_0791.py` — crafted NTFS image generator extending DF-0786's
  `gen_ntfs.py`; root-dir (ino 5) INDEX_ROOT holds one SUBNODE entry "zzzzzz"
  with `reclen=0xFFFF`.
- `ntfs_0791.img` — generated crafted image.
- `build.sh` / `run.sh` — exact build/run.
- `fix.diff` — standalone `git apply`-able fix at the root cause.
