# DF-5812 — VERDICT

**status: reproduced (baseline) / fixed (patch validation) — impact: dos (livelock via OOB swap I/O past device EOF)**

## Root cause

`sys/vm/vm_swap.c:365-366` (`swaponvp()`):

```c
aligned_nblks = (swblk_t)((nblks + SWB_DMMASK) & ~(u_swblk_t)SWB_DMMASK);  /* rounds UP */
sp->sw_nblks = aligned_nblks;
```

The stripe-free loop at `sys/vm/vm_swap.c:377-383` then frees
`[SWB_DMMAX, aligned_nblks)` — full stripes — into `swapblist`, and
`vm_swap_size`/`vm_swap_max` are incremented for every freed page. For a
device of `nblks` pages with `nblks % 64 != 0`, the range
`[nblks, aligned_nblks)` (1..63 pages, up to 252 KB) does not exist on the
device but is published as allocatable swap. `swapdev_strategy()`
(`sys/vm/vm_swap.c:128`) validates I/O against the padded `sw_nblks`, so the
phantom blocks pass every kernel-side bound check; only the backend sees the
out-of-range offset.

The vestigial `blk = min(aligned_nblks - dvbase, SWB_DMMAX)` clamp in both
the swapon free loop and `swapoff_one()`'s fill loop shows the code was
originally written for partial last stripes (FreeBSD truncates); the DF
round-up broke that contract.

## Why it is worse than "one bad write"

The write-error path in `swp_pager_async_iodone()`
(`sys/vm/swap_pager.c:1980-1993`) reacts to a failed pageout by
`swp_pager_meta_ctl(m->object, m->pindex, SWM_FREE)` — which calls
`swp_pager_freeswapspace()` (`sys/vm/swap_pager.c:583-603`) — and
`blist_free()`s the phantom block **back into the allocator**, then
redirties/activates the page (`sys/vm/swap_pager.c:1986-1989`). Since the
phantom block is the lowest-fit free space at that point, the very next
pageout reallocates the identical block → identical OOB I/O → identical
error, forever. Each iteration also spams the console
(`sys/vm/swap_pager.c:1855-1865`). Result: unkillable pageout livelock;
the guest stops responding to ssh and needs a hard reset.

## Reproduction (baseline, unpatched snapshot kernel `#0 Thu Jul 2 06:02:54 2026`)

Rig: `/root/swapfile` = exactly 200 pages (819200 bytes), attached as
`/dev/vn0` (swdevt slot 1; the built-in 4 GB `/dev/vbd0s1b` is slot 0),
then `swapon /dev/vn0`, then a 4600 MB anonymous `malloc`+touch churn
(`churn.c`) to force pageout.

Observed (full logs: `run.log`, `hang-serial-console.txt`):

1. `vm.swap_size` delta after swapon: **+192 pages** — exactly the three
   published stripes k=1..3 (768 KB) although only 136 pages (544 KB) of
   that range exist; 56 pages (229 KB) are phantom.
2. `swapinfo` reports `/dev/vn0 2048 512-blocks` (1 MB) for an 800 KB file
   — the swap subsystem advertises 25% more device than exists.
3. During churn the guest wedged; the serial console ring contains nothing
   but:
   `swap_pager: I/O error - pageout failed; offset 3444736, size 65536, error 22`
   repeated forever (ssh timed out; `vm.sh status` → down).
   Offset decode: 3444736 >> 12 = interleave page 841; seg = 841/64 = 13;
   device index = 13 % 4 = 1 (vn0); device page = (13/4)*64 + 841%64 =
   **201** — the first page past the 200-page device. 16-page cluster
   [201,217) is entirely phantom. Error 22 = EINVAL from the backend
   rejecting the past-EOF swap write.

This is the OOB-write primitive *and* the livelock, both demonstrated.

## Fix validation (kernel `#1 Tue Oct 13 02:09:45 2026`, one-line change)

`fix.diff` applied to the guest's `/usr/src` (round DOWN:
`aligned_nblks = nblks & ~SWB_DMMASK`), kernel rebuilt
(`make nativekernel`, full log `fix_build.log`), installed, rebooted, and
the **identical** PoC re-run (`fix_run.log`):

| observation | baseline (buggy) | patched |
|---|---|---|
| `vm.swap_size` delta on swapon | +192 pages | **+128 pages** (all real) |
| `swapinfo` /dev/vn0 size | 2048 512-blks (1 MB lie) | **1536 512-blks (768 KB, accurate)** |
| backing file after churn | guest wedged (writes rejected) | **819200 → 819200, unchanged** |
| console during churn | infinite I/O-error loop at page 841 | **silent** |
| guest | wedged, ssh dead | responsive; churn exits cleanly |
| `swapoff /dev/vn0` after churn | n/a (wedged) | **succeeds** |

The patched kernel published only the 128 real pages of stripes [64,192);
the churn used 512 KB of vn0 swap (all within real space, 67% of the
published capacity) and nothing wrote past device EOF. The previously
observed bad behavior is gone in every dimension. fix_status: **fixed**.

## Exploitability

Not a privilege-escalation primitive: the OOB write target is chosen by the
blist allocator (lowest-fit), not the attacker, and the written content is
anonymous page data. The demonstrated and realistic ceiling is
**denial of service** (unrecoverable pageout livelock requiring hard reset,
triggered by unprivileged memory churn once swap pressure reaches the
phantom tail), plus potential silent cross-partition corruption on
backends that do not bounds-check past-EOF I/O. Severity Medium:
admin-config precondition (swap size not a multiple of 256 KB; MB-aligned
installs are unaffected).

## Honesty notes

- The vn *file* backend rejects the OOB write (EINVAL) instead of extending
  the file, which is why the livelock — not file growth — is the observed
  artifact; both are direct consequences of the same phantom allocation.
- `vm.swap_max` is not exported as a sysctl on this kernel; the +192 delta
  was measured via `vm.swap_size`.
- The first baseline attempt aborted on that missing sysctl oid (script
  `set -e`); the rig was hardened and re-run — attempts=3 total
  (1 aborted script, 1 decisive baseline, 1 patched control).

## 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.
