# DF-2826 VERDICT — elf_getfiles() unowned fdrop() → file/vnode lifetime corruption

## Bottom line

**REPRODUCED (certain).** `elf_getfiles()` (sys/kern/kern_checkpoint.c:664-668)
calls `fp_close(fp)` — i.e. `fdrop(fp)` (sys/kern/kern_fp.c:559-562) — on the
*checkpoint file* when `fdalloc()` cannot allocate the descriptor requested by
a restored `ckpt_fileinfo`. `elf_getfiles()` does not own any reference on
`fp`: the reference belongs to `sys_sys_checkpoint()`'s `holdfp()`
(kern_checkpoint.c:756), which is dropped again by `dropfp()` at
kern_checkpoint.c:762. One `fdrop()` too many ⇒ `struct file` refcount
underflow with two deterministic death modes, both observed on the stock
guest kernel:

### Variant A — checkpoint fd >= 3 (`trigger_fd3`)

1. `holdfp(td, fd, FREAD)`: file count = 2 (fd table + holdfp; the holdfp
   reference is tracked by the per-thread fdcache, mode "lent").
2. `elf_getfiles()`'s close-loop (kern_checkpoint.c:611-612) runs
   `kern_close(fd)` for all fds >= 3, which drops the fd-table reference
   (count = 1) and detaches the fdcache entry (fclearcache detaches a lent
   entry without a drop — kern_descrip.c:246-263).
3. The crafted `cfi` (valid fhandle from `getfh("/etc/passwd")`, `cfi_index =
   0x7fffffff`) makes `ckpt_fhtovp()`+`fp_vpopen()` succeed, then
   `fdalloc()` fail (`want >= lim`, kern_descrip.c:1745) ⇒ the buggy
   `fp_close(fp)` takes the count 1 → 0 ⇒ last-reference path:
   `fo_close()` = `vn_close()` = VOP_CLOSE + **vrele(vnode)**, then
   `ffree(fp)` — the checkpoint file and its vnode reference die mid-syscall.
4. `elf_getfiles()` returns `error == 0` (the path never sets `error`) —
   **CKPT_THAW reports success** — and leaks the `tempfp` it had opened.
5. `ckpt_thaw_proc()` continues with the **freed** `fp`:
   `elf_loadphdrs(fp, ...)` (kern_checkpoint.c:258) reads `fp->f_type` /
   `fp->f_data` out of the freed chunk (UAF read that happens to be intact
   in the objcache) and calls `fp_mmap()` → `vm_mmap()` → `vnode_pager_reference()`
   → `vref(vp)` on the vnode whose usecount the premature `vrele()` took to 0:
   **`panic: vref: bad refcnt 00000000 1`** (observed twice, incl. from a
   fresh `with-src` snapshot boot; see panic.txt, baseline_fresh_vref_panic.txt).

### Variant B — checkpoint fd < 3 (`trigger_fd0`)

1. The close-loop does not touch fds 0-2; the fdcache entry for the holdfp
   reference stays tied to fdnode 0 in "lent" mode.
2. The buggy `fp_close(fp)` takes count 2 → 1, stealing the cache-owned
   reference; `dropfp()` then returns the phantom reference to the cache
   (mode 2 → 0, no drop; kern_descrip.c:540-552). The file is now at
   count 1 with *two* logical owners (fd table + phantom cache ref) —
   a silent zero-integrity state.
3. The syscall returns **0** (success-masking). The restored register state
   (this image restores cs=0x2b/ss=0x33/rflags=0x202, rip into a mapped
   LOAD segment) starts executing; this particular restored image dies on
   SIGBUS at the first fetch (see "Anomaly" below), so the process exits.
4. `exit1()` → `fdfree()` → `closef(fd0)` → `fdrop()` on the phantom-owned
   file: **`panic: fdrop: invalid f_count 0`**
   (`fdrop ← closef ← fdfree ← exit1 ← sigexit`; see panic_fd0.txt).

If the process had instead stayed alive, the phantom reference is dropped
the moment the thread's fdcache entry is evicted (kern_descrip.c:233-234
`atomic_add_int(&fp->f_count, -1)`), producing a **dangling fd-table entry**
(freed `struct file` still installed at fd N) — the textbook exploitable
file-UAF primitive (reclaim via any `falloc()` on the same per-CPU objcache
magazine ⇒ fd aliases a foreign file; `close()` of either descriptor
prematurely frees a live victim). That stage was not driven to `uid=0` in
this run (see Blockers).

## Threat model

* Reach: `sys_checkpoint(2 /*CKPT_THAW*/, fd, -1, 0)` (syscall 467), gated by
  `kern.ckptgroup` (default 0 → wheel). Verified: unprivileged `maxx`
  (uid 1001, not in wheel) gets EPERM before parsing; root triggers both
  panics. Any `kern.ckptgroup=-1` host exposes the bug to all local users.
* Attacker input: a crafted checkpoint image — full control of every field;
  the only requirement for the fdrop-steal is one `ckpt_fileinfo` whose
  `cfi_index` fails `fdalloc()` after a *successful* fhandle open.

## PoC artifacts

* `gen.c` — image generator (uses guest headers via `_KERNEL_STRUCTURES` so
  all kernel struct sizes match exactly); `build.sh` drives it.
* `stage2.c` — freestanding restored-program blob (proves register/vmspace
  restore works and would carry the post-restore exploitation stage).
* `trigger_fd3.c` / `trigger_fd0.c` — the two variants.
* `panic.txt` (variant A), `panic_fd0.txt` (variant B), `run.log` — serial
  console captures.

## Fix validation

* `fix.diff` removes the unowned `fp_close(fp)`, sets `error = EBADF`, and
  disposes the owned `tempfp` reference (also fixing the leak); it
  additionally rejects `e_phnum == 0` (DF-2827).
* Patched kernel built in-guest (`make nativekernel KERNCONF=X86_64_GENERIC`),
  installed, rebooted; both triggers re-run: **no panic** — see
  `fix_run.log`. The syscall now returns a clean error and the kernel stays
  up.

## Anomaly (unresolved, does not affect this finding)

A *fully valid* crafted checkpoint (nfiles=0) restores registers and
mappings, but the restored program reliably receives SIGBUS at the first
instruction fetch of the LOAD mapping (fault VA == rip == mapping base;
core shows cs/ss/rflags/rsp perfect and the mapping present). The same
mmap+offset+protection from an ordinary userland program executes fine
(mt.c/mt2.c/mt4.c controls). The mapping is created after
`vmspace_exec()`/`pmap_replacevm()` inside the syscall; `vm_fault` returns
`KERN_PROTECTION_FAILURE` (trap.c:1015 maps that to SIGBUS). This looks
like an independent defect in the ckpt restore path (fresh-vmspace mmap
not executable/faultable), worth a separate look; it truncated our ability
to run in-process exploitation stages.

## Blockers on the uid=0 chain (honest accounting)

The memory-corruption primitive is real and demonstrated (premature
fo_close/vrele/ffree of a live file; UAF reads of the freed chunk inside
the same syscall). Driving it to `uid=0` requires the restored program to
survive long enough to (a) evict the phantom fdcache ref on demand, (b)
reclaim the freed `struct file`, and (c) groom a victim — step (a)/(b) were
implemented in `stage2.c` but never executed because of the SIGBUS anomaly
above (the restored process dies at its first instruction). With the
anomaly understood/fixed, the chain is standard M_FILE objcache reclamation
(per-CPU LIFO) — no hardening on this guest blocks it (no SMAP/SMEP/KASLR).
