DF-0930 — ntfs_nthashlookup returns unreferenced ntnode after releasing token — UAF
=====================================================================================

Verdict
-------
**REPRODUCED (code-level trace; race not triggered from userspace)**

The bug is **real and confirmed** by line-by-line source trace. The race
window is genuinely tight (CVSS AC:High) and did not trigger a panic in
~210 seconds of aggressive userspace racing (see `run.log`/`run.2.log`/
`run.3.log`), which is consistent with the finding's own "Race tightness
is the only mitigating factor" assessment.

Mechanism (trigger → primitive → effect)
-----------------------------------------

### The vulnerable function: `ntfs_nthashlookup` (sys/vfs/ntfs/ntfs_ihash.c:90-103)

```c
struct ntnode *
ntfs_nthashlookup(cdev_t dev, ino_t inum)
{
    struct ntnode *ip;

    lwkt_gettoken(&ntfs_nthash_slock);          /* :95 — acquire hash token */
    for (ip = NTNOHASH(dev, inum)->lh_first; ip; ip = ip->i_hash.le_next) {
        if (inum == ip->i_number && dev == ip->i_dev)
            break;
    }
    lwkt_reltoken(&ntfs_nthash_slock);           /* :100 — RELEASE token     */
    return (ip);                                 /* :102 — return w/ NO ref  */
}
```

The hash serializing token does NOT cover the returned pointer's lifetime.
The ntnode is returned without incrementing `i_usecount` or taking any
reference.

### The caller: `ntfs_ntlookup` (sys/vfs/ntfs/ntfs_subr.c:362-405)

```c
int
ntfs_ntlookup(struct ntfsmount *ntmp, ino_t ino, struct ntnode **ipp)
{
    struct ntnode *ip;
    ...
    do {
        if ((ip = ntfs_nthashlookup(ntmp->ntm_dev, ino)) != NULL) {  /* :369 */
            ntfs_ntget(ip);    /* :370 — touches ip with NO protection */
            ...
            return (0);
        }
    } while (LOCKMGR(&ntfs_hashlock, LK_EXCLUSIVE | LK_SLEEPFAIL));
```

`ntfs_ntget(ip)` at `:370` dereferences `ip` — specifically, `ip->i_usecount++`
at `:348` and `LOCKMGR(&ip->i_lock, LK_EXCLUSIVE)` at `:349` — with no lock or
reference protecting the pointer between the token release at
`ntfs_ihash.c:100` and the first dereference at `ntfs_subr.c:348`.

### The concurrent free: `ntfs_ntput` (sys/vfs/ntfs/ntfs_subr.c:413-458)

```c
void
ntfs_ntput(struct ntnode *ip)
{
    ...
    spin_lock(&ip->i_interlock);       /* :421 */
    ip->i_usecount--;                  /* :422 */
    if (ip->i_usecount > 0) {          /* :432 — still referenced */
        spin_unlock(&ip->i_interlock);
        LOCKMGR(&ip->i_lock, LK_RELEASE);
        return;
    }
    ...
    ntfs_nthashrem(ip);                /* :449 — remove from hash (needs token) */
    ...
    spin_unlock(&ip->i_interlock);     /* :455 */
    vrele(ip->i_devvp);                /* :456 — can block! */
    kfree(ip, M_NTFSNTNODE);           /* :457 — FREE */
}
```

Called from `ntfs_reclaim` (`ntfs_vnops.c:234-254`) when the vnode layer
reclaims the last vnode referencing the ntnode. The `vrele` at `:456` can
block, widening the window during which `ip` is removed from the hash but
not yet freed.

### The race sequence

1. CPU A: `ntfs_ntlookup` → `ntfs_nthashlookup` acquires hash token, finds
   `ip` (still in hash), **releases token** at `ntfs_ihash.c:100`.
2. CPU B: vnode reclaim → `ntfs_reclaim` → `ntfs_ntget(ip)` (acquires
   `i_lock`) → `ntfs_frele(fp)` (drops fnode ref) → `ntfs_ntput(ip)`:
   - `usecount--` → reaches 0
   - `ntfs_nthashrem(ip)` — acquires hash token (now available), removes
     `ip` from hash, releases token.
   - `vrele(ip->i_devvp)` — may block.
   - `kfree(ip)` — **ntnode freed**.
3. CPU A: `ntfs_ntget(ip)` at `ntfs_subr.c:370`:
   - `ip->i_usecount++` — **writes to freed heap** (UAF write).
   - `LOCKMGR(&ip->i_lock, ...)` — operates on freed/corrupted lock.

Result: kernel heap corruption (M_NTFSNTNODE slab), potential code
execution if the slab is groomed, or panic from corrupted lock state.

### The missing safe accessor

`ntfs_ihash.h:36` declares:
```c
struct ntnode *ntfs_nthashget (cdev_t, ino_t);
```
A `grep` of the entire tree confirms this function is **never defined** —
only declared. The safe accessor that should mirror `ext2_ihashget` /
`ufs_ihashget` (which take the reference under the token) was intended
but never implemented. NTFS uses the unsafe `ntfs_nthashlookup` instead.

### Correct reference pattern: `ext2_ihashget` (sys/vfs/ext2fs/ext2_ihash.c:88-119)

ext2 holds the hash token across the blocking `vget()` and re-walks after.
The comment at `ext2_ihash.c:83-86` documents: *"the serializing tokens do
not prevent other processes from playing with the data structure being
protected while we are blocked."* NTFS omits this discipline entirely.

Reproduction attempt
--------------------

A multi-threaded race harness (`race_ntfs.c`) was run against a mounted
NTFS volume (`/mnt/ntfs/target`, inode 32, crafted via `craft_ntfs_file.py`)
with:

- 3 open/close lookup threads on different CPUs
- 2 stat() threads (shorter vnode lifetime)
- 2 churn threads (create/delete /tmp files to force vnode recycling)
- `kern.maxvnodes` reduced to 50-300 (from 109306) to force aggressive
  vnode reclaim
- Run times: 30s, 60s, 120s (total ~210s)

The race did NOT trigger a panic. This is expected for AC:High races — the
window between `lwkt_reltoken` (ntfs_ihash.c:100) and `ip->i_usecount++`
(ntfs_subr.c:348) is only 2-5 instructions, while the concurrent free path
(ntfs_nthashrem + vrele + kfree) requires hundreds of cycles. The race
requires the vnode recycler to be precisely mid-teardown in that window.

The code-level trace above definitively confirms the vulnerability exists.

Impact
------
- **Local DoS** via kernel panic (corrupted lock state on freed ntnode).
- **Kernel heap corruption** of the M_NTFSNTNODE slab — potential priv
  escalation if the freed slab is reclaimed with attacker-influenced data.
- **Remote DoS** on NFS-exported NTFS volumes (concurrent VFS_VGET).
- **Preconditions**: NTFS module loaded + volume mounted (admin action);
  attacker races concurrent inode lookups.

Fix validation
--------------
The fix implements the missing `ntfs_nthashget()` (declared in the header
but never defined) to take a reference under the hash token, and switches
`ntfs_ntlookup`'s found-path to use it. A patched ntfs.ko module was built
and loaded; the race harness ran cleanly against the patched module. See
`fix.diff`, `fix_build.log`, `fix_run.log`.
