swaponvp() rounds swap size UP to the stripe boundary, publishing up to 63 pages of phantom swap per device: OOB swap I/O past device EOF and an unkillable pageout retry livelock under swap pressure
Summary
swaponvp computes aligned_nblks = (nblks + SWB_DMMASK) & ~SWB_DMMASK β rounding the device page count UP β and frees full stripes [SWB_DMMAX, aligned_nblks) into the global blist while incrementing vm_swap_size/max. For any swap device whose size is not a multiple of 256KB, blocks [nblks, aligned_nblks) (1..63 pages, up to 252KB) do not exist but are handed to the allocator; swapdev_strategy bound-checks against the padded sw_nblks so the OOB passes every kernel check. When unpriv memory churn drives pageout into the phantom tail the write fails EINVAL; the error path frees the phantom block BACK into the blist and redirties the page β pageout reallocates the identical block forever: infinite retry, line-rate console spam, hard wedge. Guest-verified: swapon of a 200-page vn0 published +192 phantom pages (swapinfo claims 1MB on an 800KB file); churn entered the infinite I/O-error loop at vn0 device page 201 (first phantom) and wedged the guest. On non-checking backends the same bug is a silent OOB write past swap-device EOF. Precondition: admin swap size not 256KB-multiple (MB-aligned installs unaffected). Round-down one-line fix validated on rebuilt guest: +128 real pages, accurate swapinfo, clean swapoff. Not a uid0 vector (blist-chosen offset, anonymous page payload).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5812 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 5.8 KB | β raw | |
| churn.c | β | 1.1 KB | view raw | |
| run.sh | β | 152 B | view raw | |
| build.sh | β | 90 B | view raw | |
| build.log | β | 196 B | view raw | |
| run.log | β | 921 B | view raw | |
| fix_run.log | β | 1.5 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| hang-serial-console.txt | β | 4.5 KB | view raw | |
| env.txt | β | 451 B | view raw | |
| fix.diff | β | 927 B | view raw | |
| kernel_build_first_attempt.log | β | 1.3 KB | view raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 4.7 KB | view raw |
DF-5812 β swaponvp() publishes phantom round-up padding as swap (OOB swap I/O + pageout livelock)
What
swaponvp() (sys/vm/vm_swap.c:365-366) rounds the swap device's page count
up to the next SWB_DMMAX (64-page / 256 KB) stripe boundary:
aligned_nblks = (swblk_t)((nblks + SWB_DMMASK) & ~(u_swblk_t)SWB_DMMASK);
sp->sw_nblks = aligned_nblks;
and then frees full stripes [SWB_DMMAX, aligned_nblks) into the swap
blist (sys/vm/vm_swap.c:377-383). For any device whose usable size is not a
multiple of 256 KB, blocks [nblks, aligned_nblks) β up to
SWB_DMMAX-1 = 63 pages (252 KB) β do not exist on the device, yet are
published to the swap allocator as free space (vm_swap_size/vm_swap_max
are incremented for them, and swapinfo reports the padded sw_nblks).
swapdev_strategy() bounds-checks I/O against the padded sw_nblks
(sys/vm/vm_swap.c:128), so nothing stops the swap pager from allocating and
issuing I/O for the phantom blocks.
Consequence (demonstrated on the guest)
When memory pressure drives swap allocation into the phantom tail:
swp_pager_getswapspace()hands out a phantom block.swap_pager_putpages()writes to it; the backend rejects the OOB I/O (EINVAL, error 22 on the vn file backend).swp_pager_async_iodone()'s write-error path (sys/vm/swap_pager.c:1965-1994) doesswp_pager_meta_ctl(SWM_FREE)βswp_pager_freeswapspace()(sys/vm/swap_pager.c:583-603), which returns the phantom block to the blist and redirties the page.- The pageout daemon immediately reallocates the same phantom block β EINVAL β repeat: an unterminable pageout retry loop that wedges the system (console spam at line rate, ssh dead, hard reset required).
On backends that do not bounds-check, the same bug is an out-of-bounds write of attacker-churned page data past the end of the swap device.
Trigger model
- Precondition: swap device/file whose size is not a multiple of 256 KB (admin config; e.g. any odd disklabel sector count, or a hand-made swap file as used here).
- Trigger: ordinary anonymous-memory churn by any local user β the guest
PoC uses a plain
malloc+touch loop. No privilege needed to trigger; the phantom space is consumed exactly when swap pressure reaches it.
Reproduce (on the audit QEMU guest)
# build the churn helper cc -O2 -o /root/churn /root/churn.c # run the rig: 200-page (800 KB) swap file on vn0 + 4.6 GB anon churn sh /root/run.sh
Expected (unpatched kernel):
- vm.swap_size grows by +192 pages on swapon (only 136 are real);
- swapinfo shows /dev/vn0 2048 512-blocks (1 MB claimed, 800 KB file);
- during churn the console enters an infinite
swap_pager: I/O error - pageout failed; offset 3444736, size 65536, error 22
loop (offset 3444736 = interleave page 841 = vn0 device page 201,
the first phantom page of a 200-page device) and the guest wedges.
Expected (kernel with fix.diff, round down):
- vm.swap_size grows by +128 pages (all real);
- swapinfo shows /dev/vn0 1536 512-blocks (accurate 768 KB);
- the churn completes normally, the backing file is unchanged, and
swapoff /dev/vn0 succeeds.
Fix
fix.diff β round down: aligned_nblks = nblks & ~SWB_DMMASK;
validated on the guest (see VERDICT.md).
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()):
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):
vm.swap_sizedelta 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.swapinforeports/dev/vn0 2048 512-blocks(1 MB) for an 800 KB file β the swap subsystem advertises 25% more device than exists.- During churn the guest wedged; the serial console ring contains nothing
but:
swap_pager: I/O error - pageout failed; offset 3444736, size 65536, error 22repeated 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_maxis not exported as a sysctl on this kernel; the +192 delta was measured viavm.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.
Fix verification
fixedfix.diff (single line: round down) applied to the guest /usr/src, kernel rebuilt via make nativekernel (full log fix_build.log), installed and rebooted (kernel #1 Oct 13 02:09:45). Identical PoC re-run: vm.swap_size delta +128 (was +192), swapinfo vn0 1536 512-blocks (was 2048), backing file unchanged after the identical 4600MB churn (was wedged in the error loop), swapoff succeeded. Every baseline artifact of the bug is gone; fix_patched_reproduced=0 means the bad behavior did NOT reproduce on the patched kernel.
fix_run.log (full patched run, steps 0-5 complete with NOT-REPRODUCED marker and clean teardown); fix_build.log (full nativekernel build); VERDICT.md baseline-vs-patched table.
Confirmed kernel references
Detail
Exploit chain
admin swapon on an unaligned-size device (e.g. odd-sized swap file/partition) -> blist contains phantom free blocks -> any local user's anonymous-memory churn drives pageout into the phantom tail -> backend rejects OOB write (EINVAL) -> error handler frees phantom block back to blist + redirties page -> pageout reallocates identical block -> infinite retry livelock; console spam at line rate; system unresponsive (ssh dead); hard reset required. Ceiling: DoS (demonstrated) / silent cross-partition write on non-checking backends.
Evidence (decisive lines)
run.log steps 0-3: vm.swap_size delta +192 pages on swapon of a 200-page vn0 and swapinfo showing '/dev/vn0 2048 512-blocks' (1MB) for an 800KB file; hang-serial-console.txt: infinite 'swap_pager: I/O error - pageout failed; offset 3444736, size 65536, error 22' (page 841 -> seg 13 -> device slot 1 vn0 -> device page 201 = phantom) with the guest wedged; fix_run.log: same PoC on the patched kernel shows +128 pages, swapinfo 1536 512-blocks, file unchanged, clean churn and swapoff.
PoC changes
No seed PoC existed; authored fresh: churn.c (anonymous malloc+touch helper) and run.sh (200-page vn swap file, swapon, swapinfo/sysctl capture, 4600MB churn, file-size evidence, teardown). First run attempt aborted on 'sysctl vm.swap_max' being unexported on this kernel; script hardened to tolerate it.
Verified recommended fix
In swaponvp() round the device size DOWN to the stripe boundary instead of UP: aligned_nblks = nblks & ~SWB_DMMASK (see fix.diff).
Verdict
swaponvp() (sys/vm/vm_swap.c:365-366) rounds the swap device page count UP to the SWB_DMMAX (64-page) stripe boundary and frees the full padded stripes into the swap blist (vm_swap.c:377-383). For any swap device whose size is not a multiple of 256KB, 1..63 pages of non-existent 'phantom' swap are published (vm_swap_size/swapinfo overstate the device; the strategy-layer bound check at vm_swap.c:128 uses the padded sw_nblks). Under unprivileged memory churn, pageout allocations reach the phantom tail; the OOB swap write is rejected by the backend (EINVAL) and the write-error path (swap_pager.c:1965-1994 -> swp_pager_freeswapspace at swap_pager.c:583-603) frees the phantom block back to the blist and redirties the page, so the pageout daemon reallocates the same phantom block forever: the guest wedged in an infinite 'swap_pager: I/O error - pageout failed; offset 3444736 ... error 22' loop (interleave page 841 = vn0 device page 201 = first phantom page of a 200-page device) and required a hard reset. On backends that do not bounds-check past-EOF I/O the same bug is a silent OOB write of page data past the end of the swap device.
No comments yet.