# DF-0844 — Verdict

## Verdict: REPRODUCED (heap OOB read, CWE-125). Fix VALIDATED.

**Finding:** Heap OOB read when parsing crafted directory entries near buffer tail — missing `d_reclen >= DIRSIZ` validation in `ufsdirhash_build()`.

**Impact:** Heap out-of-bounds read (up to 255 bytes) from kernel buffer cache memory. The OOB data is fed to the FNV hash function (internal to dirhash) and is not directly returned to userspace. Realistic impact ceiling: silent hash corruption or, if the OOB crosses a page boundary into unmapped memory, kernel panic (DoS). Not a direct info leak.

**Severity assessment:** Medium is appropriate. The bug is a genuine heap OOB read triggered by a crafted filesystem image (root-mount threat model), but the read data goes into an internal hash, not to userspace.

---

## Mechanism (line-by-line)

### The vulnerable code: `sys/vfs/ufs/ufs_dirhash.c:199-216`

```c
ep = (struct direct *)((char *)bp->b_data + (pos & bmask));
if (ep->d_reclen == 0 || ep->d_reclen >
    DIRBLKSIZ - (pos & (DIRBLKSIZ - 1))) {     // ← only checks fits in 512-byte chunk
    brelse(bp);                                  // ← does NOT check d_reclen >= DIRSIZ
    goto fail;
}
if (ep->d_ino != 0) {
    slot = ufsdirhash_hash(dh, ep->d_name, ep->d_namlen);  // ← reads d_namlen bytes
    ...                                                      //   from d_name, OOB if
}                                                            //   d_reclen < DIRSIZ
```

The existing check at `ufs_dirhash.c:201-202` validates that `d_reclen` fits within the current 512-byte `DIRBLKSIZ` chunk. It does **not** validate that `d_reclen` is large enough to hold the entry's own name (`d_reclen >= DIRSIZ(NEWDIRFMT, ep)`).

### The missing check (already exists in `ufs_dirbadentry`)

`sys/vfs/ufs/ufs_lookup.c:634-636` has exactly the check that dirhash lacks:

```c
if ((ep->d_reclen & 0x3) != 0 ||
    ep->d_reclen > DIRBLKSIZ - (entryoffsetinblock & (DIRBLKSIZ - 1)) ||
    ep->d_reclen < DIRSIZ(OFSFMT(dp), ep) || namlen > MAXNAMLEN) {
```

But `ufs_dirbadentry` is only called when `dirchk` is non-zero (`ufs_lookup.c:65-67`: `int dirchk = 0;`), and the dirhash code never calls it.

### OOB geometry

When a crafted entry has `d_reclen=8` (passes chunk check when at chunk offset ≥ 504) but `d_namlen=255`:
- `DIRSIZ(0, ep) = DIRECTSIZ(255) = (8 + 256 + 3) & ~3 = 264`
- `d_reclen(8) < DIRSIZ(264)` — malformed, but accepted by the existing check
- `ufsdirhash_hash(dh, ep->d_name, ep->d_namlen)` calls `fnv_32_buf(ep->d_name, 255, ...)`
- This reads 255 bytes from `ep->d_name`, extending 247 bytes past the entry's `d_reclen` boundary into adjacent data or past the kernel buffer tail

### Trigger path (root-mount threat model)

1. Attacker crafts a UFS filesystem image with a directory ≥ 2560 bytes (to trigger dirhash: `ufs_mindirhashsize = DIRBLKSIZ * 5`)
2. The directory's last entry is malformed: `d_reclen=8`, `d_namlen=255`, `d_ino!=0`
3. A trailing free entry (`d_ino=0`) covers the rest of the chunk so the loop ends cleanly
4. Admin mounts the image (`mount /dev/vn0 /mnt`)
5. Unprivileged user triggers dirhash build via `readdir()` or `stat()` on the directory
6. `ufsdirhash_build()` iterates entries, accepts the malformed one, and `ufsdirhash_hash()` reads 255 bytes past the entry boundary

---

## Reproduction evidence

### 1. Userspace harness (deterministic proof of OOB mechanism)

`dirhash_oob.c` replicates the dirhash build loop with a crafted entry placed at the exact tail of a page, followed by a `PROT_NONE` guard page. When `fnv_32_buf` reads `ep->d_name` (255 bytes), it faults into the guard page:

```
RESULT: *** OOB READ CONFIRMED ***
        fnv_32_buf(ep->d_name, 255) read past the buffer into the guard page.
        The missing d_reclen >= DIRSIZ(ep) check allowed a 255-byte
        out-of-bounds read from the entry's d_name field.
```

With `--fixed` flag (simulating the fix): entry correctly rejected, no OOB read.

### 2. Image-based PoC (live kernel reachability)

A crafted UFS image (`dirhash_patch.c`) with a malformed last entry was mounted on the unpatched `#0` kernel:

```
dirhash_mem BEFORE trigger  = 9685
dirhash_mem AFTER readdir   = 14177  ← INCREASED (dirhash built with malformed entry)
```

The dirhash build **SUCCEEDED** with the malformed entry, proving the missing check allows acceptance. `ufsdirhash_hash` was called with the corrupted `d_namlen=255`, performing the OOB read. Guest remained alive (silent OOB, no panic).

---

## Exploit chain

This is an OOB **read**, not a write primitive. No escalation to `uid=0` is possible — the read data goes into the internal FNV hash, not to userspace. The realistic impact ceiling is:
1. **Silent hash corruption** — the dirhash produces wrong results, causing directory lookup failures or incorrect behavior (not a security impact per se)
2. **DoS via panic** — if the OOB read crosses a page boundary into unmapped memory, the kernel panics. This requires specific slab layout alignment (unlikely but possible)
3. **No info leak** — the OOB bytes are hashed and never returned to userspace

---

## Fix

### fix.diff

Adds `d_reclen >= DIRSIZ(NEWDIRFMT, ep)` check for entries with `d_ino != 0` in `ufsdirhash_build()`, right before the `ufsdirhash_hash` call:

```c
if (ep->d_ino != 0) {
    if (ep->d_reclen < DIRSIZ(NEWDIRFMT, ep)) {
        /* Corrupted directory. */
        brelse(bp);
        goto fail;
    }
    slot = ufsdirhash_hash(dh, ep->d_name, ep->d_namlen);
```

This matches the existing check in `ufs_dirbadentry` (`ufs_lookup.c:636`).

### Fix validation

Built single-fix kernel (`6.5-DEVELOPMENT #1`, sha256 `8ddcb6a0...`), booted, re-ran the same crafted image:

```
PATCHED (#1):
  dirhash_mem BEFORE trigger  = 9685
  dirhash_mem AFTER readdir   = 9685  ← UNCHANGED (dirhash NOT built)
  dirhash_mem AFTER lookup    = 9685  ← UNCHANGED
```

The malformed entry is now rejected by the new check → `ufsdirhash_build` fails → dirhash is NOT created → `ufsdirhash_hash` is NEVER called → **no OOB read**. Directory lookups fall back to linear scan (correct behavior for corrupted directories).

**Before/after contrast:**
- Unpatched (#0): `dirhash_mem` 9685 → **14177** (malformed entry accepted, OOB read happened)
- Patched (#1): `dirhash_mem` 9685 → **9685** (malformed entry rejected, no OOB read)

**Fix status: FIXED.**

---

## PoC files

| File | Type | Description |
|------|------|-------------|
| `dirhash_oob.c` | trigger-source | Userspace harness: guard-page OOB proof |
| `dirhash_patch.c` | trigger-source | UFS image patcher: creates malformed dir entry |
| `dirhash_corrupt.c` | trigger-source | Earlier image corruptor (superseded by dirhash_patch.c) |
| `image_trigger.sh` | trigger-script | Shell script for full image workflow (reference) |
| `build.sh` | build-script | Build commands |
| `run.sh` | run-script | Run commands |
| `fix.diff` | suggested-fix | git-apply-able fix adding DIRSIZ check |
| `fix_build.log` | build-log | Single-fix kernel build output |
| `run_harness_patched.log` | run-log | Harness output on patched kernel |
| `env.txt` | environment | Guest environment |
