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

swap_pager_getpage() 20-second timeout diagnostic dereferences the completed-and-released pbuf (use-after-free read in console path)

Field Value
ID DF-2701
Status new
Severity Info
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L
CWE CWE-672 / CWE-416
File sys/vm/swap_pager.c
Lines 1494-1496 (invalidation), 1527-1535 (derefs), 2099-2107 (relpbuf)
Area vm
Confidence likely
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

swap_pager_getpage() declares bp invalid after vn_strategy() because swp_pager_async_iodone() releases it via relpbuf() on completion. Yet when the hz*20 tsleep times out, the diagnostic kprintf dereferences bio->bio_offset and bp->b_bcount. If the stalled I/O completes in the window between the timeout wakeup and the dereference, the pbuf has been freed to the pbuf zone and may already be reused β€” the kprintf then reads and prints freed/reused kernel memory. Impact confined to a console/dmesg diagnostic (values only); triggering additionally requires a swap read stalled >20s plus a microsecond-scale race.

Snapshot the two values before dispatch and print the locals (diff in the row above / findings/poc/DF-2701/).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of swap_pager.c (GLM 5.3); negative-result validation (400 swap-alloc records) run live.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2701 Β· 5 files
FileTypeDescriptionSize
README.md β€” 1.5 KB ↓ raw
VERDICT.md β€” 4.0 KB ↓ raw
verdict.json β€” 2.5 KB view raw
hog.c β€” 621 B view raw
swap_allocs.txt β€” 8.7 KB view raw

DF-2701 β€” swap_pager_getpage() timeout diagnostic dereferences a pbuf that completion may already have freed

Status: untested (Info severity β€” below the verification threshold; mechanism is certain, trigger needs a >20 s stalled swap I/O)

Build / run (of the accompanying negative-result validation, which WAS run)

# on the guest, as root:
sysctl vm.report_swap_allocs=400
cc -O2 -o /tmp/hog hog.c && /tmp/hog      # ~5 GB touch storm on a 4 GB box
dmesg | grep swap_alloc > swap_allocs.txt # 400 (blk, n) allocation records

Expected (validation, observed): 400 allocations, 268 at non-16-aligned offsets (heavy fragmentation), 0 runs crossing a SWB_DMMAX stripe boundary, 40 runs ending exactly on a stripe boundary (live fire of known DF-0948's trigger), all runs confined to interleave slot 0 (single active swap device).

The finding itself (not reproduced; Info)

sys/vm/swap_pager.c:1486-1490 states bp is invalid after vn_strategy(). sys/vm/swap_pager.c:1527-1535 violates that invariant: when the 20 s tsleep() times out, the kprintf dereferences bio->bio_offset and bp->b_bcount β€” if the I/O completes in the window between timeout wakeup and the kprintf, swp_pager_async_iodone() has already relpbuf()'d the bp and it may have been reused by any other swap I/O. UAF-read confined to a console diagnostic.

Fix sketch

Save bio_offset and b_bcount into locals before vn_strategy() (alongside blk/i) and print those.

VERDICT.md
↓ download raw

DF-2701 VERDICT β€” untested (Info)

What was found

swap_pager_getpage() (sys/vm/swap_pager.c) dispatches its read with vn_strategy(swapdev_vp, bio) at :1496 and its own comment at :1486-1490 declares bp invalid from that point ("bp cannot be considered valid after this point because we automatically release it on completion"). Nevertheless, when the 20-second tsleep() at :1527 times out, the diagnostic kprintf at :1528-1535 dereferences bio->bio_offset (:1533) and bp->b_bcount (:1534). Completion (swp_pager_async_iodone β†’ relpbuf, :2099-2107) may run in the window between the timeout wakeup and the dereference, freeing the pbuf back to the pbuf zone where it can be immediately reused by any other swap I/O. The deref therefore reads freed/reused kernel memory and prints it to the console. This is a UAF read only; no control flow depends on the values.

Why Info and not higher: the trigger requires a swap read stalled for >20 s (dead/hung disk, NFS swap hiccup) and completion landing inside a microsecond-scale race window; the impact is bogus numbers in a console message. No path to disclosure (console/dmesg), corruption, or control flow.

Why not verified: per contract, Low/Info hardening findings are not PoC'd, and manufacturing a 20 s-stalled swap device plus winning the race window is not a meaningful use of the single-tenant guest. Mechanism is certain from source; rating "likely" only for the untested race window.

What was verified instead (negative-result validation for this pass)

The pass-2's central analytical claim β€” that blist allocations can NEVER cross a SWB_DMMAX(64-page) stripe, because BLIST_BMAP_RADIX == 64 == SWB_DMMAX and blst_leaf_alloc() serves runs from a single 64-aligned leaf β€” was validated live on the guest:

  • sysctl vm.report_swap_allocs=400 (swap_pager.c:1704-1707), then a ~5 GB anonymous touch storm (hog.c) on the 4 GB guest forced real pageout to the 4 GB swap device.
  • 400 swap_alloc %08jx,%d records captured (swap_allocs.txt):
  • 268 allocations at non-16-aligned offsets (real fragmentation),
  • 0 runs with blk%64 + n > 64 (no stripe crossing),
  • 40 runs with blk%64 + n == 64 β€” the exact-boundary case where the putpages stripe trim (:1713-1717) fires with n - j == 0: known DF-0948's trigger observed live 40 times (cross-evidence, not a new finding),
  • all 400 runs inside interleave slot 0 (single active device), consistent with the swaponvp interleave geometry.
  • Source proof of the geometry: sys/kern/subr_blist.c blst_meta_alloc requires count <= child radix (panics otherwise) and serves the entire run from one child; children partition [blk, blk+radix) consecutively from 0; leaves therefore cover 64-aligned linear spans == stripes.

This killed the strongest candidate of the pass (cross-device sw_nused mis-accounting via stripe-crossing allocations β†’ permanent swapoff EINTR): swapacctspace()'s single-device assumption (sys/vm/vm_swap.c:592-601) is upheld by blist geometry, not luck.

Environment

DragonFly dfbsd 6.5-DEVELOPMENT #0 (X86_64_GENERIC), 4 GB RAM, 4 GB /dev/vbd0s1b swap, stock INVARIANTS kernel, guest up and clean (hog exited, swap use back to 0).

--- a/sys/vm/swap_pager.c
+++ b/sys/vm/swap_pager.c
@@ -1490,6 +1490,8 @@ swap_pager_getpage(vm_object_t object, vm_pindex_t pindex,
    bp->b_cmd = BUF_CMD_READ;
    BUF_KERNPROC(bp);
+   /* snapshot for the timeout diagnostic: bp is invalid after strategy */
+   off_t save_offset = bio->bio_offset;
+   long save_bcount = bp->b_bcount;
    vn_strategy(swapdev_vp, bio);
@@ -1528,9 +1530,8 @@ swap_pager_getpage(vm_object_t object, vm_pindex_t pindex,
            kprintf(
                "swap_pager: indefinite wait buffer: "
                " bp %p offset: %lld, size: %ld "
                " m=%p busy=%08x flags=%08x\n",
                bp,
-               (long long)bio->bio_offset,
-               (long)bp->b_bcount,
+               (long long)save_offset,
+               (long)save_bcount,
                mreq, mreq->busy_count, mreq->flags);

(Diff authored post-analysis; never applied to sys/.)

Fix verification

not_testable
per-fix-DF-2701

Confirmed kernel references

Detail

Evidence (decisive lines)

['sys/vm/swap_pager.c:1486-1490 (bp invalid after vn_strategy)', 'sys/vm/swap_pager.c:1514-1537 (timeout loop)', 'sys/vm/swap_pager.c:1527-1535 (stale bp/bio deref in kprintf)', 'sys/vm/swap_pager.c:2099-2108 (relpbuf on completion)', 'findings/poc/DF-2701/swap_allocs.txt (400 swap_alloc records: 0 stripe crossings under heavy fragmentation; 40 exact-boundary DF-0948 trigger fires)']

PoC changes

none; finding left untested (Info). hog.c/swap_allocs.txt are the negative-result validation artifacts reused from DF-2672's storm pattern.

Verified recommended fix

Snapshot bio_offset/b_bcount into locals before vn_strategy() and print the locals in the timeout diagnostic.

Verdict

Info-severity use-after-free READ confined to the 20-second-timeout diagnostic kprintf in swap_pager_getpage(): after vn_strategy() the bp must be considered freed (relpbuf in swp_pager_async_iodone), yet the timeout path dereferences bio->bio_offset and bp->b_bcount. If completion races the timeout wakeup, freed/reused pbuf memory is read and printed to console. Not verified: triggering requires a swap I/O stalled >20 s and the impact is console output only. The same run's guest validation (findings/poc/DF-2701/swap_allocs.txt, 400 allocations) proved the pass-2 central negative claim: blist never returns runs crossing a SWB_DMMAX stripe (leaf radix 64 == SWB_DMMAX, source-proven in sys/kern/subr_blist.c blst_meta_alloc/blst_leaf_alloc), which killed the candidate cross-device sw_nused mis-accounting finding, and captured 40 live fires of known DF-0948's exact-stripe-boundary trim trigger.