# DF-0831 — VERDICT

**Status: REPRODUCED**  ·  **Impact: panic (kernel heap OOB write, DoS)**  ·  **Confidence: certain**
**Fix: VALIDATED** (single-line clamp; patched `udf.ko` survives the PoC cleanly)

## The bug (root cause, line-by-line)

`udf_getfid()` in `sys/vfs/udf/udf_vnops.c` iterates File Identifier
Descriptors (FIDs) of a UDF directory.  The directory data is read one
*extent* at a time into `ds->data` with `ds->size` = bytes available in the
current extent, while `ds->fsize` = the directory's total `inf_len`
(`udf_vnops.c:673`), which can span multiple extents.

The four lines that combine into a catastrophic heap write:

1. **`udf_vnops.c:605`** — after a non-fragmented FID, the cursor is advanced
   with **4-byte alignment**:
   ```c
   ds->off += (total_fid_size + 3) & ~0x03;
   ```
   When `total_fid_size` is not a multiple of 4, this rounds *up* by 1–3
   bytes.  The fragmentation guard at **`:539`–`:540`** only requires
   `ds->off + total_fid_size <= ds->size`, so the aligned advance can push
   `ds->off` up to **3 bytes past `ds->size`**.

2. **`udf_vnops.c:505`** — the next call's end-of-directory test compares
   against `ds->fsize`, **not** `ds->size`:
   ```c
   if (ds->offset + ds->off >= ds->fsize) { ... return NULL; }
   ```
   For a **multi-extent** directory `fsize > size`, so a 1–3 byte overshoot
   does **not** terminate the loop.  (A single-extent directory has
   `fsize == size`, which is why the bug needs ≥2 extents — see "Reachability".)

3. **`udf_vnops.c:543`** — the (now mis-aligned) cursor enters the fragmented-FID
   branch and computes the fragment size:
   ```c
   frag_size = ds->size - ds->off;        /* int : 81 - 84 == -3 */
   ```

4. **`udf_vnops.c:544` and `:555`** — the guard is a **signed** compare and
   the length is passed to `bcopy` whose third argument is `size_t`:
   ```c
   if (frag_size >= ds->udfmp->bsize) { ... }   /* -3 >= 2048  -> FALSE -> BYPASS */
   ...
   ds->buf = kmalloc(ds->udfmp->bsize, M_UDFFID, M_WAITOK | M_ZERO);
   bcopy(fid, ds->buf, frag_size);              /* int(-3) -> size_t 0xFFFFFFFFFFFFFFFD */
   ```
   The negative `int` sign-extends to ≈ 16 EB on 64-bit → an **unbounded
   kernel heap write** out of the freshly `kmalloc(2048)`'d `ds->buf`.

## Reproduction (default GENERIC kernel, INVARIANTS ON)

A minimal but valid UDF image (`craft_img.py`) is built whose **root
directory has two extents**:

| extent | bytes | contents |
|--------|-------|----------|
| 0      | 81    | FID_A (parent, `l_fi=0`) at off 0 (size 38, aligned 40); FID_B (OSTA-8bit name `"AB"`, `l_fi=3`) at off 40 (size 41, aligned 44) |
| 1      | 40    | FID_C (terminal, name `"X"`, `l_fi=2`) — placed at *block offset 81* to satisfy the `offset % bsize` data-pointer quirk in `udf_readatoffset` (`:1057`) |

With `ds->size = 81` for extent 0:
* FID_B passes the guard (`40 + 41 == 81 <= 81`), takes the non-fragmented
  branch, and `:605` advances `ds->off` to `40 + 44 == 84` → **overshoots
  `ds->size` by 3**.
* Next `udf_getfid()` call: `:505` `0 + 84 < 121` → not end-of-dir;
  `:539` `84 + 38 > 81` → fragmented branch; `frag_size = 81 - 84 = -3`;
  signed guard bypassed; `bcopy(..., (size_t)-3)` → **page fault**.

Trigger (root mounts the image; the readdir is unprivileged):

```
vnconfig -c vn0 df0831.udf
mount_udf -o ro /dev/vn0 /mnt
su maxx -c 'ls /mnt'      # -> kernel panic
```

**Panic (5 reproductions, varying fault address — a real unbounded read):**
```
panic: vm_fault: fault on stack guard, addr: 0xfffff80118274000
--- trap 000000000000000c, rip = ffffffff80bcab4f ---
memmove() at memmove+0x24f 0xffffffff80bcab4f          <- bcopy() backend
udf_readdir() at udf_readdir+0x138 0xffffffff82602298  <- caller
```
`memmove+0x24f` is `repe movsq (%rsi),%es:(%rdi)` — the bulk copy faulting on
the source running off the end because the length is `0xFFFFFFFFFFFFFFFD`.

## Deterministic harness

`harness.c` transcribes the `udf_getfid()` arithmetic verbatim (the
`:605` alignment, the `:543` negative `frag_size`, the `:544` signed-guard
bypass, the `:555` int→`size_t` `bcopy`) with a poison allocator (one RW page
followed by a `PROT_NONE` guard page).  Output:

```
udf_getfid() call #2: aligned advance -> ds->off=84  *** OVERSHOOT by 3 (ds->size=81) ***
udf_getfid() call #3: frag_size(int) = -3
                     (size_t)frag_size = 0xfffffffffffffffd  (18446744073709551613 bytes)
                     signed check `frag_size>=bsize` -> BYPASSED
bcopy overrun into poison region -> FAULT caught (crossed the buffer boundary)
```

## Exploit-chain assessment (why impact = panic, not uid0)

The primitive is a **genuine heap OOB write** (CWE-787), but its *magnitude*
is the whole story: the bad length is always `size_t(-1|-2|-3)` ≈ 16 EB —
it is **not attacker-tunable to a small, controlled value** (`frag_size` is
fixed by the alignment overshoot of at most 3 bytes).  Consequently:

* The `repe movsq` copy loop in `bcopy`/`memmove` runs straight off the end
  of the `kmalloc(2048)` destination (and the bp-buffer source) and
  **page-faults on the first unmapped page** — within a page or two — before
  any code path can dereference a corrupted victim object.  The fault is in
  the copy itself, not in a later consumer, so there is no window in which a
  corrupted function-pointer / `ucred *` / refcount gets *called* or
  *dereferenced*.
* There is no way through this bug to produce a *precise, bounded* overwrite
  of a chosen slab object; the sign-extended length is intrinsically maximal,
  so slab grooming cannot convert it into control-flow hijack on the default
  GENERIC kernel (and the same page-fault happens on an INVARIANTS-OFF
  kernel too — INVARIANTS/KASSERT are not even reached).

This is a valid stop for the escalation phase: the primitive's nature
(maximal-length, immediately-faulting copy) cannot be shaped into a
controlled write that survives long enough to be leveraged.  The realistic
impact ceiling is therefore **local kernel panic / DoS from a crafted
filesystem image** (mount requires root; the triggering `readdir` is
unprivileged — the standard "admin mounted attacker media" model).  No
`uid=0` is claimed.

## Reachability note (single-extent dirs are safe)

The bug requires a multi-extent directory so that the `:505` `fsize`-based
end-of-dir test does not catch the 1–3 byte overshoot (a single extent has
`fsize == ds->size`, so `:505` terminates immediately).  `mkudffs`-style
images typically produce single-extent directories, which is why this needs a
crafted image — but multi-extent directories are entirely legal UDF (large
dirs, certain writer layouts), so the path is genuinely reachable, not dead
code.

## The fix (`fix.diff`)

Minimal, targeted at the root cause — clamp the negative `frag_size` to 0
*before* it reaches the int→`size_t` `bcopy` length promotion.  When
`frag_size == 0` the subsequent code reads the entire FID from the next
extent (`:566`–`:593`), which is the semantically-correct behavior for a FID
that begins in the next extent (the overshoot bytes were the previous FID's
alignment padding):

```c
frag_size = ds->size - ds->off;
if (frag_size < 0)        /* <- added: alignment overshoot; FID is in next extent */
    frag_size = 0;
if (frag_size >= ds->udfmp->bsize) { ... }
```

## Fix validation (Phase 8)

`fix.diff` applied to `/usr/src`, `udf.ko` rebuilt with
`make SYSDIR=/usr/src/sys KERNCONF=X86_64_GENERIC`, swapped into
`/boot/kernel/udf.ko`, `kldload`ed, and the **same PoC** re-run:

| | unpatched `#0` GENERIC | patched `udf.ko` |
|---|---|---|
| `mount_udf` | OK | OK |
| `ls /mnt` (getdents) | **panic** `memmove+0x24f ← udf_readdir` (guest dead) | **4 dirents** (`.`, `..`, `AB`, `X`), errno 0, **no panic**, guest up |

`git apply --check` passes; the module compiles clean (rc=0); before/after is
a clean panic→no-panic.  `fix_status: fixed`.

(`ls -l` reports "Cannot allocate memory" when *stat*-ing the `AB`/`X` entries
because those file inodes (`lb_num` 4/5) have no File Entry in this minimal
image — `udf_vget` returns ENOMEM.  This is a property of the tiny test image,
not the readdir path, which returns cleanly.)
