# DF-0871 — PoC evidence pack

Heap overflow in NTFS `$AttrDef` name copy via unbounded `wchar`→`char`
do/while walk.  **File:** `sys/vfs/ntfs/ntfs_vfsops.c:444,458-460`.
**CWE-787 (OOB write).** Severity: High.

## The bug (confirmed line-by-line)

`ntfs_mountfs()` parses the on-disk `$AttrDef` table (MFT record 4) at mount
time.  For every 160-byte `struct attrdef` it reads, it allocates a 72-byte
`struct ntvattrdef` and copies the attribute **name** with an unbounded
do/while:

```c
/* sys/vfs/ntfs/ntfs_vfsops.c */
ntmp->ntm_ad = kmalloc(num * sizeof(struct ntvattrdef), M_NTFSMNT, M_WAITOK);  /* :444 */
...
for (i=0;i<num;i++){
    ...
    j = 0;                                                       /* :457 */
    do {
        ntmp->ntm_ad[i].ad_name[j] = ad.ad_name[j];              /* :459  DEST is char[0x40]=64 */
    } while(ad.ad_name[j++]);                                     /* :460  NO bound on j */
    ntmp->ntm_ad[i].ad_namelen = j - 1;                          /* :461 */
    ntmp->ntm_ad[i].ad_type = ad.ad_type;                        /* :462 */
}
```

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

The destination `ad_name` is `char[64]` but the loop index `j` is bounded
**only** by finding a NUL `wchar` in the **source** `ad.ad_name[64]`.  If the
on-disk `$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`,
which alias `ad.ad_name[64..79]`) are also all non-zero half-words, the walk
runs `j = 0..80+`.  The write `ntmp->ntm_ad[i].ad_name[j]` then:

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

The post-loop assignments at `:461-462` "repair" the within-object bytes
(`ad_namelen`/`ad_type` of entry `i`) but do **not** touch the cross-entry /
past-allocation bytes written at `j>=72`.  Those stay corrupted.

## Reproduction

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

A faithful userspace transcription of `:444,457-462` backed by a poisoned
allocator (victim array at the end of a guard-paged page).  No slab luck or
INVARIANTS needed — the overflow is detected byte-exactly.

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

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

A crafted NTFS image (`craft_img.py`, 1–2 evil 160-byte `$AttrDef` records
whose entire 160 bytes are non-zero half-words + a zero terminator) is mounted
with `vnconfig` + `mount_ntfs` (root).  Each mount runs the unbounded copy.
During a churn loop the slab gets 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`, and its later dereference faults:

```
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xffffffffffffffff
instruction pointer     = 0x8:0xffffffff8063b1c6
current process         = 1015
Stopped at kqueue_register+0x526:  movq (%r15),%rdx
```

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

The live panic is timing/layout-dependent (heap grooming); the **harness is the
deterministic proof** and reproduces on every run.

## Impact / threat model

- **Primitive:** attacker-content-controlled heap OOB write into the
  `M_NTFSMNT` slab zone (`kmalloc-128`/`kmalloc-256`).  The written bytes are
  the low bytes of attacker-chosen on-disk `wchar`s, so 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).
- **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 data).
- **uid=0 escalation:** the trigger requires root credential, so there is no
  **unprivileged→root** privilege boundary to cross end-to-end (root→kernel is
  game-over by definition).  This is a valid hard blocker for an `uid0`
  verdict.  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.

## Fix (`fix.diff`)

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';
```

This is a minimal, targeted fix at the root cause.  `git apply --check` passes.
Supersedes/matches the finding markdown's proposal.

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

- Applied `fix.diff` to in-guest `/usr/src/sys/vfs/ntfs/ntfs_vfsops.c`
  (`git apply`), rebuilt `ntfs.ko` (`cd /usr/src/sys/vfs/ntfs && make`, rc=0,
  `fix_build.log`), hot-swapped (`kldunload`/`kldload`, sha256
  `dbc96b62…`).
- **Before (unpatched `#0`):** buggy harness SIGSEGVs at j=72 (deterministic);
  live evil-image churn → `kqueue_register` panic (`panic.txt`).
- **After (patched `ntfs.ko`):** fixed harness exit 0, **0 bytes** past the
  allocation (`harness_compare.log`); **30 evil-image mount cycles** (15× num=1
  + 15× num=2) → **0 panics**, guest UP (`fix_run.log`).  Benign image still
  mounts and parses attributes (functional regression check OK).

→ **fix_status: fixed.**

## Files

| file                 | purpose                                                   |
|----------------------|-----------------------------------------------------------|
| `craft_img.py`       | NTFS image crafter: evil `$AttrDef` (all-nonzero) records |
| `craft_benign.py`    | control crafter: short NUL-terminated `$AttrDef` names    |
| `harness.c`          | deterministic overflow harness (BUGGY do/while)           |
| `harness_fixed.c`    | same harness mirroring `fix.diff` (bounded + NUL-term)    |
| `build.sh`/`run.sh`  | exact repro scripts                                       |
| `build.log`          | harness build output                                      |
| `harness_run.log`    | buggy harness SIGSEGV + cross-entry report                |
| `harness_compare.log`| buggy-vs-fixed before/after                               |
| `baseline_harness.log`| baseline (#0) harness confirmation                       |
| `mount_attempt.log`  | live mount attempts (silent corruption + churn)           |
| `panic.txt`          | live kernel panic signature (`kqueue_register` fault)     |
| `fix.diff`           | git-apply-able one-hunk fix                               |
| `fix_apply.log`      | patch application + patched region                        |
| `fix_build.log`      | single-fix `ntfs.ko` build output                         |
| `fix_run.log`        | patched-module live re-test (30 cycles, 0 panics)         |
| `env.txt`            | guest uname / cc / sysctls / module sha256                |
| `manifest.json`      | machine-readable catalog                                  |
| `VERDICT.md`         | full narrative                                            |

## Reproduce

On the repo root (guest up on `with-src`):
```sh
cd findings/poc/DF-0871
scp -F ../../../dfbsd-qemu/config craft_img.py craft_benign.py harness.c harness_fixed.c dfbsd:/root/df0871/
./../../../dfbsd-qemu/vm.sh run_root 'cd /root/df0871 && python3 craft_img.py ntfs_evil.img 2 && cc -O2 -o harness harness.c && (./harness 2; echo exit=$?)'
# deterministic: SIGSEGV at j=72 + cross-entry corruption reported
```
