# DF-0807 — `dirfs_readdir` premature `dp` advancement → heap OOB read + info leak

**Verdict:** REPRODUCED (deterministic harness; dirfs is vkernel-only so no live-boot test is possible — same precedent as DF-0806).

**Impact:** kernel heap info leak (read-only primitive). No escalation path — the bug only **reads** OOB; there is no write, UAF, or type-confusion component.

**Confidence:** certain.

---

## The bug (line-by-line)

`sys/vfs/dirfs/dirfs_vnops.c:1277-1286` — `dirfs_readdir` for-loop:

```c
for (dp = (struct dirent *)buf; bytes > 0 && uio->uio_resid > 0;
    bytes -= _DIRENT_DIRSIZ(dp), dp = dpn) {          /* line 1278 */
    r = vop_write_dirent(&error, uio, dp->d_ino, dp->d_type,
        dp->d_namlen, dp->d_name);                     /* line 1279-1280 */
    if (error || r)
        break;
    dpn = _DIRENT_NEXT(dp);                            /* line 1283 */
    dp = dpn;                                          /* line 1284 — BUG */
    cnt++;
}
```

The C comma operator in the for-increment (line 1278) evaluates left-to-right:

1. `bytes -= _DIRENT_DIRSIZ(dp)` — uses the **current** `dp`
2. `dp = dpn`                       — then advances

But the loop **body** already executed `dp = dpn` on line 1284. So when the
increment's step (1) runs, `dp` is **already** the next entry (the one the
body has not yet processed), not the entry that was just written out. After
the **last** valid entry, the body sets `dp = dpn = buf + bytes`, which (when
the `getdirentries` buffer was filled completely, the common case with
`bufsiz` clamped to 4096 at lines 1248-1249 and ~200+ directory entries) is
also one byte past the `kmalloc(bufsiz)` allocation. Step (1) then derefs
`dp->d_namlen` past the allocation → **heap OOB read**.

Worse: if the (untrusted, OOB) `d_namlen` read is small enough that
`DIRENT_DIRSIZ(dp) < bytes`, `bytes` stays positive and the loop body runs
**one more iteration** with the OOB `dp`. `vop_write_dirent`
(`sys/kern/vfs_subr.c:2559-2582`) then reads `dp->d_ino`, `dp->d_type`,
`dp->d_namlen`, and `dp->d_name` (line 2575: `bcopy(d_name, dp->d_name,
d_namlen)`) from past the buffer and copies them into a fresh dirent that is
`uiomove`'d to the user `readdir` result. That is a **kernel heap info
leak**: attacker-recognizable bytes from the slab chunk / redzone adjacent to
the dirfs readdir buffer surface in userland.

### Why a harness (not a live-boot test)

`dirfs` is **vkernel64-only**: `grep -c dirfs sys/conf/files` ⇒ 0; it is
listed only in `sys/platform/vkernel64/conf/files` (lines 45-47). It is
absent from the running `X86_64_GENERIC` host kernel, and there is no
`dirfs.ko` in `/boot/kernel` (`kldstat -v | grep -c dirfs` ⇒ 0). It cannot
be mounted or triggered on this guest. The deterministic harness is the
accepted proof — same precedent as DF-0806 (`dirfs_readlink` off-by-one).

---

## Reproduction: deterministic harness

`harness.c` transcribes the exact `dirfs_readdir` for-loop with two
allocator styles, exercising 4 buffer sizes (512 / 1024 / 2048 / 4096 bytes,
the last matching the `dirfs_readdir` 4096 clamp at lines 1248-1249). Each
buffer is filled with N valid dirents sized so the buffer is **exactly**
filled (`bytes == bufsiz`), forcing the premature `dp = dpn` to advance
**past** the allocation after the last valid entry.

### Guard-page variant (OOB READ)
The buffer is placed flush against a `PROT_NONE` guard page. After the last
valid entry, the for-increment reads `dp->d_namlen` from the guard page →
`SIGSEGV` (caught via `sigaction` + `siglongjmp`). Across all 4 buffer sizes
the BUGGY transcription faults and the FIXED transcription does not.

### Leak-zone variant (INFO LEAK)
The buffer is followed by a writable "leak zone" pre-filled with a fake
dirent whose `d_ino=0xDEADBEEFCAFEBABE`, `d_type=0xEE`, `d_namlen=7`,
`d_name="HEAP-LE..."`. With `d_namlen=7`, `DIRENT_DIRSIZ(fake)=24 < 32`
(valid entry record size), so `bytes` stays positive after the OOB
increment and the loop body runs one more iteration, copying the OOB bytes
into the user sink. The harness checks the sink for the recognizable
`"HEAP-LE"` prefix → **INFO LEAK CONFIRMED**. Across all 4 buffer sizes the
BUGGY transcription leaks and the FIXED transcription does not.

### Decisive output (from `run.log`, bufsiz=4096 case)
```
[guard-page variant] bufsiz=4096 name_len=8
  filled bytes=4096 (== bufsiz ? YES — dp advances PAST allocation)
  BUGGY loop:  FAULT -> OOB READ CONFIRMED in for-increment `bytes -= DIRSIZ(dp)` (entries=128 bytes_after=32)
  FIXED loop:  no fault (loop terminated cleanly) (entries=128 bytes_after=0)
[leak-zone variant] bufsiz=4096 name_len=8
  filled bytes=4096 (== bufsiz ? YES), leak zone @ 0x8004bb000, fake d_namlen=7 (DIRSIZ=24 < rec=32)
  BUGGY loop:  entries=139 bytes_after=-43944 ; sink has OOB marker ? YES -> INFO LEAK CONFIRMED
    leaked marker at sink offset 4112: 'HEAP-LE'
  FIXED loop:  entries=128 bytes_after=0 ; sink has OOB marker ? no (clean termination)
```

The `bytes_after=0` for the FIXED loop vs `bytes_after=32` (guard) /
`-43944` (leak) for the BUGGY loop proves the bytes accounting is repaired:
the fix makes the increment compute `DIRSIZ` of the just-processed entry and
correctly drives `bytes` to 0 after the last valid entry, so the loop
terminates without ever dereferencing an OOB `dp`.

---

## Impact ceiling

Read-only primitive. The bug gives an attacker (who can mount a `dirfs`
filesystem on a vkernel and trigger `readdir` on a directory with enough
entries to fill the 4096-byte buffer) a kernel-heap OOB **read** and a
bounded info leak into the `readdir` result. There is no write, UAF, or
type-confusion component — no escalation chain exists. The realistic ceiling
is **vkernel heap info leak** (the leak could expose adjacent slab-chunk
contents / pointers, defeating KASLR-equivalent obscurity for a subsequent
attack on the vkernel, but the vkernel itself is the boundary, not the host
kernel). Runtime reachability is vkernel-only.

---

## Fix

`fix.diff` removes line 1284 (`dp = dpn`) from the loop body. The
for-increment's own `dp = dpn` then handles the advancement, and
`bytes -= _DIRENT_DIRSIZ(dp)` correctly computes the size of the entry that
was just written out. This matches the finding markdown's `## Recommended
fix` proposal exactly ("remove `dp=dpn` from the loop body; let the increment
handle advancement").

```diff
--- a/sys/vfs/dirfs/dirfs_vnops.c
+++ b/sys/vfs/dirfs/dirfs_vnops.c
@@ -1281,7 +1281,6 @@
 		if (error || r)
 			break;
 		dpn = _DIRENT_NEXT(dp);
-		dp = dpn;
 		cnt++;
 	}
```

---

## Phase 8 — fix validation

Live boot test is not possible (dirfs is vkernel-only; no vkernel runs on
this guest). Validated to the maximum extent:

1. **`git apply --check`** on a clean `sys/` tree ⇒ RC=0 (host-side).
2. **Compile-neutrality**: `dirfs_vnops.c` compiled PATCHED vs UNPATCHED with
   kernel build flags (`-D_KERNEL`, `-I` paths mapping `<machine/*>` to the
   vkernel64 platform headers). Both produce an **IDENTICAL 6-error set**:
   ```
   /usr/include/cpu/cpufunc.h:269:1: error: static declaration of 'ffs' follows non-static declaration
   /usr/include/cpu/cpufunc.h:277:1: error: static declaration of 'ffsl' follows non-static declaration
   /usr/include/cpu/cpufunc.h:285:1: error: static declaration of 'fls' follows non-static declaration
   /usr/include/cpu/cpufunc.h:293:1: error: static declaration of 'flsl' follows non-static declaration
   /usr/include/cpu/cpufunc.h:301:1: error: static declaration of 'flsll' follows non-static declaration
   /usr/src/sys/sys/ktr.h:47:10: fatal error: opt_ktr.h: No such file or directory
   ```
   These are pre-existing kernel/userland header-include artifacts (the
   kernel build proper generates `opt_ktr.h` via `config` and uses
   `-nostdinc`; my manual flags reproduce neither). The crucial fact is
   **the diff between PATCHED and UNPATCHED is empty** — the 1-line deletion
   introduces zero new compile errors. (Pre-existing dirfs_vnops.c-specific
   `kmalloc`/`kfree`/`M_WAITOK`/`M_ZERO` errors seen by DF-0806 only surface
   after `opt_ktr.h` is provided; the comparison is valid at any depth
   because the fix is a deletion of a self-contained statement.)
3. **Harness FIXED transcription**: across all 4 buffer sizes, the FIXED loop
   terminates cleanly (`bytes_after=0`, no fault, no marker in sink). See
   `run.log` for the full output.

**fix_status: `not_testable`** for live boot (dirfs not in host kernel, no
vkernel on guest) — the fix is validated to the maximum extent possible via
`git apply --check` + compile-neutrality + harness.

---

## Reachability & preconditions (realism)

To trigger on a real vkernel deployment, an attacker must:
1. Run a vkernel (a DragonFly virtual kernel process — `dirfs` is vkernel-only).
2. Mount a `dirfs` filesystem inside the vkernel.
3. `readdir` a directory whose entry count fills the 4096-byte buffer
   completely (≈128+ entries with short names, or fewer with longer names
   that pack tightly).

These are normal operations on a vkernel that uses dirfs. No special
privilege inside the vkernel is required beyond filesystem access. The
impact boundary is the **vkernel** process, not the host kernel.

---

## Files in this evidence pack

| File                 | Purpose                                                |
|----------------------|--------------------------------------------------------|
| `harness.c`          | Deterministic loop transcription (guard-page + leak-zone variants) |
| `build.sh`           | `cc -O2 -Wall -o harness harness.c`                   |
| `run.sh`             | `./harness`                                            |
| `build.log`          | Full compiler output (clean build)                     |
| `run.log`            | Full decisive harness run (all 4 buffer sizes)         |
| `fix.diff`           | `git apply`-able 1-line fix                            |
| `phase8_validate.sh` | Phase 8 compile-neutrality validation script           |
| `fix_run.log`        | Full Phase 8 validation output                         |
| `env.txt`            | Guest environment (uname, cc version, dirfs absence)  |
| `manifest.json`      | Machine-readable artifact catalog                      |
