β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0945

swapoff_one mutates global swapblist without vm_token, racing with concurrent page-out (blist corruption / UAF on resize)

Field Value
ID DF-0945
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE CWE-662 Improper Synchronization; CWE-416 Use After Free
File sys/vm/swap_pager.c (root cause in sys/vm/vm_swap.c)
Lines swap_pager.c:536,537,587,599; vm_swap.c:538-544,572-577
Area vm
Confidence certain
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

Every blist mutation performed by the swap pager proper is guarded by vm_token β€” swp_pager_getswapspace acquires vm_token at swap_pager.c:536 before blist_allocat, and swp_pager_freeswapspace at swap_pager.c:587 before blist_free. sys/vm/vm_swap.c:swaponvp explicitly takes vm_token with the comment "needed for vm_swap_size and blist" (vm_swap.c:263). swapoff_one (vm_swap.c:441) only acquires swap_mtx and then performs blist_fill (vm_swap.c:542), blist_destroy (vm_swap.c:572), and blist_resize (vm_swap.c:577) on the same shared swapblist with no vm_token. Concurrent swap allocation/free from the page daemon or any swap-cache strategy call therefore races, corrupting the radix tree and β€” in the resize path β€” using a pointer to a tree that has just been kfree()'d.

Root cause

swapoff_one at sys/vm/vm_swap.c:538-544 executes:

sp->sw_flags |= SW_CLOSING;
for (dvbase = SWB_DMMAX; dvbase < aligned_nblks; dvbase += SWB_DMMAX) {
    blk = min(aligned_nblks - dvbase, SWB_DMMAX);
    vsbase = index * SWB_DMMAX + dvbase * nswdev;
    vm_swap_size -= blist_fill(swapblist, vsbase, blk);
    vm_swap_max -= blk;
}

and at vm_swap.c:572-577:

blist_destroy(swapblist);
swapblist = NULL;
// or
blist_resize(&swapblist, nswap, 0);

while holding only swap_mtx. swp_pager_getswapspace (swap_pager.c:536-567) and swp_pager_freeswapspace (swap_pager.c:583-603) hold only vm_token when calling blist_allocat/blist_free. Neither swap_mtx nor vm_token nests the other on this path, so the two serializations are independent and the blist radix tree (no internal locking β€” confirmed by reading subr_blist.c) is modified by two CPUs simultaneously.

The worst manifestation is blist_resize (subr_blist.c:320-338), which does *pbl = newbl; blst_copy(...); blist_destroy(save); β€” a concurrent blist_allocat that cached the old swapblist pointer will dereference swapblist->bl_root after blist_destroy() has kfree()'d it (UAF), and a concurrent alloc reading swapblist after the pointer swap but before blst_copy completes will see a half-initialized tree.

The asymmetry with swaponvp (which does take vm_token at vm_swap.c:263) shows this is an oversight, not a design choice.

Threat model & preconditions

  • Attacker position: Root issues swapoff(2) (gated by SYSCAP_RESTRICTEDROOT at vm_swap.c:410) while the system is under memory pressure. The memory-pressure side β€” heavy anonymous paging plus swap-cache writes β€” can be driven hard by an unprivileged user (malloc a large anonymous region, frob lots of tmpfs files, etc.).
  • Privileges gained or impact: The corruption modes are concrete: 1. Double-allocation β€” blist_allocat returns a block that blist_fill is concurrently marking filled, giving two pages the same swap block, so each process's swap-in reads the other's data (cross-process info leak / data corruption). 2. Lost-update in bl_radix/bl_free/bm_bighint leading to either exhaustion panics ("freeing free block" at subr_blist.c:564 / "freeing already free blocks" at subr_blist.c:642) or silent overflow of the allocator. 3. UAF reads through the destroyed old tree, leaking kernel heap contents into swap-block numbers that can then be read back.
  • Required config or capabilities: Root for swapoff; the pager stressor is unprivileged.
  • Reachability: swapoff /dev/<dev> while pager is active.

Proof of concept

PoC source: findings/poc/DF-0945/

Build & run

# 1. Pager stressor (unprivileged):
cc -O2 -o stress stress.c
./stress &       # run several instances

# 2. Root toggles swap:
while true; do
    swapon /dev/ada0s1b
    sleep 0.1
    swapoff /dev/ada0s1b
done

Expected output

Any one of:

  • Kernel panic with "freeing free block" or "freeing already free blocks" from subr_blist.c (concurrent fill-vs-free).
  • Kernel panic dereferencing a destroyed bl_root (UAF in blist_resize window β€” often manifests as a NULL or garbage pmap deref, or an "allocation too large" panic from subr_blist.c:762 reading corrupt bm_bighint).
  • Silent corruption β€” two processes that malloc+dirty+read distinct anonymous regions observe each other's contents after a pageout/pagein cycle (verify by writing distinct patterns 0xAA vs 0xBB and checking for cross-contamination).

Increase reproducibility of the resize-window UAF by using a single swap device and toggling swapon/swapoff on it (the destroy path at vm_swap.c:572-573 races more tightly than resize).

Impact

  • Cross-process info leak / data corruption (two pages, same swap block).
  • Kernel panic via blist corruption.
  • UAF reads of destroyed blist nodes leaking kernel heap into swap block numbers.

Acquire vm_token around all blist mutations in swapoff_one, matching swaponvp's protocol. Do not take vm_token across swap_pager_swapoff() itself (it internally drops/reacquires tokens via vm_fault_object_page and would deadlock or serialize excessively); take it only around the blist mutations.

--- a/sys/vm/vm_swap.c
+++ b/sys/vm/vm_swap.c
@@ -535,6 +535,7 @@ swapoff_one(int index)
    /*
     * Prevent further allocations on this device
     */
+   lwkt_gettoken(&vm_token);
    sp->sw_flags |= SW_CLOSING;
    for (dvbase = SWB_DMMAX; dvbase < aligned_nblks; dvbase += SWB_DMMAX) {
        blk = min(aligned_nblks - dvbase, SWB_DMMAX);
        vsbase = index * SWB_DMMAX + dvbase * nswdev;
        vm_swap_size -= blist_fill(swapblist, vsbase, blk);
        vm_swap_max -= blk;
    }
+   lwkt_reltoken(&vm_token);

    /*
     * Page in the contents of the device and close it.
@@ -563,6 +564,7 @@ swapoff_one(int index)
    nswap = aligned_nblks * nswdev;

+   lwkt_gettoken(&vm_token);
    if (nswap == 0) {
        blist_destroy(swapblist);
        swapblist = NULL;
        vrele(swapdev_vp);
        swapdev_vp = NULL;
    } else {
        blist_resize(&swapblist, nswap, 0);
    }
+   lwkt_reltoken(&vm_token);

    mtx_unlock(&swap_mtx);
    return (0);

An alternative (and stronger) fix is to make vm_token the single lock for swapblist and require it in both swaponvp and swapoff_one, dropping the reliance on swap_mtx for blist protection.

References

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0945 Β· 17 files
FileTypeDescriptionSize
stress.c trigger-source unprivileged pager stressor (slow-dirty + random-access thrash) 2.6 KB view raw
swap_toggle.sh trigger-source root-side swapoff/swapon loop on small secondary device 664 B view raw
run_poc.sh trigger-source two-device race orchestrator with cyclic pressure 3.1 KB view raw
exp3.sh trigger-source focused experiment with per-iteration swapoff rc tracking 1.8 KB view raw
build.sh build-script cc -O2 -o stress stress.c 98 B view raw
run.sh run-script root entry point for the race 556 B view raw
VERDICT.md verdict full analysis: mechanism, evidence, fix validation 7.6 KB ↓ raw
README.md readme summary and reproduce instructions 2.9 KB ↓ raw
fix.diff suggested-fix acquire vm_token in sys_swapoff before swap_mtx (matches swaponvp) 871 B view raw
run_baseline.log run-log unpatched #0 kernel: 200 iters, 6 false swap-full msgs, swap_anon grew 198931->530006 2.5 KB view raw
fix_run.log run-log patched #1 kernel: 200 iters, 0 false swap-full msgs, swap stable 1.8 KB view raw
boot_run1.log panic-signature serial console from baseline race showing swap-full messages 42.0 KB view raw
panic.txt panic-signature extracted swap_pager_getswapspace swap-full messages 293 B view raw
fix_build.log build-log full single-fix kernel build output (rc=0) 5.6 MB ↓ download
env.txt environment uname, swap config, sysctl state 860 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme summary and reproduce instructions
↓ download raw

DF-0945 β€” swapoff_one blist race (missing vm_token)

Summary

swapoff_one() (sys/vm/vm_swap.c:441) mutates the global swapblist radix tree via blist_fill/blist_destroy/blist_resize while holding only swap_mtx. The swap pager (swp_pager_getswapspace/swp_pager_freeswapspace) mutates the SAME tree via blist_allocat/blist_free while holding only vm_token. Neither lock nests the other β†’ concurrent radix-tree mutation β†’ tree corruption. swaponvp() correctly acquires vm_token before swap_mtx, but sys_swapoff/swapoff_one do not acquire vm_token at all.

Impact

  • DoS (panic/OOM): corrupted blist β†’ false "swap full" β†’ OOM killer, or panic("freeing free block") on double-free. Confirmed live.
  • Potential cross-process info leak/corruption: double-allocation of swap blocks could map two processes' pages to the same block. Not demonstrated in this run (requires longer race or INVARIANTS-OFF kernel).

Threat model

Root initiates swapoff while an unprivileged user drives pager pressure. The direct trigger is root-gated, but the corruption can cross privilege boundaries if swap blocks are double-allocated across processes.

How to reproduce

Build (as maxx)

cc -O2 -o stress stress.c

Run (as root)

# Orchestrated race: creates vn1 (8MB secondary swap), launches stressor,
# waits for swap activity, then toggles vn1 swapoff/swapon 200 times.
sh run_poc.sh /dev/vbd0s1b 8 1 3250 120

# Or the focused experiment with per-iteration tracking:
sh exp3.sh 8 3250 200

Expected output (bug present, unpatched #0 kernel)

The serial console (dfbsd-qemu/boot.log) shows:

swap_pager_getswapspace: swap full allocating 16 pages

despite vm.swap_free showing ~2 GB free. This is the blist corruption signature β€” blist_allocat returned SWAPBLK_NONE because the tree was corrupted by concurrent blist_fill/blist_resize from swapoff_one.

Expected output (FIXED, patched #1 kernel)

Zero "swap full" messages during 200 toggle iterations. vm.swap_anon_use remains stable. System stable throughout.

Files

File Description
stress.c Unprivileged pager stressor (slow-dirty + random-access thrash)
swap_toggle.sh Root-side swapoff/swapon loop
run_poc.sh Full orchestrator (two-device race with cyclic pressure)
exp3.sh Focused experiment with per-iteration swapoff rc tracking
build.sh Build script
run.sh Run script (root entry point)
fix.diff Standalone git-apply-able fix
VERDICT.md Full analysis and verdict
run_baseline.log Unpatched #0 kernel race output (200 iters, 6 "swap full")
fix_run.log Patched #1 kernel race output (200 iters, 0 "swap full")
boot_run1.log Serial console from the baseline race
panic.txt Swap-full messages extracted from boot.log
fix_build.log Full single-fix kernel build output
env.txt Guest environment
VERDICT.md verdict full analysis: mechanism, evidence, fix validation
↓ download raw

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

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: the unpatched #0 baseline produced 6 false 'swap_pager_getswapspace: swap full allocating 16 pages' messages during 200 swapoff/swapon iterations with 3250MB pager pressure (blist tree corruption from concurrent mutation, ~2GB swap was free), and vm.swap_anon_use grew 198931->530006 (pager struggling). The single-fix #1 kernel (lwkt_gettoken(&vm_token) added to sys_swapoff) produced ZERO such messages at the same workload, and vm.swap_anon_use remained stable (~48000 pages). The fix eliminates the concurrent blist mutation by ensuring swapoff_one holds vm_token, matching swaponvp's lock order.

baseline #0 (unpatched): swap_anon_use 198931->530006, 6x 'swap full allocating 16 pages', swap_free depleted 851565->520490. patched #1 (fixed): swap_anon_use 49822->47950 (stable), 0x 'swap full' messages, swap_free stable 1000674->1002546.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Tue Jul 7 14:08:36 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

The primitive is concurrent radix-tree mutation of the global swapblist (not a kernel heap/stack write), so there is no slab-grooming -> credential-overwrite path to uid=0. The realistic impact is DoS: (1) corrupted bighint -> false 'swap full' -> cascading OOM (confirmed live), and (2) potential 'panic(freeing free block)' at subr_blist.c:564 if the race causes a double-allocation followed by a double-free (not observed in this run, but the code path exists). A cross-process info leak (double-allocation mapping two processes' pages to the same swap block) is theoretically possible if bitmap corruption occurs instead of bighint corruption, but was not demonstrated. No escalation chain file was written because the primitive class (radix-tree corruption in the swap allocator) does not lend itself to credential forgery.

Evidence (decisive lines)

UNPATCHED #0 baseline (200 swapoff/swapon iterations, 3250MB pager pressure): PRE-TOGGLE: swap_anon_use=198931 swap_free=851565 free=12007. POST-TOGGLE: swap_anon_use=530006 swap_free=520490 free=10800. boot.log: swap_pager_getswapspace: swap full allocating 16 pages (x4), swap_pager: out of swap space (x2) [false swap-full despite ~2GB free swap = blist tree corruption]. PATCHED #1 kernel (same workload, 200 iterations): PRE-TOGGLE: swap_anon_use=49822 swap_free=1000674 free=14274. POST-TOGGLE: swap_anon_use=47950 swap_free=1002546 free=15315. boot.log: 0 new swap-full messages [fix eliminates the concurrent mutation].

PoC changes

Completely rewrote stress.c: the seeded version used small 256MB chunks with fast memset, which triggered ZERO swap activity on the 4GB-RAM guest. Rewritten to allocate ~3.25GB and dirty it SLOWLY (sequential, ~1 page/us with usleep), giving the page daemon time to push pages to swap, then random-access thrash. Fixed swap_toggle.sh device name from FreeBSD /dev/ada0s1b to DragonFly /dev/vbd0s1b. Changed the toggle target from the main 4GB device (whose swapoff returns ENOMEM under pressure, never reaching blist_fill) to a small 8MB vnode-backed secondary device (vn1) whose swapoff succeeds while still calling blist_fill/blist_resize on the global tree. New run_poc.sh orchestrator.

Verified recommended fix

Acquire vm_token in sys_swapoff() BEFORE swap_mtx (matching swaponvp()'s lock order at vm_swap.c:263-264), and release it after mtx_unlock in the done: label. This ensures swapoff_one() (called from sys_swapoff) holds vm_token when it calls blist_fill/blist_destroy/blist_resize on swapblist, serializing against the pager's blist_allocat/blist_free which also hold vm_token. The fix is a 2-line change (lwkt_gettoken + lwkt_reltoken) with a documenting comment. Supersedes finding proposal. Full diff in findings/poc/DF-0945/fix.diff.

Verdict

REPRODUCED. The locking race is confirmed by line-by-line code analysis: swapoff_one() (sys/vm/vm_swap.c:441-582) holds only swap_mtx while calling blist_fill (:542), blist_destroy (:572), blist_resize (:577) on the global swapblist radix tree. The pager (swp_pager_getswapspace at swap_pager.c:532/:536 and swp_pager_freeswapspace at :583/:587) holds only vm_token while calling blist_allocat/blist_free on the SAME tree. Neither lock nests the other (swaponvp at vm_swap.c:263 correctly takes vm_token THEN swap_mtx; sys_swapoff at :414 takes only swap_mtx). Live reproduction on the unpatched #0 kernel: root toggled a small 8MB secondary swap device 200 times (all 200 swapoff reached blist_fill/blist_resize, rc=0) while unprivileged user maxx drove 3.25GB of anonymous paging pressure. During the toggle phase, 'swap_pager_getswapspace: swap full allocating 16 pages' appeared in the serial console DESPITE ~2GB of swap being free (vm.swap_free=520490 pages). This false-positive swap exhaustion is the signature of blist tree corruption from concurrent radix-tree mutation: blist_allocat returned SWAPBLK_NONE because bighint fields were corrupted by the racing blist_fill/blist_resize. Concurrently vm.swap_anon_use grew 198931->530006 (pager struggling with the corrupted tree).