swaponvp() stripe round-up registers up to 63 phantom swap pages per device beyond the real device end, wedging the pageout daemon in an unbounded EINVAL retry loop
| Field | Value |
|---|---|
| ID | DF-2879 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-687 Incorrect Calculation |
| File | sys/vm/vm_swap.c |
| Lines | 365-367, 377-383 |
| Area | vm |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:vm |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
swaponvp() rounds each device's page count UP to a SWB_DMMAX
(64-page/256KiB) multiple and frees whole stripes [SWB_DMMAX,
aligned_nblks) into the global swapblist β the min() clip at :378 is
dead code because aligned_nblks is already a stripe multiple β so up to
63 pages per device that do not exist on the media are advertised as
free swap and counted in vm_swap_size. When the pager allocates a
phantom block, the disk layer rejects the beyond-EOF bio with EINVAL
and swp_pager_async_iodone re-dirties the page β the pageout daemon
retries forever. Any swap device whose page count is not a 256KiB
multiple (odd-sized vn image, unaligned partition, NFS swap file)
silently registers nonexistent swap; under pressure the kernel
livelocks. Demonstrated twice from fresh resets: 25,712 / 18,215
swap_pager: I/O error - pageout failed; error 22 lines and guest
death (ssh dead, vm.sh down). Root-misconfiguration availability loss,
no memory corruption/disclosure.
Proof of contest
VERIFIED (findings/poc/DF-2879/): 266240-byte vn image β swapctl -l shows 1024 512-blocks though diskinfo reports 520; primary swap off, vn1 only, 5.2GB anon hog β unbounded error loop, guest down in ~25s (2/2). Fix (use the real page-truncated count so the min() clips the final partial stripe) validated: real 520 blocks registered, exactly 1 page gained, ZERO I/O errors under identical pressure, clean swapoff.
Recommended fix
Validated fix.diff in findings/poc/DF-2879/.
Timeline
- 2026-09-02 Discovered during pass-2 audit of vm_swap.c (GLM 5.3); pageout-daemon wedge + guest death reproduced 2/2 + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2879 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.0 KB | β raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| trigger.sh | β | 789 B | view raw | |
| run.sh | β | 915 B | view raw | |
| build.sh | β | 395 B | view raw | |
| hog.c | β | 868 B | view raw | |
| run.log | β | 2.0 KB | view raw | |
| wedgelog.txt | β | 1.1 KB | view raw | |
| env.txt | β | 579 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| fix_build.log | β | 1.4 KB | view raw | |
| fix_run.log | β | 1.7 KB | view raw | |
| verdict.json | β | 4.0 KB | view raw |
DF-2879 β phantom swap tail: swaponvp() round-up registers swap pages beyond the device end
- File:
sys/vm/vm_swap.c(swaponvp, lines 365β383; sibling fill-loop inswapoff_one, lines 539β544) - Class: incorrect resource accounting / out-of-bounds swap registration β system-wide DoS (pageout error-retry livelock)
- Trigger: root
swaponof any block device whose PAGE_SIZE block count is not a multiple ofSWB_DMMAX(64 pages = 256 KiB) - Buckets: base:vm / DoS
Build
cc -O2 -o /tmp/hog /tmp/hog.c # guest, base system cc
No kernel changes are needed for the reproduction.
Run
./trigger.sh # registration proof: 520-block device registered as 1024 ./run.sh # consequence proof: phantom pageout I/O-error livelock
Expected output (stock kernel)
trigger.sh:
/dev/vn1 blksize=512 ... size=0x000000000041000 260.00 KB <- 520 512-blocks Device: 512-blocks Used: /dev/vn1 1024 0 <- registered 2x real size vm.swap_size: 1048576 (= 1048512 primary + 64 from vn1, of which 1 page is real)
run.sh (primary swap removed; 5.2 GB hog): the pageout daemon retries the
same phantom blocks forever; serial console fills with
swap_pager: I/O error - pageout failed; offset 1368064,size 65536, error 22
(25,712 lines in run 1, 16,000+ in run 2) and the guest stops responding
(ssh commands hang, dmesg fails with Cannot allocate memory, vm.sh
status β down). Offset 1368064 / 4096 = interleaved block 334 β
seg 5 β device index 1 (vn1) β device page 78 of a 65-page device.
Patched kernel (fix.diff applied)
trigger.sh registers vn1 with its real 520 blocks (vm.swap_size gains
exactly 1 page, the only real page in the allocatable stripe); run.sh shows
no I/O error - pageout failed lines β the pager correctly reports swap full
instead of paging to nonexistent blocks β and the guest stays responsive.
DF-2879 VERDICT β phantom swap tail from swaponvp() stripe round-up
Status: REPRODUCED (impact: dos; system-wide pageout livelock), fix validated
Root cause (path:line)
sys/vm/vm_swap.c:swaponvp():
- :332-333 β device size is converted to PAGE_SIZE blocks (
nblks, real size, floored to a page multiple). - :365-366 β
aligned_nblks = (nblks + SWB_DMMASK) & ~SWB_DMMASKrounds UP to aSWB_DMMAX(64-page = 256 KiB) stripe multiple. - :367 β
sp->sw_nblks = aligned_nblks(the per-device bound later used byswapdev_strategy, :128). - :377-383 β the free-loop hands whole 64-page stripes
[SWB_DMMAX, aligned_nblks)to the globalswapblistas allocatable swap and adds them tovm_swap_size/vm_swap_max. Becausealigned_nblksis a round-UP of the real size, the final stripe's tail (aligned_nblks - nblksβ [1..63] pages) does not exist on the device. Theblk = min(aligned_nblks - dvbase, SWB_DMMAX)clip is dead code for an aligned value, so nothing clips the phantom tail. (FreeBSD's ancestor code clipped to the realnblks.)
Consequence chain (verified on the guest)
- Registration: a 266,240-byte vn (65 pages = 520 512-blocks) is registered as
1024 512-blocks;
vm.swap_sizegains 64 free pages of which exactly 1 is real (device stripe 0[0,64)is reserved by design; the only real page in the freed stripe[64,128)is page 64). swp_pager_getswapspace()(sys/vm/swap_pager.c:532-568) allocates the phantom blocks from the blist like any other.swapdev_strategy()(sys/vm/vm_swap.c:92-155) passes them: its only bound check isnblkno + sz > sp->sw_nblks(:128) against the inflatedsw_nblks.- The disk layer rejects the beyond-device bio (
dscheckβ EINVAL; subr_disk.c:1240-1246 / subr_diskslice.c) β the bio completes withB_ERROR. swp_pager_async_iodone()(sys/vm/swap_pager.c:1855-1865 prints "swap_pager: I/O error - pageout failed ... error 22"; :1965-1994) drops the swap assignment (SWM_FREE, block leaks out of the blist), re-dirties and re-activates the page.- The pageout daemon picks the still-dirty page again β new allocation β
error β repeat. Observed 25,712 / 18,215 error iterations in two runs;
the loop never terminates, the guest stops responding (
dmesgfails withCannot allocate memory, ssh hangs,vm.sh statusβ down). With the phantom device as the only swap, effectively 100 % of pageout targets are phantom, so the system cannot free any anonymous memory at all.
No memory-safety consequence: read errors mark the page m->valid = 0
(swap_pager.c:1947) β SIGBUS, and write errors re-dirty the page in place
(:1980-1993); the failure mode is availability, not corruption or disclosure.
Reproduction (fresh vm.sh reset with-src, stock kernel)
See README.md / trigger.sh / run.sh / run.log / wedgelog.txt.
Both runs wedged the guest identically at interleaved offsets 1368064 and
1433600 (device pages 78 and 94 of a 65-page device β pure phantom region).
Fix validation
fix.diff replaces the round-up with the real page-truncated count
(aligned_nblks = nblks), making the pre-existing min() clip in both the
swapon free-loop (vm_swap.c:378) and the swapoff fill-loop (vm_swap.c:540)
effective, exactly as in FreeBSD's ancestor code. After building
make -j6 nativekernel KERNCONF=X86_64_GENERIC in the guest and rebooting:
trigger.sh: vn1 registers its real 520 blocks;vm.swap_sizegains exactly 1 page. (see fix_run.log)run.sh(same 5.2 GB hog, primary swap removed): zero "swap_pager: I/O error - pageout failed" lines; the pager reports swap-full and the guest remains responsive;vm.sh statusβ up throughout.
Threat model
swapon requires SYSCAP_RESTRICTEDROOT (vm_swap.c:202), so the trigger is a
root configuration action β but the consequence is a full-system availability
loss (unkillable pageout livelock) from a plausible configuration (any swap
device whose size is not a 256 KiB multiple β e.g. an oddly-sized NFS swap
file, a vn image, or an unaligned partition), not an obviously-invalid one.
There is no privilege-boundary crossing.
Fix verification
fixedPatched kernel built in-guest (rc=0, 0 errors) and booted. Registration phantom gone (vn1 = real 520 blocks, vm.swap_size +1 page). Identical pressure scenario (primary swap removed, 5.2 GB hog): zero swap_pager I/O errors, guest responsive, clean recovery without reset; unaligned device also swaps off cleanly. Baseline stock kernel: same scenario produced 25k+/18k+ error iterations and a wedged, unresponsive guest twice.
['findings/poc/DF-2879/fix.diff', 'findings/poc/DF-2879/fix_build.log', 'findings/poc/DF-2879/fix_run.log']
Confirmed kernel references
Detail
Evidence (decisive lines)
['trigger.sh + run.log: real device 0x41000 bytes (520 512-blocks) registered as 1024 512-blocks; vm.swap_size gains 64 pages of which 1 is real', "wedgelog.txt: 25,712 / 18,215 unbounded 'swap_pager: I/O error - pageout failed; offset 1368064,error 22' iterations; vm.sh status -> down after both runs", 'fix_run.log: patched kernel registers the real 520 blocks, gains exactly 1 page, and under the identical pressure scenario produces ZERO I/O errors while staying responsive', 'fix.diff: replace the round-up with the real page-truncated count so the pre-existing min() clips in both the swapon free-loop (vm_swap.c:378) and swapoff fill-loop (vm_swap.c:540) take effect']
PoC changes
trigger/run scripts assembled from the live session (vn 266240-byte file -> vnconfig -> swapctl -a; hog recompiled per reset because /tmp is wiped on boot)
Verified recommended fix
In swaponvp() use the real page-truncated block count (aligned_nblks = nblks) instead of rounding up to SWB_DMMAX, letting the existing min() clip the final partial stripe; see fix.diff
Verdict
swaponvp() rounds each swap device's page count UP to a SWB_DMMAX (64-page) stripe multiple and frees the entire last stripe into the global blist, registering up to 63 pages per device that do not exist on the device. Any pageout the pager directs at a phantom block is rejected by the disk layer (EINVAL) and the pageout daemon wedges in an unbounded error-retry livelock. Reproduced twice from fresh resets on the stock kernel: a 65-page vn device registered as 128 pages; under memory pressure the serial console accumulated 25,712 and 18,215 'swap_pager: I/O error - pageout failed; offset 1368064..., error 22' lines (offset = device page 78 of a 65-page device) and the guest went down both times. swapon requires SYSCAP_RESTRICTEDROOT, so this is a root-misconfiguration-triggered full-system availability loss, not a privilege-boundary crossing; no memory corruption or disclosure (read errors set m->valid=0 -> SIGBUS; write errors re-dirty in place).
No comments yet.