# DF-0880 — VERDICT

## Verdict: REPRODUCED (heap over-read / CWE-125), FIX VALIDATED

The bug is **real and confirmed** on DragonFly 6.5-DEVELOPMENT `#0`
X86_64_GENERIC (INVARIANTS ON). It is a **heap over-read (CWE-125)** in
`udf_vget()` driven by attacker-controlled `l_ea`/`l_ad` fields read off a
crafted UDF filesystem image. The deterministic on-guest manifestation is a
**kernel page-fault panic** (the 63 KB over-read crosses an unmapped page).
The authored `fix.diff` closes it: validated by rebuilding `udf.ko` and
hot-swapping — the same image then returns `EINVAL` with no panic.

## Mechanism (trigger → primitive → effect)

The vulnerable code is `sys/vfs/udf/udf_vfsops.c` in `udf_vget()`:

```
:514  if ((error = RDSECTOR(devvp, sector, udfmp->bsize, &bp)) != 0) { ... }
      /* RDSECTOR expands to bread(devvp, ..., udfmp->bsize, bp)  (udf.h:87)
         -> bp->b_data holds EXACTLY udfmp->bsize (2048) bytes. */
:520  fe = (struct file_entry *)bp->b_data;
:521  if (udf_checktag(&fe->tag, TAGID_FENTRY)) { ... }          /* tag only */
:527  size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad;
      /* UDF_FENTRY_SIZE=176 (ecma167-udf.h:352); l_ea/l_ad are uint32_t
         read straight off disk (ecma167-udf.h:348-349), NEVER validated
         against bsize anywhere between :527 and :530. */
:528  unode->fentry = kmalloc(size, M_UDFFENTRY, M_WAITOK | M_ZERO);  /* dest OK */
:530  bcopy(bp->b_data, unode->fentry, size);
      /* reads `size` bytes from the 2048-byte SOURCE buffer bp->b_data. */
```

With a crafted root File Entry carrying `l_ea=0`, `l_ad=0xFFFF`:

```
size = 176 + 0 + 65535 = 65711
over-read past bp->b_data = 65711 - 2048 = 63663 bytes (62.2 KB)
```

`bcopy` reads 65711 bytes starting at `bp->b_data` (a 2048-byte buffer-cache
buffer). The read walks 63 KB past the buffer into neighbouring kernel
pages; when it reaches an unmapped page it **page-faults → panic**.

### Why mount succeeds but ls/stat panics

The mount-time read of the root File Entry (`udf_mount`, :372-386) only reads
the sector and checks the descriptor **tag** (`TAGID_FENTRY`) — it does **not**
compute `size` or `bcopy`. So `mount_udf` succeeds on the crafted image. The
over-read fires on the **first access** that needs the root vnode — `ls`/`stat`
on the mountpoint → `udf_root()` (:439-450) → `udf_vget()` (:484) → the
unchecked `bcopy` at :530.

## Reproduction evidence

### 1. Live kernel panic (default GENERIC #0, INVARIANTS ON)

Crafted image `df0880.udf` (root FE `l_ad=0xFFFF`) → `vnconfig` + `mount_udf`
(root) → `ls /mnt` (unprivileged) → **panic**:

```
Fatal trap 12: page fault while in kernel mode
fault virtual address  = 0xfffff800522f7000
fault code             = supervisor read data, page not present   <- READ fault
instruction pointer    = 0x8:0xffffffff80bcaa0a
...
Stopped at      memmove+0x10a:  repe movsq  (%rsi),%es:(%rdi)     <- the bcopy
```

Backtrace (captured with `debug.debugger_on_panic=0`, `debug.trace_on_panic=1`):

```
memmove() at memmove+0x10a      <- bcopy(bp->b_data, unode->fentry, 65711)
udf_root() at udf_root+0x25     <- udf_root calls udf_vget (:450)
```

The fault is explicitly a **"supervisor read data, page not present"** — a
**READ** fault, confirming the heap **over-read** (CWE-125), not a write. The
`repe movsq (%rsi),%es:(%rdi)` is the block-copy reading from `rsi`
(`bp->b_data`) past the page boundary.

### 2. Deterministic harness (no UDF image needed)

`harness.c` transcribes `udf_vget`'s :514/:520/:527/:530 arithmetic verbatim
and performs the identical `bcopy` with the source buffer placed at the end of
a page backed by a `PROT_NONE` guard page — so the over-read faults *exactly*
at the 2048-byte boundary, proving the read length is unbounded by `bsize`:

```
size = UDF_FENTRY_SIZE(176) + l_ea(0) + l_ad(65535) = 65711
over-read past bp->b_data = 65711 - 2048 = 63663 bytes (62.2 KB)
FAULT caught: bcopy of 65711 bytes crossed the 2048-byte source boundary
```

## Impact ceiling (read-only primitive — valid Phase-6 hard blocker)

This is a **read-only** OOB read (CWE-125). The write side
(`unode->fentry = kmalloc(size)`) is correctly sized and in-bounds; only the
**read** from `bp->b_data` overflows. Per the Phase-6 hard-blocker list, a
genuinely read-only primitive has **no escalation chain** to develop — there is
no write/corruption to convert. The realistic impact is:

- **DoS (panic)**: the deterministic outcome — the 63 KB over-read page-faults.
  Triggerable by an **unprivileged user** (`ls`/`stat`) once an admin has
  mounted (or made mountable via `vfs.usermount=1`) a crafted UDF image. The
  mount itself requires root (or a root-created image owned by the attacker —
  an acceptable precondition per the exploit-environment realism test).
- **Info leak (secondary, unreliable)**: if the over-read happens *not* to
  fault (the buffer sits in the middle of a large mapped region), the
  over-read bytes are copied into `unode->fentry->data[]` (the allocation
  descriptor area). This is kernel heap residue, not directly exfiltrated to
  userspace via an obvious path, and the fault is the overwhelming likelihood.

`impact = panic` (the deterministic, reproducible manifestation on the default
GENERIC kernel).

## PoC changes from the seeded scaffolding

Created from scratch (no seeded PoC existed for DF-0880):
- `craft_img.py` — adapted the proven DF-0831 UDF image builder; sets the root
  File Entry (`sector 65`) to `l_ea=0`, `l_ad=0xFFFF` so
  `UDF_FENTRY_SIZE + l_ea + l_ad = 65711 >> bsize=2048`. Directory extents are
  irrelevant (the panic fires in `udf_vget` before `readdir`).
- `harness.c` — deterministic transcription of the `udf_vget` :514/:520/:527/:530
  arithmetic with a poisoned source allocator, proving the 63 KB over-read.
- `run.sh` — guest-side: `vnconfig` + `mount_udf` (root) then `ls` (unprivileged)
  → panic.
- `fix.diff` — `git apply`-able bounds check.

## Fix validation (Phase 8 — single-fix module)

**Approach**: `udf` is a loadable KLD module (`udf.ko`). Applied `fix.diff` to
`/usr/src/sys/vfs/udf/udf_vfsops.c`, rebuilt `udf.ko` (`make` in the module
dir, `rc=0`, no warnings), hot-swapped into `/boot/kernel/udf.ko`, `kldload`.

**Before (unpatched `#0` udf.ko)**: `mount_udf` OK; `ls /mnt` → **Fatal trap 12
page fault in `memmove+0x10a`** (read-fault), backtrace `memmove ← udf_root`.
Guest DOWN.

**After (patched `udf.ko`, same `#0` kernel)**: `mount_udf` OK; `ls /mnt2` →
`Invalid argument` (EINVAL); `stat /mnt2` → EINVAL; **NO PANIC**, guest UP.
`dmesg` shows the fix's check firing:
```
udf_vget: file entry too large (65711 > 2048)
```
This proves the patched code path executes and rejects the oversized entry
*before* the `bcopy`, instead of over-reading.

**Fix verdict**: VALIDATED — clean before/after on the same `#0` kernel; the
patched module eliminates the panic and the over-read.

## The fix (fix.diff)

Adds a bounds check immediately after the `size` computation (:527) and before
the `kmalloc`/`bcopy` (:528/:530), matching the existing error-return style
(`kfree(unode)` + `brelse(bp)` + `return`):

```c
size = UDF_FENTRY_SIZE + fe->l_ea + fe->l_ad;
if (size > udfmp->bsize || size < (int)UDF_FENTRY_SIZE) {
    kprintf("udf_vget: file entry too large (%d > %d)\n", size, udfmp->bsize);
    error = EINVAL;
    brelse(bp);
    kfree(unode, M_UDFNODE);
    return(error);
}
```

The `size < UDF_FENTRY_SIZE` arm also catches the case where
`l_ea + l_ad` (uint32) overflows the `int size` back below the header size.
ECMA-167 [4/14.9] specifies a file entry occupies exactly one block, so
`size <= bsize` is the correct invariant. This matches the finding markdown's
`## Recommended fix` proposal (clamp `size` against `bsize`).
