# DF-3156 VERDICT — reproduced, uid0

## Bottom line

**reproduced / uid0_privesc / certain.** A crafted Rock Ridge ISO makes
`cd9660_rrip_defname()` write up to 183 fully attacker-controlled bytes at
`output_buffer + 255` — past a 255-byte kernel-stack array in
`cd9660_lookup()` and past the dirent name area of the kmalloc'd
`struct isoreaddir` in `cd9660_readdir()`. The heap variant was turned into
an arbitrary kernel write (fake `struct uio`), then into a hijack of
`sysent[71].sy_call`, then into ring0 execution of a userland stub that
zeroes the calling process's credentials — **`nobody` ends with
`uid=0 euid=0` and reads a mode-0400 root file**, on the stock INVARIANTS
kernel #0, from a fresh `vm.sh reset`. The two-line `fix.diff` was built as
kernel #1 and re-tested: both triggers degrade to clean fallback names, no
panic, no escalation.

## How it works (path:line)

1. **Dangling chain.** `cd9660_rrip_altname`
   (sys/vfs/isofs/cd9660/cd9660_rrip.c:226-284) bcopy()s every NM fragment
   and advances `ana->outbuf` (:276-277). A fragment with
   FLAGS&ISO_SUSP_CFLAG_CONTINUE (0x01) returns 0 *without* setting the
   `ISO_SUSP_ALTNAME` result bit (:279-283) — the name is "still coming".
   The craft places such an NM as the final field of a CE continuation
   area (`craft_exploit.py`): after it the walk simply runs out of fields
   (`pend` reached, `iso_ce_len == 0` → loop breaks at :544-545).
2. **Fallback at the stale pointer.** Because `result` lacks
   `ISO_SUSP_ALTNAME`, the default-value loop (:581-584) calls
   `cd9660_rrip_defname`, which `isofntrans()`es the record's ISO name at
   `ana->outbuf` = `base + 255` (:289-292) — OOB. Budget arithmetic:
   `reclen(255) = 33 + name_len + NM1(10) + CE(28)` → name_len ≤ 183;
   chain length N = 5 + 250 = 255 (the `*outlen > maxlen=NAME_MAX` check
   caps N at 255 — both fragments individually legal).
3. **Stack sink.** `cd9660_lookup` (cd9660_lookup.c:109
   `char altname[NAME_MAX]`) — 183 controlled bytes from base+255; measured
   saved RBP at OOB+22, saved RIP at OOB+30:
   `frame pointer = 0x444342415a595857`, `Stopped at cd9660_lookup+0x461:
   ret` (panic_lstat.txt) — direct return-address control.
4. **Heap sink → arbitrary write.** In `cd9660_readdir`
   (cd9660_vnops.c:542) the buffer is `idp->current.de.d_name`; the write
   lands on `struct isoreaddir` tail fields (d_name ends at idp+816):
   `currooff` (name[17..24)), **`uio` (name[25..32))**, `uio_off`,
   `eofflag`, `cookies` (name[53..60) = 0), `ncookies`. Naive corruption
   panics in `iso_uiodir+0x25` (panic_dents_serial.log); pointing
   `idp->uio` at a fake `struct uio`/`iovec` in userland (no SMAP) turns
   `iso_uiodir()`'s `uiomove(dp, _DIRENT_DIRSIZ(dp), idp->uio)` into
   `bcopy(dirent, <any kernel address>, 152)` (`uio_segflg=UIO_SYSSPACE`,
   `uio_rw=UIO_READ` — no td KASSERT on that path, kern_subr.c:105-108,
   :138-143). `curroff` set huge exits the readdir loop cleanly; cookies
   NULL avoids the cookie free; the syscall returns normally.
5. **sysent hijack → ring0 → uid0.** The copied dirent = 16-byte header +
   the NM-chain bytes (both attacker data; the *ISO name* only supplies
   the OOB tail smash). Target `&sysent[71]-16`: header lands on dead
   `sysent[70]` (sstk), chain byte 0..8 → `sysent[71].{sy_narg=0,
   sy_rsize=4}`, bytes 8..16 → `sy_call` = ring0 stub at 0x20000040,
   bytes 24..128 restore `sysent[72..77]` (vadvise/munmap/mprotect/
   madvise/vhangup/vlimit) to their exact original table values so every
   live syscall (incl. sbrk/mmap used by exec) keeps working. `syscall(71)`
   executes the stub in ring0 (no SMEP):
   `%gs:0 → gd_curthread(+8) → td_proc(+168) → p_ucred(+16) →
   cr_uid(+64)/cr_ruid(+160)/cr_svuid(+164) = 0, cr_ngroups(+68)=1,
   cr_caps(+176)=~0`; returns 0 through the intact syscall frame.
   Result (run.log): `uid after: 0 euid: 0` + contents of
   `/root/df3156_rootproof` (0400 root).

## Why this is new (not DF-3146 / DF-0878 / DF-0879)

* DF-3146: negative `wlen = length - 5` underflow → u_short wrap → 64KB
  rewind. Here every fragment length is ≥ 5 and every accumulation passes
  the maxlen check; the write offset is a *positive* legitimate chain
  length and the sink is the *fallback* writer, not `altname`'s bcopy.
* DF-0878: loop-level entry-length upper-bound OOB *reads* (handlers
  reading past short entries). This is an OOB *write* caused by walk
  *termination semantics*, and my entry lengths are honest.
* DF-0879: unbounded CE chains / infinite loop. Two CE hops only.

## Reproduction matrix (guest 6.5-DEVELOPMENT, x86_64, securelevel -1)

| run | kernel | trigger | result |
|---|---|---|---|
| run.log | #0 stock | `exploit` as nobody | **uid after: 0 euid: 0**, root file read, rc=0, guest up |
| panic_lstat.txt / panic_lstat_serial.log | #0 stock | `trigger lstat` as nobody | Fatal trap 9, controlled rbp/rip, `cd9660_lookup+0x461: ret` |
| panic_dents_serial.log | #0 stock | `trigger dents` as nobody | Fatal trap 9 in `iso_uiodir+0x25` (corrupted `idp->uio`) |
| fix_run_exploit.log | #1 patched | `exploit` as nobody | poison entry ships as `namlen=131 name=A` (fallback at base); `syscall(71)` → SIGSYS from the stock nosys slot; uid stays 65534 |
| fix_run_exploit.log | #1 patched | `trigger lstat`/`dents` | SURVIVED, clean pattern fallback name, guest up |

Fix kernel: `patch -p1 < fix.diff`, `make -j8 nativekernel`,
`make installkernel`, reboot → `#1: Mon Sep  7 07:47:17 UTC 2026`.

## PoC fixes vs the seed

No seed existed (new finding). Iterations that mattered: (1) even-length
ISO names need the pad byte before the SUSP area or the walker starts one
byte into the first field (cd9660_rrip.c:484-486); (2) the dirent that the
hijacked uiomove copies is built from the NM-chain bytes, not the ISO name
(the ISO name is consumed by the OOB tail smash); (3) the record's first
name byte must not be 0/1 — `cd9660_rrip_getname` (:652-657) treats such
records as "."/"..", skips the NM table row and calls defname at the base
(no OOB); (4) `d_type`/`d_unused2` of the dirent header are stale heap
bytes, so the sysent alignment must keep the 16-byte header on dead fields.

## Recommended fix

`fix.diff` (in this pack): rewind `ana->outbuf -= *ana->outlen;
*ana->outlen = 0;` at the top of `cd9660_rrip_defname()` — the fallback
always rebuilds the name at the caller's buffer base; the deltas are 0 on
every legitimate path that reaches it. Defense-in-depth (not required for
correctness): have `cd9660_rrip_loop` reset the output state whenever the
walk terminates with `ISO_SUSP_ALTNAME` still pending.

## Interactive root shell (2026-10-16 maintenance pass, run_shell.log)

The exploit now has a `--shell` mode (or `DF3156_SHELL=1`): after the same
credential-zeroing `syscall(71)` and the rootproof read it
`execl("/bin/sh","sh")` into a real interactive `#` shell — captured under
`script(1)` on a pty in `run_shell.log`/`run_shell.raw`: tty-echoed commands,
`id` → `uid=0(root) gid=0(wheel) groups=0(wheel)` *inside the shell*, rootproof
re-read, `touch /tmp/DF3156_OWNED` landing `0:0`-owned (verified from the ssh
root context after `exit`), `rc=0`, guest stable. Two latent PoC defects were
fixed to make the ending work: (1) the ring0 stub wrote `cr_caps = ~0`, but
`cr_caps` is a *restriction* bitmap (sys/sys/caps.h: "negatives, i.e.
restrictions") — `~0` is maximally restricted and made every post-surgery
`execve` fail EACCES (the original run.log's exec'd `id` never printed for
exactly this reason); the stub now writes `0` (unrestricted), after which
`setgid(0)/setuid(0)` also run and exec works (run.2.log now shows the full
`uid=0(root) ... groups=0(wheel)` `id` output in default mode too). (2) the
kernel constants are build-specific: on the current `with-src` snapshot
kernel (a later fix-validation rebuild, `sysent=0xffffffff810cb880`) the
write lands 0x3f68 below the real sysent, the syscall(71) probe SIGSYSes, and
the misdirected 152-byte write corrupts unrelated static data — reproducibly
panicking at exit (`funsetfd_locked+0x28`, type 12); on the stock
clean-install kernel (`sysent=0xffffffff810c7900`) every constant matches and
the chain works first-try. Reproduce on the clean-install snapshot
(`vm.sh reset`, not `with-src`) and re-derive constants from
`nm /boot/kernel/kernel` when the kernel changes. One exploit run per fresh
boot+mount remains the reliable pattern: a repeat run risks the OOB spill
(the 131-byte name writes 66 bytes past the 880-byte `isoreaddir` into an
adjacent kmalloc slot) hitting a live neighbor — observed once as a
`funsetfd_locked` trap on process exit. The interactive-shell ending changes
nothing about the finding or its fix: it is a userspace epilogue after the
same verified primitive, so the validated `fix.diff` kernel stops it at
stage 1 identically (the Sep 7 fix kernel #1 no longer exists on the current
guest image — its snapshot was replaced by the with-src rebuild — so no new
fix-kernel contrast run was made this pass; the original fix validation in
fix_run_exploit.log stands).

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
