# DF-0871 — VERDICT

**Verdict: REPRODUCED (heap OOB write / corruption, with a live panic on GENERIC).**
**Impact: `corruption` → manifests as `panic` (kqueue_register victim deref).**
**Confidence: certain.**
**Fix: VALIDATED (`fixed`).**

---

## Root-cause confirmation

`ntfs_mountfs()` (sys/vfs/ntfs/ntfs_vfsops.c) parses the on-disk `$AttrDef`
table at mount time.  For each 160-byte `struct attrdef` it allocates a 72-byte
`struct ntvattrdef` and copies the attribute name with an **unbounded**
wchar→char do/while:

- `sys/vfs/ntfs/ntfs_vfsops.c:444` — `ntmp->ntm_ad = kmalloc(num * sizeof(struct ntvattrdef), M_NTFSMNT, M_WAITOK);`  (72 B per entry)
- `sys/vfs/ntfs/ntfs_vfsops.c:458-460` —
  ```c
  do {
      ntmp->ntm_ad[i].ad_name[j] = ad.ad_name[j];   /* DEST is char[0x40]=64 */
  } while(ad.ad_name[j++]);                          /* NO bound on j */
  ```

Type facts (confirmed in `sys/vfs/ntfs/ntfs.h`):
- `ntfs.h:37` — `typedef u_int16_t wchar;`
- `ntfs.h:203` — `#define NTFS_ATTRNAME_MAXLEN 0x40` (= 64)
- `ntfs.h:206` — `struct attrdef { wchar ad_name[0x40]; u32 ad_type; u32 r1[2]; u32 ad_flag; u64 min; u64 max; }` = **160 B**
- `ntfs.h:215` — `struct ntvattrdef { char ad_name[0x40]; int ad_namelen; u32 ad_type; }` = **72 B**

The index `j` is bounded **only** by a NUL `wchar` in the *source* `ad.ad_name`
(which is `wchar[64]`).  If a crafted `$AttrDef` record has no NUL `wchar`
anywhere in its 64-wide-char name **and** its trailing 32 B (`ad_type` /
`reserved1` / `ad_flag` / `ad_minlen` / `ad_maxlen`, aliasing `ad.ad_name[64..79]`)
are all non-zero half-words, the walk runs `j = 0..80+`.  The destination write
`ntmp->ntm_ad[i].ad_name[j]` then:

- `j=0..63`  → in-bounds name bytes
- `j=64..67` → `ntvattrdef[i].ad_namelen` (repaired by `:461`)
- `j=68..71` → `ntvattrdef[i].ad_type`    (repaired by `:462`)
- **`j=72..`** → **`ntvattrdef[i+1].ad_name[0..]`** (cross-entry) **or, for the
  last entry, past the whole `kmalloc()`** → **HEAP OOB WRITE into `M_NTFSMNT`.**

The post-loop assignments (`:461-462`) repair the within-object bytes of entry
`i` but do **not** touch the bytes written at `j>=72`.  Those stay corrupted,
exactly as the finding claims.

## Evidence

### A. Deterministic harness (`harness.c`)

Faithful userspace transcription of `:444,457-462` with a poisoned allocator
(victim array at the end of a guard-paged page).  The source is presented as an
oversized `wchar[256]` buffer (all non-zero up to a final NUL) so the do/while
is genuinely unbounded — defeating the compiler's `ad_name[64]` array-bounds UB
assumption that would otherwise let a naïve transcription silently "pass".

- **buggy, num=1:** writes `dest[j]` for `j>=72` → **SIGSEGV at the first byte
  past the 72-byte object.**  Deterministic proof the write leaves the object.
- **buggy, num=2:** entry[0]'s walk overwrote **72 bytes of entry[1].ad_name**
  (cross-entry corruption — the finding's claimed primitive), then the last
  entry's overflow crosses the 144-byte allocation → SIGSEGV.
- **fixed (`harness_fixed.c`, mirrors `fix.diff`):** exit 0, **0 bytes** past
  the allocation, name length capped at 63 + NUL-terminated.

(`harness_run.log`, `harness_compare.log`.)

### B. Live kernel (`#0` GENERIC, INVARIANTS ON)

Crafted NTFS image (`craft_img.py`): 1–2 evil 160-byte `$AttrDef` records whose
entire 160 bytes are non-zero half-words + an all-zero terminator.  `vnconfig` +
`mount_ntfs` (root) runs the unbounded copy on every mount.  During a churn
loop the slab is groomed so the corrupted `ntm_ad` chunk is adjacent to a
victim object; the **`0xFF` bytes** the evil data writes (truncated low-bytes
of the `0xFFFFFFFF` trailing fields) corrupt a victim pointer to
`0xffffffffffffffff`, whose later dereference faults:

```
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xffffffffffffffff
Stopped at kqueue_register+0x526:  movq (%r15),%rdx
```
(`panic.txt`.)

**Controlled experiment (causation):**
- **benign** image (`craft_benign.py`, short NUL-terminated `$AttrDef` names →
  walk stops `j<64`, no overflow): **10 mount/unmount cycles, 0 panics**,
  guest UP.
- **evil** image: `kqueue_register` panic during churn (above).

The live panic is timing/layout-dependent (heap-grooming); the **harness is the
deterministic proof** and fires on every run.  A single clean mount of the evil
image usually produces silent within-allocation/cross-entry corruption (no
immediate INVARIANTS trip because the bytes land in slab padding within the same
chunk); repeated mounts groom the slab into a configuration where the corrupted
chunk borders a dereferenced victim and the kernel faults.

## Impact / threat model & escalation ceiling

- **Primitive:** attacker-content-controlled heap OOB write into the
  `M_NTFSMNT` slab zone (`kmalloc-128` for `num=1`, `kmalloc-256` for `num=2`).
  The written bytes are the low bytes of attacker-chosen on-disk `wchar`s, so
  the content is shapable.
- **Trigger:** `mount -t ntfs` of a crafted image.  **Root-only** in the
  default config (`vfs.usermount=0`; `mount_ntfs`/`vnconfig` not setuid —
  verified in `env.txt`).
- **Realistic threat:** a malicious NTFS image (USB / download / multi-user
  system / automount) causes kernel heap corruption and/or panic when mounted
  by root — the classic untrusted-filesystem-image attack surface.  High
  severity (kernel memory corruption from untrusted on-disk data).
- **uid=0 escalation — BLOCKED by a valid hard blocker:** the write is
  reachable only from an already-root credential (the `mount` syscall requires
  root).  Per the audit's bright-line rule, root→kernel is game-over by
  definition; there is no **unprivileged→root** privilege boundary to cross
  end-to-end.  The primitive *could* be slab-groomed to escalate *if* an
  unprivileged user could mount the image (e.g. an admin sets
  `vfs.usermount=1` and provides an attacker-owned, device-accessible image),
  but that is a conditional scenario, not the default config.  Honest impact is
  therefore **heap corruption / DoS (panic)** from a malicious filesystem image.

## PoC changes (vs. the seeded scaffolding)

This pack was built from scratch (no seeded sources).  Reuses the proven
DF-0785 NTFS image builder.  Key additions:
- `craft_img.py` — DF-0871 variant: evil `$AttrDef` (all-nonzero 160-byte
  records) + **well-formed** root dir `ir_size==datalen` so only the
  mount-time `$AttrDef` bug is exercised (DF-0785's lookup bug isolated).
- `craft_benign.py` — negative-control crafter (short NUL-terminated names).
- `harness.c` / `harness_fixed.c` — deterministic transcriptions.  v2 of the
  buggy harness uses an oversized source buffer to defeat the compiler's
  array-bounds UB assumption (v1 silently passed at `-O2`).

## Fix (`fix.diff`) — VALIDATED

Minimal, targeted fix at the root cause — bound the do/while to the destination
array length and force NUL-termination:

```diff
-           } while(ad.ad_name[j++]);
+           } while(ad.ad_name[j++] &&
+               j < (int)sizeof(ntmp->ntm_ad[i].ad_name));
+           /* DF-0871: force NUL-termination within the fixed-size buffer
+            * so the wchar->char walk can never overrun ntvattrdef.ad_name. */
+           ntmp->ntm_ad[i].ad_name[sizeof(ntmp->ntm_ad[i].ad_name) - 1] = '\0';
```

`git apply --check`: OK.  Supersedes/matches the finding markdown's proposal.

### Phase 8 — single-fix `ntfs.ko` (hot-swap)

- `git apply` of `fix.diff` to in-guest `/usr/src/sys/vfs/ntfs/ntfs_vfsops.c`;
  rebuilt `ntfs.ko` (`cd /usr/src/sys/vfs/ntfs && make`, cc 8.3, rc=0 —
  `fix_build.log`); hot-swapped (`kldunload` → install → `kldload`, sha256
  `dbc96b6262bd579da8a7b050f490047048e6b5ed4f5fd338324d3848788e021d`).

| kernel / module          | harness                 | live evil-image mount          |
|--------------------------|-------------------------|--------------------------------|
| unpatched `#0` GENERIC   | SIGSEGV at j=72         | **panic** `kqueue_register+0x526` (fault 0xffffffffffffffff) |
| fixed `ntfs.ko`          | clean exit 0, 0 B over  | **30 cycles, 0 panics**, guest UP |

Benign image still mounts and parses attributes on the fixed module (functional
regression check OK).  **→ fix_status: `fixed`.**
