# DF-0766 — Heap OOB write in NFS READDIR/READDIRPLUS reply XDR null-padding (missing nfsm_clget before pad bytes)

**Status: NOT REPRODUCED — claim is UNREACHABLE (false positive for the security impact).**
**Impact: none.** The missing `nfsm_clget` guard is a genuine latent
defense-in-depth gap, but the claimed heap OOB write cannot occur because the
NFS XDR 4-byte alignment invariant guarantees the entry name never ends exactly
on a 2048-byte mbuf-cluster boundary when padding is needed.

## What the finding claims

In `sys/vfs/nfs/nfs_serv.c`, both `nfsrv_readdir` (READDIR) and
`nfsrv_readdirplus` (READDIRPLUS) finish each directory entry by null-padding
the file name up to an `int32_t` boundary:

```c
/* And null pad to a int32_t boundary */            // nfs_serv.c:3186 / 3524
for (i = 0; i < rem; i++)
    *bp++ = '\0';                                   // <-- claimed OOB write
tl = nfsm_clget(&info, mp1, mp2, bp, be);           // <-- called AFTER, "too late"
```

`rem = nfsm_rndup(nlen) - nlen` is 1..3 when the name length is not a multiple
of 4.  Every *other* write site in the encoder calls the `nfsm_clget` macro
(`nfsm_subs.h:200` → `_nfsm_clget`, `nfsm_subs.c:968`) first, which — when the
write cursor `bp` has reached the cluster end `be` — allocates a fresh
`MCLBYTES` (=2048) cluster and resets `bp`/`be`.  The pad loop does not, so the
finding asserts that if a name fills the cluster exactly (`bp == be` after the
name copy) the pad writes 1..3 NUL bytes one-past the cluster into adjacent
kernel heap.

## Why it does NOT reproduce — the alignment invariant

The encoder writes only XDR 4-byte-aligned units.  Verified on this guest
(`sizes.c` against the real kernel headers):

| quantity | value | mod 4 |
|---|---|---|
| `MCLBYTES` (cluster) | 2048 (`MCLSHIFT`=11) | 0 |
| reply header `H` (RPC reply 28 + postopattr 88 + cookieverf 8) | 124 | 0 |
| READDIR entry header words (`nfs_true`/fileid_hi/fileid_lo/nlen) | 16 | 0 |
| READDIR entry cookie words | 8 | 0 |
| `sizeof(struct flrep)` (READDIRPLUS per-entry payload) | **132** | 0 |
| `NFSX_V3FH` = `sizeof(fhandle_t)` = 8 + 20 (`fid` is `__packed`) | 28 | 0 |

Therefore every term that locates the name is a multiple of 4 except `nlen`
itself.  For entry `i`:

```
name_end_i = H + Σ(prior entry totals) + 16 + nlen_i
           ≡ nlen_i   (mod 4)
```

The pad loop reaches `bp == be` **only** when `name_end_i` falls exactly on a
cluster boundary, i.e. `name_end_i ≡ 0 (mod 2048) ⇒ name_end_i ≡ 0 (mod 4) ⇒
nlen_i ≡ 0 (mod 4) ⇒ rem_i = nfsm_rndup(nlen_i) − nlen_i = 0`.  So whenever
padding is actually needed (`rem > 0`), the name **never** ends on a cluster
boundary, `bp < be` strictly, and the pad bytes always fit inside the current
cluster.  The OOB state is unreachable.  This holds for NFSv2 and v3, and for
both READDIR and READDIRPLUS.

(The name-copy loop can cross a cluster boundary mid-name, but it is correctly
guarded per chunk; after such a crossing `bp` is reset to the start of a fresh
cluster and then advanced only by the remaining ≤255 name bytes, so it ends
far inside the new cluster, not at `be`.)

## Evidence

1. **`reach.c`** — faithful cursor simulation across 2048-byte clusters plus an
   exhaustive + 400 000-trial random brute force over directory layouts, for
   both READDIR and READDIRPLUS.  **Max OOB-state entries found: 0** in every
   variant.  Also prints the invariant proof.  (`build.sh && run.sh`.)
2. **`nfs_pad_oob.c`** — demonstrates that the pad loop is *structurally*
   unguarded (if you forcibly place `bp == be`, the pad writes into a red zone),
   confirming the *code-quality* gap is real even though the state is
   unreachable in the live encoder.
3. **`live_test.sh`** — sets up the guest's NFS server (`rpcbind`/`mountd`/
   `nfsd`), exports `/export`, mounts over loopback, and runs 800 directory
   listings of a 720-file directory whose name lengths are all ≠ 0 (mod 4)
   (`rem ∈ {1,2,3}`).  Run on the **unpatched #0** kernel: no panic, no slab
   corruption, guest stays up.  This corroborates the unreachability proof.
4. **`sizes.c`** — confirms `sizeof(struct flrep)=132 (mod4=0)` and
   `NFSX_V3FH=28` against the actual kernel headers.

## Exploit chain

Not applicable.  No memory corruption is reachable — not even a kernel panic —
so there is no primitive to groom or convert.  The "fixed-content NUL write"
characterization in the finding is moot because the write never happens.

## Defense-in-depth fix (`fix.diff`) — validated

Although the bug is unreachable, the pad loop is the *only* write site in the
encoder that lacks a `nfsm_clget` guard, which is an inconsistency and a latent
robustness hazard (a future change to XDR alignment, header size, or cluster
size would make it live).  `fix.diff` adds the guard before each pad byte in
both READDIR and READDIRPLUS, matching the pattern used everywhere else:

```c
for (i = 0; i < rem; i++) {
    tl = nfsm_clget(&info, mp1, mp2, bp, be);
    *bp++ = '\0';
}
```

Validated on a single-fix kernel (`make -j6 nativekernel`, `#1` build):
`fix.diff` applies cleanly (both hunks), the kernel compiles and boots, and the
NFS READDIR/READDIRPLUS path works with no regression (`live_test.sh`: 800 ls
passes, no panic).  Because the bug is unreachable there is no behavior delta to
measure; the patch is defense-in-depth.  This matches the finding's recommended
fix (add `nfsm_clget` before the pad bytes).

## Reproduce

```sh
# on the guest as maxx (unprivileged):
sh build.sh && sh run.sh          # reachability analysis -> "UNREACHABLE"
cc -o sizes sizes.c && ./sizes    # struct-size verification
# as root (live NFS corroboration):
sh live_test.sh
```

## Suggested severity change

The finding is filed **High** as a heap OOB write.  Verification shows the OOB
is **unreachable**; the realistic impact is **none**.  Recommend downgrading to
**Info** (defense-in-depth / code-quality) and applying `fix.diff` for
consistency and robustness.
