# DF-0878 — VERDICT

**Verdict: REPRODUCED** (info leak + local DoS/panic). Fix VALIDATED on a
single-fix kernel.

## The bug (confirmed by source trace)

`cd9660_rrip_loop()` (`sys/vfs/isofs/cd9660/cd9660_rrip.c`) walks the SUSP
(System Use) entries of an ISO9660 directory record.  The loop's only length
guard is a **lower-bound** check; it never verifies that a SUSP entry's
declared `h.length` fits within the record before handing the entry to its
handler:

- `cd9660_rrip.c:509` — `while (pend >= phead + 1)` only ensures room for the
  4-byte `ISO_SUSP_HEADER` (`type[2] + length + version`).
- `cd9660_rrip.c:515` — `result |= ptable->func(phead, ana)` dispatches the
  handler **with no check that `phead + h.length <= pend`**.
- `cd9660_rrip.c:530` — `if (isonum_711(phead->length) < sizeof(*phead)) break;`
  is a **lower-bound** plausibility check only (rejects `length < 4`); it never
  bounds `length` from above, and it runs *after* the dispatch anyway.

The handlers trust the attacker-controlled `h.length`:

- **NM** (`cd9660_rrip_altname`, `cd9660_rrip.c:258`) — `wlen = h.length - 5`;
  `cd9660_rrip.c:276` — `bcopy((char*)p + 5, outbuf, wlen)` copies `h.length-5`
  bytes from `p+5` into the alternate-name buffer (returned to userspace as the
  filename by `getdents`, or by `stat`).  When `p+5+wlen` exceeds `pend`, this
  reads past the directory record's declared end.
- **SL** (`cd9660_rrip_slink`, `cd9660_rrip.c:116`) — `pcompe = (char*)p +
  isonum_711(p->h.length)`; the component loop (`:125-193`) and its
  `bcopy(inbuf,outbuf,wlen)` at `:193` read OOB component bytes into the
  symlink target (returned by `readlink`).
- **TF** (`cd9660_rrip_tstamp`, `cd9660_rrip.c:334`) — reads 7/17-byte
  timestamps from an entry that may be only 5 bytes long.

`ISO_SUSP_HEADER.length` is a single 7.11 byte (`cd9660_rrip.h:43`), so the
OOB read extent is up to **255 bytes** past the SUSP entry header, i.e. up to
~250 bytes past the record boundary.  When the malicious record is placed at
the end of a directory block, the read runs past the 2048-byte directory
buffer into kernel heap.

## Reproduction

### 1. Deterministic harness (`harness.c`)

Transcribes `cd9660_rrip_loop` + the NM handler verbatim with a poisoned
allocator (a 16-byte "record" followed by a sentinel-filled heap tail).  With
`NM.length=255`, the handler copies 250 bytes from `p+5`; **239 bytes** land
past `pend`, all carrying the heap sentinel.  This proves the OOB read extent
unconditionally, independent of any kernel memory layout.

```
[loop] dispatched NM handler: copied 250 bytes; 239 bytes read PAST pend (OOB)
RESULT: 239 bytes read PAST the directory-record boundary (pend)
        239 of those leaked bytes carry the heap sentinel 0x5a
```

### 2. Live in-kernel leak — "early" ISO variant

`craft_iso.py --early` builds a minimal ISO9660 image whose root directory has
a `.` record carrying the required `SP` + `ER IEEE_P1282` entries (RRIP is
only enabled when `cd9660_rrip_offset` finds an `ER`, `cd9660_rrip.c:719`) and
a file `Z` whose System Use area holds a single `NM` entry with the length
byte forged to **255** but only 8 bytes physically present.  The rest of the
block is a `0xCC` sentinel after a record-list terminator.

Root mounts (`vfs.usermount=0`, so root mounts; the trigger is unprivileged),
then an unprivileged `getdents` (`ls`/`stat`) on the mountpoint fires the NM
handler.  The returned filename is **250 bytes**: `'ZZZ' + 0x00 + 246 × 0xCC`
— the 0xCC bytes were placed **past the record boundary** and were read back
into the filename, proving the in-kernel OOB read past `pend`:

```
entry #2: ino=39005 dirsiz=272 type=0 namelen=250
  d_name (250 bytes):
    hex: 5a5a5a00cccccccc...cccc   (246 bytes of leaked sentinel)
```

### 3. Live in-kernel panic — "boundary" ISO variant

`craft_iso.py --boundary` positions the same forged-NM record as the **last**
record of the 2048-byte root directory block (ending exactly at byte 2048).
The NM handler's 250-byte read then starts at block offset 2036 and runs to
2286 — **238 bytes past the 2048-byte directory buffer**.  On the default
`X86_64_GENERIC` kernel this faults into unmapped kernel address space:

```
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff80050ca7000
fault code = supervisor read data, page not present
Stopped at memmove+0x2e: movq 0x8(%rsi),%rdx
```

`memmove` is the `bcopy` backend; the fault is the read inside
`cd9660_rrip_altname:276`.  This is a reliable **local DoS** (panic) via an
unprivileged `getdents` after a root mount of a crafted image.

## Impact ceiling

This is a **read-only OOB primitive**: every affected handler `bcopy`s *from*
the OOB region *into* a legitimate kernel buffer (the filename / symlink
target / inode timestamps).  There is **no attacker-controlled write to an
attacker-chosen kernel address**, so there is **no privilege-escalation chain**
from this bug alone.

Realistic impact:
- **Kernel info leak** — up to ~250 bytes of kernel heap / adjacent buffer-cache
  data per call, disclosed to an unprivileged user through the filename (NM),
  symlink target (SL), or inode timestamps (TF).  Useful for defeating KASLR
  (moot on this guest where KASLR is off) and for heap-layout reconnaissance.
- **Local DoS** — the boundary-variant page fault is a reliable unprivileged
  kernel panic (confirmed on the default GENERIC kernel).
- **Threat model** — root must mount / make mountable a crafted ISO
  (`vfs.usermount=0`); the disclosure/DoS trigger is then any unprivileged
  `ls`/`stat`/`readlink` on the mountpoint.

## The fix

Add the missing **upper-bound** check in `cd9660_rrip_loop`, before the handler
dispatch, so a forged `h.length` cannot drive a read past `pend`:

```c
while (pend >= phead + 1) {
    if ((char *)phead + isonum_711(phead->length) > (char *)pend)
        break;                       /* DF-0878: whole entry must fit */
    if (isonum_711(phead->version) == 1) {
        ...
```

This is minimal, targeted at the root cause, and closes all three handlers
(NM/SL/TF) at once because they are all dispatched through this loop.  Full
`git apply`-able diff: `fix.diff`.

## Fix validation (Phase 8)

Built a single-fix kernel from `/usr/src` with only this diff applied
(`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, rc=0), installed
`kernel.stripped`→`/boot/kernel/kernel`, rebooted to
`6.5-DEVELOPMENT #1` (sha256 `8d4915fb…`).

| variant  | baseline `#0` (unpatched)                         | fixed `#1`                              |
|----------|--------------------------------------------------|-----------------------------------------|
| early    | `namelen=250`, d_name=`5a5a5a00cccc…` (246 B leak)| `namelen=1`, d_name=`5a` (**no leak**)  |
| boundary | `Fatal trap 12` page-fault panic in `memmove`    | all 59 entries returned, **no panic**   |

The fix eliminates both the info leak and the panic.  Baseline reproduces both
(clean before/after).  See `fix_run.log`.

## PoC changes from the seeded scaffold

The PoC was built from scratch (no seeded scaffold existed for this finding):
- `harness.c` — deterministic transcription of the loop + NM handler with a
  poisoned allocator.
- `craft_iso.py` — hand-built ISO9660 image generator (PVD, path table, root
  directory block) with crafted SUSP `SP`+`ER`+`NM` entries; `--early`
  (in-buffer OOB leak) and `--boundary` (past-buffer panic) variants.  Two
  non-obvious requirements discovered during verification: (1) RRIP requires an
  `ER IEEE_P1282` entry in the `.` record (`cd9660_rrip.c:719` insists on it),
  not just `SP`; (2) the boundary record must end exactly at byte 2048 with no
  zero-gap or `readdir` skips it.
- `dumpents.c` — direct `getdents` dumper that hex-dumps each `d_name` so the
  leaked/OOB bytes are visible (DragonFly `struct dirent` uses `_DIRENT_DIRSIZ`,
  no `d_reclen`).
