# DF-0785 — Verdict

**REPRODUCED — uid=0 (local unprivileged → root) on the default GENERIC kernel.**

A heap OOB write in `ntfs_ntlookupfile` (CWE-787) is a **groomable, fully
attacker-controlled arbitrary heap write** that was escalated to a reliable
`maxx(uid 1001) → uid=0(root)` privilege escalation on `6.5-DEVELOPMENT #0`
(X86_64_GENERIC, INVARIANTS ON, SMAP/SMEP/KASLR all OFF), via a **userspace-only
chain** (no `kldload`, no setuid helper, no INVARIANTS-OFF). Reproduced on **two
independent fresh-reset boots** (`run.chain1.log`, `run.chain2.log`). The prior
`panic`/`corruption` impact is superseded — `uid0` is the demonstrated ceiling.

## Root cause (confirmed, `path:line`)

`sys/vfs/ntfs/ntfs_subr.c`, `ntfs_ntlookupfile`:

- `:867` `blsize = vap->va_a_iroot->ir_size;` — `ir_size` is an on-disk
  `u_int32_t` **inside** the resident `$INDEX_ROOT` attribute header.
- `:868` `rdsize = vap->va_datalen;` — a **separate** on-disk value (resident
  attribute data length, `a_r.a_datalen`).
- `:888` `rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);` — buffer sized by `ir_size`.
- `:890-891` `ntfs_readattr(... rdsize ... rdbuf)` — copies `rdsize` bytes.
  **No `rdsize <= blsize` check.** When `ir_size < va_datalen`, the resident
  `$INDEX_ROOT` data (attacker-controlled) overflows the `blsize`-byte slab
  object by `va_datalen - ir_size` bytes into the **next slab chunk** in the same
  zone page. `ntfs_readattr`'s own guard (`:1671`, `roff+rsize>va_datalen`) cannot
  catch this — the buggy call passes `rsize=rdsize=va_datalen`, so the test is
  `va_datalen>va_datalen` (always false).

The sibling reader already does it right: `ntfs_ntreaddir:1105`
`kmalloc(max(vap->va_datalen, fp->f_dirblsz), M_NTFSDIR, M_WAITOK);`

The primitive: **write size = `va_datalen`, overflow extent = `va_datalen-ir_size`,
content of every byte attacker-chosen, slab bucket attacker-chosen via `ir_size`,
destination = the physically-next slab chunk in the same zone.** DragonFly's slab
(`kern_slaballoc.c`) selects zones by **size only** (shared across all `M_*`
types), has **no content canary/redzone** (state is a per-zone bitmap,
`:1654-1683`; `use_weird_array` only poisons freed chunks, never verifies), so a
single overflow into a **live** victim is silent on GENERIC (INVARIANTS catches
only *free-list* corruption later, under churn — that is the panic path).

## Threat model / reachability

`mount_ntfs` is `SYSCAP_RESTRICTEDROOT` → the mount itself needs root (the
standard filesystem-image threat model: an admin mounts / makes mountable an
attacker-owned NTFS image — e.g. a USB stick, forensic image, or
`vfs.usermount=1` + a root-created image chowned to the user). **Post-mount, the
name-lookup trigger (`ntfs_lookup → ntfs_ntlookupfile`) is unprivileged** — every
step of the chain after the mount runs as `maxx` (uid 1001). The goal and result:
maxx → uid=0.

## The escalation chain (`chain.c`, `sc.S`)

**Bucket / victim selection.** Seat `rdbuf` in **slab zone 34 (704-byte chunks)**
— the same zone as `struct socket` (`socreate`: `kmalloc(sizeof(struct socket),
M_SOCKET)`; `sizeof(struct socket)=696 → zone 34`). Set `ir_size/blsize=704`,
`va_datalen=744` ⇒ a **40-byte overflow** into the next live socket covering
`so_pcb(8)=0`, `so_proto(16)=&forged_protosw`, `so_head(24)=0`,
`so_port(32)=&forged_lwkt_port`. (`struct file`/`f_ops` is in the separate
`kmalloc_obj` objcache pool and is unreachable; `struct socket` is plain
`kmalloc` → same zones as `M_TEMP`.)

**Why `so_port`, not `so_proto`.** The DragonFly socket layer dispatches
`so_proto->pr_usrreqs->pru_*` and `pr_ctloutput` **via lwkt netmsg to the
netisr thread** (`uipc_msg.c` `so_pr_ctloutput` → `lwkt_domsg(so->so_port, …)`,
and `lwkt_thread_putport` *always* returns `EASYNC` → the handler runs in the
netisr thread context, not the caller's). netisr has no mapping of our user-space
shellcode page, so hijacking `so_proto`'s function pointers cannot fetch our
shellcode. Instead we hijack **`so_port`** (a `struct lwkt_port *`):
`lwkt_domsg` → `lwkt_beginmsg(port,msg)` = `port->mp_putport(port,msg)`
(`msgport.h`, `lwkt_msgport.c:80`) — a **synchronous direct call in the
originating process context** (maxx's `getsockopt`), where our user-space
mappings are live. We forge `so_port->mp_putport = &shellcode`.

**Grooming.** Spray 5000 UDP sockets to densely fill zone-34 across CPUs, then
close every 5th to punch holes each flanked by live sockets. Each `stat /mnt/x`
runs `ntfs_ntlookupfile` ⇒ one 40-byte overflow that, when `rdbuf` lands in a
hole, corrupts the live socket immediately after it (`so_port` → forged port).
Then `getsockopt(fd, IPPROTO_IP, IP_TTL)` on every live socket: the corrupted
one routes `sogetopt` (`sopt->sopt_level != SOL_SOCKET` &&
`so->so_proto->pr_ctloutput != NULL`) → `so_pr_ctloutput` →
`lwkt_domsg(forged_port, msg)` → `forged_port->mp_putport` = **shellcode**.

**Conversion shellcode** (`sc.S`, 157 bytes, in maxx context). Walks
`procglob[mypid & 0xff].allproc` (`procglob=0xffffffff81193640`, `allproc.lh_first@+0x20`),
finds our proc (`p_list.le_next@0`, `p_pid@92`), and zeroes its
`p_ucred`(16) uid fields — `cr_uid@64`, `cr_ngroups@68`, `cr_groups@72`,
`cr_ruid@160`, `cr_svuid@164`, `cr_rgid@168`, `cr_svgid@172`, `cr_caps@176` —
then returns 0 (non-EASYNC) so `lwkt_domsg` marks the msg done and
`getsockopt` returns cleanly. No SMAP ⇒ kernel reads our forged protosw/port
pages; no SMEP ⇒ kernel executes our shellcode page; no KASLR ⇒ all kernel
addresses fixed. `mypid` is patched into a fixed user page (`0x13372000`) read
by the shellcode.

**Landing.** On the next syscall entry the kernel re-syncs `td_ucred` from
`p_ucred` (`thread.h:279`) → `geteuid()==0` → `setresuid(0,0,0)` normalises →
`exec /bin/sh` ⇒ root shell.

```
[chain] pid=2218 uid=1001 euid=1001
[chain] shellcode @ 0x13371000 (157 B); forged protosw @ 0x13372800; forged port @ 0x13373000 (mp_putport->shellcode)
[chain] sprayed 5000 sockets (zone-34 / struct socket)
[chain] punched holes; 4000 live sockets remain
[chain] *** ROOT ACQUIRED *** uid=0 euid=0
uid=0(root) gid=0(root) groups=0(root)
after setresuid: uid=0(root) gid=0(wheel) groups=0(wheel)
CHAIN_SUCCESS_UID0
```

Reproduced from a **fresh `vm.sh reset with-src`** (`run.chain2.log`) — not
leaked state. The guest goes down shortly *after* success (post-exploitation
collateral: zone-34 sockets whose `so_port` was redirected to user pages get
touched by kernel cleanup / process exit), but the escalation (`id`→root,
`setresuid` success) is already demonstrated. The grooming is probabilistic and
typically succeeds within ~24 overflows; on GENERIC, overflows that instead hit
free chunks corrupt the free list and surface as the `chunk_mark_free:1675` /
`BADFREE2` INVARIANTS panic (the original `panic` impact) — that is the same bug
manifesting destructively rather than exploitatively.

## Fix

`fix.diff` — one line, matching the sibling `ntfs_ntreaddir:1105`:

```diff
-	rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK);
+	rdbuf = kmalloc(max(blsize, rdsize), M_TEMP, M_WAITOK);
```

### Fix validation (Phase 8) — `fixed`

Same `#0` GENERIC kernel, hot-swapped a single-fix `ntfs.ko` built from the
patched `/usr/src` (`fix_build.log`, cc 8.3, `-Werror`, rc=0; loaded-module
disasm shows the `max()` materialise as `cmovae`). Re-ran the **identical**
chain + crafted image:

| ntfs module | chain result | guest |
|---|---|---|
| **unfixed** (original) | **`uid=0(root)`** (run.chain1/2.log) | down (post-success collateral) |
| **fixed** (`kmalloc(max)`) | **did not acquire root**, `uid=1001` throughout | **UP**, no panic (`fix_run.log`) |

`max(blsize,rdsize)=max(704,744)=744` ⇒ `kmalloc(744)`→768-byte buffer, 744-byte
copy ⇒ **no overflow** ⇒ no socket is ever corrupted ⇒ no `so_port` hijack ⇒ no
shellcode ⇒ no escalation. **fix_status = fixed.** The fix supersedes/matches
the finding markdown's proposal.

(Note: a *separate*, minor ntfs robustness issue exists under extreme malformed-
image lookup churn — after ~60–80 lookups the scan path faults in
`ntfs_ntlookupfile`; it is present with and without the fix, is a DoS not an
escalation, and is out of scope for DF-0785's overflow. It would warrant its own
finding.)

## Files

| file | purpose |
|---|---|
| `craft_img.py`        | hand-crafts a mountable NTFS image; poisons root `$INDEX_ROOT` `ir_size`; now accepts the overflow payload (`ir_size datalen payload`) |
| `ntfs_evil.img`       | original panic-demo image (`ir_size=256`,`va_datalen=896` ⇒ 640-byte overflow) |
| `ntfs_chain.img`      | escalation image (`ir_size=704`,`va_datalen=744` ⇒ 40-byte overflow into the next socket: `so_proto`/`so_port` hijack) — re-craft via `craft_img.py ntfs_chain.img 704 744 <payload>` |
| `chain.c`             | the escalation chain: shellcode embed + socket spray/groom + `getsockopt` trigger |
| `sc.S`                | the kernel shellcode source (assembled → 157 bytes embedded in `chain.c`) |
| `build_chain.sh`/`run_chain.sh` | exact build/run for the chain |
| `harness.c`/`harness_fixed.c` | deterministic overflow transcription (guard-paged) — unfixed SIGSEGV(139), fixed clean(0) |
| `run.chain1.log`/`run.chain2.log` | two fresh-reset `uid=0` reproductions |
| `fix_build.log`/`fix_run.log` | single-fix `ntfs.ko` build + hot-swap re-test (no escalation, guest up) |
| `run.log`/`panic.txt` | original live panic reproduction (`chunk_mark_free:1675` / `BADFREE2`) |
| `fix.diff`            | git-apply-able one-line fix |
| `env.txt`/`manifest.json` | guest env + artifact catalog |
