# DF-2798 — VERDICT

**Status: reproduced** (kernel panic on stock INVARIANTS guest + the full
silent-corruption chain demonstrated standalone under production-kernel
semantics). **Impact: panic** on the audit guest (INVARIANTS); on production
(non-INVARIANTS) kernels the demonstrated chain is *silent meta-bit
poisoning → out-of-domain block numbers handed to the caller* — for the
vm_contig consumer that is a `PHYS_TO_VM_PAGE` beyond `vm_page_array`, i.e. a
wild `vm_page_t` for a driver. No unprivileged reachability in-tree (caller
bug amplification); no leak; class = DF-2789 (removed-terminator discipline).

## How reproduced

### 1. Kernel mode (stock INVARIANTS guest, fresh `vm.sh reset with-src`)

KLD `kld_aoobfree/aoobfree.ko` creates its **own** alist(100), frees it, then
`alist_free(bl, 128, 32)` with `bl_blocks=100`:

```
aoobfree: creating own alist(100)
aoobfree: calling alist_free(bl,128,32) with bl_blocks=100 -- expect KKASSERT panic at :286
panic: assertion "blkno + count <= bl->bl_blocks" failed in alist_free at /usr/src/sys/kern/subr_alist.c:286
Trace: alist_free+0xba <- aoobfree_modevent+0x8e <- module_register_init ...
```

Full capture: `panic.txt`. Proves the ONLY bound between a caller range slip
and the meta-bit poisoning is an INVARIANTS-only KKASSERT.

### 2. Production-kernel chain (standalone, `-DNDEBUG`, identical code)

`run.log` (decisive lines):

```
after alist_free(bl,128,32): bl_free=132 (capacity=100!) root bitmap=0000037f  <-- NO PANIC, bits 8..9 set (child4=11)
3x alist_alloc(bl,0,32) -> 64 (in-domain exhausted)
alist_alloc(bl,0,32) returned 128  <-- bl_blocks=100: OUT-OF-DOMAIN block number handed out
uninitialized slots 5..16 written: 0 (corruption is logical, in parent meta bits)
```

Root-cause trace (all `sys/kern/subr_alist.c`):

1. `alist_free:286` — `KKASSERT(blkno + count <= bl->bl_blocks)` compiled out
   on production kernels → `alst_meta_free` entered with an out-of-range
   request.
2. `alst_meta_free:745-753` — the **full-cover branch has no `bl_blocks`
   check** (`} else if (freeBlk < bl->bl_blocks) {` guards only the partial
   branch; the unconditional "beyond limit" panic at `:784` sits in that else
   chain and is unreachable for full-cover children). A free of a whole child
   beyond the limit silently sets the parent's 2-bit pair to ALL-FREE (`11`)
   and `bl_free += count` → 132 > capacity 100.
3. `alst_meta_alloc:552-568` — the direct-allocation path likewise has **no
   `bl_blocks` check**; once in-domain space is exhausted it matches the
   poisoned `11` pair and returns block 128 ≥ `bl_blocks` (the terminator
   guards that used to catch this class were `#if 0`-removed at `:584-592`,
   `:727-733`, `:861-876`, replaced only by the `blk >= bl->bl_blocks` guards
   at `:581`/`:754` — which these two paths bypass).
4. Contrast: a *partial* out-of-domain free panics loudly at `:784`
   (`alst_meta_free: attempt to free block 120 beyond limit of 100`, child
   exit=1 in run.log) — the asymmetry (partial=loud, full=silent) is the bug.
5. Compounding: `alist_create:166` allocates `bl_root` **without `M_ZERO`**
   while `alst_radix_init:839-881` over-allocates slot space to radix-16
   granularity (memindex += ALIST_META_RADIX per meta node regardless of how
   many children are in-domain) — e.g. blocks=100 → `bl_rootblks=17` but only
   5 slots ever initialized. The 4.4M-op legal-sequence differential fuzz
   (DF-2797 pack) proved via poison-persistence (H7) that no legal sequence
   ever writes those slots — but they are live heap garbage one off-by-one
   away from being interpreted as a node. `M_ZERO` removes that ammunition.

In-kernel sink for the returned out-of-domain block:
`vm_page.c:2835` `m = PHYS_TO_VM_PAGE((vm_paddr_t)blk << PAGE_SHIFT)` — with
blk ≥ 65536 for `vm_contig_alist`, `m` indexes past `vm_page_array` → wild
kernel pointer returned to the DMA caller. In-tree callers never slip
(`vm_page.c:2868` bounds by `vm_low_phys_reserved`; boot frees bounded by the
same value) — hence caller-bug amplification, Low severity.

## Exploit chain

None directly (requires a kernel-internal caller bug to seed). Chain when
seeded: range slip → silent `11`-pair poisoning (`:752`) → direct-path
allocation returns blk ≥ bl_blocks (`:561`) → `PHYS_TO_VM_PAGE` OOB → driver
writes through a fabricated `vm_page_t` → arbitrary kernel memory
corruption. Every step demonstrated except the final driver write (needs a
misbehaving in-tree caller, which does not exist).

## Fix validation

`fix.diff` applied to the guest's `/usr/src`, kernel rebuilt, A/B rerun — see
`fix_validation.log`: the standalone production-emulation now panics loudly
at the new unconditional `alist_free` bound (`alist_free: free 128+32 beyond
limit of 100`) *before* any meta bit is touched, on both INVARIANTS and
production builds; the direct path additionally refuses out-of-domain spans
(defense in depth); the KLD panic message changes from the KKASSERT text to
the explicit `alist_free` panic (intentional, production-safe). The silent
path is gone.
