# DF-0768 — Verdict

**nfs_readdirplusrpc_uio signed-overflow in `nfsm_rndup(i)` corrupts XDR cursor → wild-pointer read → kernel panic (local DoS)**

## Verdict: REPRODUCED (panic / local unprivileged DoS). Fix authored, built, and VALIDATED.

The finding's core claim — that a server-controlled `i` in the
`nfs_readdirplusrpc_uio()` "else" (attrflag==0) branch can drive
`nfsm_rndup(i)` into a signed overflow that corrupts the XDR decode cursor
into a wild kernel pointer — is **real and exploitable by a malicious NFSv3
server to panic any local client that reads the share.** Fix authored, built
into a single-fix kernel, and validated before/after.

## Mechanism (trigger → primitive → effect)

The buggy branch lives at `sys/vfs/nfs/nfs_vnops.c:2924-2928`:

```c
} else {
    /* Just skip over the file handle */
    NULLOUT(tl = nfsm_dissect(&info, NFSX_UNSIGNED));
    i = fxdr_unsigned(int, *tl);              /* server-controlled int     */
    ERROROUT(nfsm_adv(&info, nfsm_rndup(i))); /* overflows for i>=0x7FFFFFFD */
}
```

The chain, every hop cited:

1. **Attacker controls `i`.** `i = fxdr_unsigned(int, *tl)` where `*tl` is the
   next 4 bytes of the READDIRPLUS reply (`sys/vfs/nfs/xdr_subs.h:52` —
   `fxdr_unsigned(t,v) = (t)ntohl((int32_t)(v))`). A malicious server places
   `0x7FFFFFFD` here.

2. **`nfsm_rndup` overflows.** `nfsm_rndup(a) = ((a)+3) & ~3`
   (`sys/vfs/nfs/nfsm_subs.h:203`). For `a = 0x7FFFFFFD`:
   `(0x7FFFFFFD + 3) & ~3 = 0x80000000 & ~3 = 0x80000000 = INT_MIN`
   (signed overflow → UB; in practice wraps to `-2147483648`).

3. **`nfsm_adv` treats the negative length as "fits in cluster" and corrupts
   the cursor.** `nfsm_adv` (`sys/vfs/nfs/nfsm_subs.c:911`):
   ```c
   n = mtod(info->md, caddr_t) + info->md->m_len - info->dpos;  /* small +  */
   if (n >= len) {        /* n(+) , len = INT_MIN  =>  TRUE            */
       info->dpos += len; /* dpos += INT_MIN  =>  ~2 GiB BELOW real data */
       error = 0;
   }
   ```
   `info->dpos` is now a wild kernel pointer ~2 GiB below the real mbuf data.
   No error is returned.

4. **The very next `nfsm_dissect` reads from the wild pointer.** Back in the
   caller (`nfs_vnops.c:2930`): `NULLOUT(tl = nfsm_dissect(&info,
   NFSX_UNSIGNED))`. In `nfsm_dissect` (`nfsm_subs.c:287`):
   ```c
   n = mtod(info->md, caddr_t) + info->md->m_len - info->dpos;
   /* = (mbuf_end) - (real_dpos - 2GiB) = R + 0x80000000,
      truncated to int => NEGATIVE, so "bytes <= n" is FALSE */
   if (bytes <= n) { ... } else {
       error = nfsm_disct(&info->md, &info->dpos, bytes, n, &cp2);
   ```
   So it routes to `nfsm_disct` (`nfsm_subs.c:1313`) with a **negative `left`
   (= n)**.

5. **`nfsm_disct` page-faults on the wild source.** Because the malicious
   reply is padded to span ≥2 mbuf clusters, `mp->m_next != NULL`, so
   `nfsm_disct` takes the mbuf pull-up branch:
   ```c
   mp->m_len -= left;            /* left<0 => m_len grows huge          */
   ...
   bcopy(*dposp, p, left);       /* (size_t)left ~= 2^64 => copies
                                     forever from the WILD *dposp        */
   ```
   The `bcopy` (= `memmove`) reads 8-byte words from the corrupted `dpos`
   (`0xfffff7ff...` / `0xfffff800...`, 2 GiB below the real mbuf) and
   page-faults on the first unmapped page.

### Observed panic (unpatched `6.5-DEVELOPMENT #0`, 3/3 runs)

```
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff7ff8f7a3ff8        (≈2 GiB below real mbuf data)
fault code             = supervisor read data, page not present
instruction pointer    = 0x8:0xffffffff80bcab4f
current process        = 870   (the ls/getdents driven by maxx)
Stopped at  memmove+0x24f:  repe movsq  (%rsi),%es:(%rdi)
db>
```
(A 4th run tripped a downstream `panic: assertion "obj != NULL" failed in
vm_object_hold_shared` from the page-fault handler walking into the wild
address — same root `memmove` wild read, different downstream KASSERT. Both
are this bug.)

## Trigger / threat model

- **Pre-condition (realistic, set up by root):** an admin has mounted an NFS
  share with `-o rdirplus` (the standard NFSv3 performance option that selects
  the READDIRPLUS proc-17 path in `nfs_doio`, `sys/vfs/nfs/nfs_bio.c:1137`).
  The server is malicious/compromised (AUTH_SYS is cleartext; an on-path
  attacker or a owned server can craft any reply).
- **Unprivileged trigger:** any local user issues a single `getdents` on the
  mount (`ls /mnt`). No privilege, no special device, no setuid helper.
- **Impact ceiling:** kernel panic / local DoS. The wild pointer is **read**
  by `bcopy` (`memmove`), so there is **no attacker-controlled write
  primitive** and thus **no escalation path to `uid=0`**. (Per the run
  instructions, an OOB-read/wild-pointer panic from a server reply is
  characterised, not escalated.) Note the `mp->m_len -= left` does corrupt
  one mbuf's length field to a huge value, but that mbuf is on the ephemeral
  NFS-reply chain being torn down; no durable cross-object heap corruption was
  demonstrated and none is needed for the DoS.

## Why a naive single-cluster reply does NOT panic (and how the PoC forces it)

A small READDIRPLUS reply that fits in one mbuf cluster (MCLBYTES=2048) does
**not** panic: `nfsm_disct` sees `mp->m_next == NULL` and returns `EBADRPC`
(clean error). The corruption is silent in that case. To reach the
page-faulting pull-up branch, the PoC pads the reply to ~8 KiB so it spans ≥2
clusters; the corruption point (offset ~156) then sits in cluster 1, whose
`m_next` is non-NULL, so `nfsm_disct` takes the `bcopy(corrupted_dpos, fresh,
huge)` path and faults. (A real malicious server simply sends a large reply;
no client cooperation is needed.)

## Fix (`fix.diff`)

Bound the server-controlled handle length `i` to `NFSX_V3FHMAX` (= 64,
`sys/vfs/nfs/nfsproto.h:115`) **before** `nfsm_rndup(i)`, mirroring the
bounds check `nfsm_getfh` already applies (`nfsm_subs.c:453`). This makes the
signed overflow unreachable (max `nfsm_rndup(64) = 64`, no overflow) while
preserving the legitimate `i == 0` case (post_op_fh3 `handle_follows == 0`,
"no handle"). An out-of-range `i` returns `EBADRPC`.

```c
i = fxdr_unsigned(int, *tl);
if (i < 0 || i > NFSX_V3FHMAX) {
    error = EBADRPC;
    m_freem(info.mrep);
    info.mrep = NULL;
    goto nfsmout;
}
ERROROUT(nfsm_adv(&info, nfsm_rndup(i)));
```

This **matches the finding's primary recommendation** ("bound i to
NFSX_V3FHMAX"). It does not attempt the larger semantic rewrite ("parse
post_op_fh3 handle_follows properly") — the branch's pre-existing
handle_follows-as-length misparse for honest servers is a separate,
non-security issue and is left untouched (the `i == 0` / `i ∈ [1,64]` cases
behave exactly as before).

## Fix validation (Phase 8)

| kernel | build | same PoC result |
|---|---|---|
| `6.5-DEVELOPMENT #0` (unpatched baseline) | — | **panic** — Fatal trap 12, `memmove+0x24f`, fault va `0xfffff7ff8f7a3ff8`; guest DOWN (3/3 runs) |
| `6.5-DEVELOPMENT #1` (single-fix, sha256 `458d4b05…`) | `make -j6 nativekernel` rc=0 | **no panic** — `ls` returns RC=0, guest UP, maxx alive (4/4 runs) |

The fix closes the bug: the identical malicious READDIRPLUS reply that
deterministically panics the unpatched kernel is cleanly rejected (the
out-of-range `i` → `EBADRPC`) on the single-fix kernel, with no panic and a
live guest.

## PoC changes from the seeded scaffold

There was no pre-existing PoC scaffold for DF-0768 in `findings/poc/DF-0768/`
(finding was `status=new`, unverified). This run authored the complete
evidence pack from scratch, adapting the working malicious-NFSv3-server
pattern from DF-0767:

- `nfs_mal_server.c` — malicious NFSv3 server stub (rpcbind + MOUNTv3 + NFSv3
  on loopback). The READDIRPLUS (proc 17) handler emits the crafted entry
  (`attrflag=0` then `i=0x7FFFFFFD`) **plus ~8 KiB of trailing padding** to
  force a multi-mbuf-cluster reply so the page-faulting `nfsm_disct` pull-up
  branch is reached.
- `trigger.sh` — the unprivileged `ls -f /mnt` (getdents) driven as `maxx`.
- `run.sh` — full harness (build as maxx, start server as root, mount with
  `-o rdirplus`, fire maxx trigger).
- `build.sh` — `cc -O2 -Wall -o nfs_mal_server nfs_mal_server.c`.
