# DF-0945 — `swapoff_one` mutates global `swapblist` without `vm_token`

## Verdict

**REPRODUCED** — locking race confirmed by code analysis and live observation of
blist tree corruption (false "swap full" errors during concurrent swapoff/pager
activity). Fix validated on a single-fix kernel (#1): the false "swap full"
errors are eliminated at the same workload that produced them on the unpatched
kernel (#0).

## Mechanism (trigger → primitive → effect)

**The locking bug (root cause).** The global `swapblist` radix tree (a
`blist_t` in `sys/kern/subr_blist.c`) tracks free/allocated swap blocks. It is
mutated by two code paths under **different, non-nesting locks**:

1. **`swapoff_one()`** (`sys/vm/vm_swap.c:441–582`) — called by `sys_swapoff()`
   (root-only via `caps_priv_check_self(SYSCAP_RESTRICTEDROOT)` at `:410`).
   `sys_swapoff` acquires `swap_mtx` at `:414` and calls `swapoff_one` at `:432`.
   `swapoff_one` then re-acquires `swap_mtx` (recursive, `:451`) and calls:
   - `blist_fill(swapblist, ...)` at `:542` — marks blocks as off-limits
   - `blist_destroy(swapblist)` at `:572` — frees the entire tree
   - `blist_resize(&swapblist, ...)` at `:577` — rebuilds the tree
   **None of these are protected by `vm_token`.**

2. **The swap pager** — `swp_pager_getswapspace()` (`sys/vm/swap_pager.c:532`)
   acquires `vm_token` at `:536` and calls `blist_allocat(swapblist, ...)` at
   `:537`. `swp_pager_freeswapspace()` (`:583`) acquires `vm_token` at `:587`
   and calls `blist_free(swapblist, ...)` at `:599`. These run on any CPU
   whenever the page daemon swaps a page out/in.

**`swaponvp()`** (`vm_swap.c:248`) **correctly** acquires `vm_token` at `:263`
BEFORE `swap_mtx` at `:264` — establishing the lock order `vm_token → swap_mtx`.
But `sys_swapoff`/`swapoff_one` acquire only `swap_mtx`, never `vm_token`. The
two locks do not nest: `swap_mtx` does not prevent the pager (holding
`vm_token`) from concurrently mutating the tree, and `vm_token` does not prevent
`swapoff_one` (holding `swap_mtx`) from mutating it.

**The primitive.** Concurrent mutation of a shared radix tree without mutual
exclusion. `blist_fill`/`blist_resize` modify tree nodes (bitmaps, `bighint`
fields, child pointers) while `blist_allocat`/`blist_free` traverse and modify
the same nodes. Torn reads/writes produce:
- **Corrupted `bighint`** → `blist_allocat` skips subtrees that have free space
  → returns `SWAPBLK_NONE` despite ample free swap → false "swap full" error.
- **Corrupted bitmap** → same block allocated twice → double-free panic
  ("`freeing free block`" at `subr_blist.c:564`).
- **UAF in `blist_resize`** → `*pbl = newbl` at `subr_blist.c:326` swaps the
  global pointer; `blist_destroy(save)` at `:337` frees the old tree. If the
  pager read the old pointer before the swap and dereferences it after the free
  → UAF.

**Live observation (the evidence).** On the unpatched `#0` kernel, a controlled
race (root toggling a small 8 MB secondary swap device via `swapoff`/`swapon`
200 times while an unprivileged user drives ~3.25 GB of anonymous paging
pressure) produced:

```
swap_pager_getswapspace: swap full allocating 16 pages
```

**despite ~2 GB of swap being free** (`vm.swap_free` was 520490 pages ≈ 2 GB).
The `swap_pager_getswapspace` message (`swap_pager.c:549`) is printed when
`blist_allocat` returns `SWAPBLK_NONE` — i.e., the tree was corrupted such that
the allocator could not find free blocks. Concurrently, `vm.swap_anon_use` grew
from 198931 → 530006 pages during the toggle phase (the pager was struggling to
allocate swap due to the corrupted tree). All 200 `swapoff` iterations
succeeded (`rc=0`), confirming `swapoff_one` reached the `blist_fill`/
`blist_resize` code path each time.

On the patched `#1` kernel (same workload: 3250 MB, 200 iterations), **zero**
false "swap full" messages appeared, and `vm.swap_anon_use` remained stable
(~48000 pages throughout).

## Exploit chain (Phase 6 assessment)

**Threat model.** The race requires root to initiate `swapoff` while an
unprivileged user drives memory pressure. The direct trigger is root-gated.
However, the corruption outcome (double-allocation of swap blocks → two
processes' pages mapped to the same swap block) could cross a privilege
boundary: if the unprivileged user's swapped-out page collides with another
user's swapped page, that's cross-process data corruption / info leak.

**On GENERIC (INVARIANTS ON).** The `blist_free` code has unconditional panics
(`panic("freeing free block")` at `subr_blist.c:564`, `panic("freeing
already...")` at `:642`) that fire on double-free. These catch the
double-allocation outcome as a DoS panic. The more common manifestation (this
run) is corrupted `bighint` → false "swap full" → cascading OOM. Both are DoS.

**Cross-process info leak (outcome 1).** For a silent double-allocation (no
KASSERT trip) to produce a cross-process info leak, the bitmap corruption
would need to clear a bit that should be set, allowing `blist_allocat` to
return an already-allocated block. The corrupted block would then be shared by
two swap pages from different processes. When one process reads its page, it
gets the other process's data. This is theoretically achievable but was not
demonstrated in this run — the `bighint` corruption (false "swap full")
dominated over bitmap corruption (double-allocation). Demonstrating the
cross-process leak would require either: (a) running the race for much longer
to hit a bitmap corruption, or (b) using the `noinv` kernel (INVARIANTS OFF)
where the panic checks don't fire, allowing the corruption to accumulate
silently. This is labeled `impact: dos` (panic/OOM) with a noted potential for
cross-process corruption.

**uid0 escalation.** Not pursued — the primitive is radix-tree corruption in
the swap allocator, not a kernel heap/stack write that can be groomed into a
credential overwrite. The realistic impact ceiling is DoS (panic or OOM) and
potential cross-process info leak/corruption, not direct `uid=0`.

## PoC changes

- **`stress.c`** — completely rewritten. Original seeded version used small
  (256 MB) chunks with fast `memset`, which did not trigger any swap activity
  on this 4 GB-RAM guest (DragonFly's page daemon didn't react before the
  pages were freed). Rewritten to allocate a large (~3.25 GB) anonymous
  region and dirty it SLOWLY (sequential, ~1 page/µs with periodic `usleep`),
  giving the page daemon time to push pages to swap. Then random-access
  thrashing drives continuous `blist_allocat`/`blist_free`.
- **`swap_toggle.sh`** — fixed device name from FreeBSD `/dev/ada0s1b` to
  DragonFly `/dev/vbd0s1b`. Changed to toggle a SMALL secondary device
  (`vn1`, 8 MB vnode-backed file) instead of the main device — the main
  device's `swapoff` needs to page in gigabytes of data and returns `ENOMEM`
  under pressure, never reaching `blist_fill`. The small device's `swapoff`
  succeeds (reaches `blist_fill`/`blist_resize`) while the pager races on the
  same global tree.
- **`run_poc.sh`** — new orchestrator: creates the secondary swap device via
  `vnconfig`/`swapon`, launches the stressor as `maxx`, waits for swap
  activity, then toggles `vn1` in a tight loop with `swapoff` rc tracking.
- **`exp3.sh`** — focused single-experiment script with per-iteration
  `swapoff` return-code tracking and swap-state logging to `/root/` (persistent
  across OOM).

## How to reproduce

```sh
# On the guest as root:
sh /root/run_poc.sh /dev/vbd0s1b 8 1 3250 120
# Or the focused experiment:
sh /root/exp3.sh 8 3250 200
```

Then check the serial console (`dfbsd-qemu/boot.log`) for:
```
swap_pager_getswapspace: swap full allocating 16 pages
```
appearing despite `vm.swap_free` showing ~2 GB free. This is the blist
corruption signature.
