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

pbuf reservation counters (pfreecnt): unsynchronized check-then-decrement in getpbuf*/trypbuf* drives counters negative and past their caps

Field Value
ID DF-2865
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:L
CWE CWE-362 / CWE-367
File sys/vm/vm_pager.c
Lines 418/463/512 (gates) vs 445/490/539/578/610 (decrements)
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

The per-subsystem pbuf reservation counters (nsw_rcount, nsw_wcount_, v_pbuf_count, mnt_pbuf_count, nfs/smbfs/vinum) gate concurrency in getpbuf/trypbuf*, but the *pfreecnt <= 0 gate is not atomic with the atomic_add_int(pfreecnt, -1) decrement β€” and the bucket spinlock held at the decrement is per-bucket, so CPUs are typically under different locks. N CPUs can each observe the counter at 1, each pass, and each decrement: a cap-1 reservation is driven to 1βˆ’N with N pbufs in flight. Proven on the 6-cpu guest: counter observed at βˆ’1 and 2 simultaneous holders against cap 1. Effects: caps violable (transient over-consumption of the shared pools) and negative excursions block getpbuf sleepers while pbufs are actually free (extra latency stacked on DF-0953's wake thresholds). Unpriv-reachable via clustered file I/O and swap-in pressure. Self-correcting per release β€” latency-class DoS, no corruption. Distinct from DF-0953 (release-side wake thresholds): different function, lines, fix.

Proof of contest

VERIFIED (findings/poc/DF-2865/pbufres.ko, KLD, stock INVARIANTS, one thread per CPU): vm.pbres_neg=2, vm.pbres_min=-1, vm.pbres_maxinflight=2 (identical on an independent boot). Fix (cmpxchg reservation loop in all five allocators; buffer put back on lost race) validated on rebuilt kernel #1: neg=0, min=0, maxinflight=1, clean unload.

Validated fix.diff in findings/poc/DF-2865/.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of vm_pager.c (GLM 5.3); KLD repro + fix validation same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2865 Β· 13 files
FileTypeDescriptionSize
README.md β€” 3.3 KB ↓ raw
VERDICT.md β€” 3.0 KB ↓ raw
pbufres.c β€” 5.0 KB view raw
build.sh β€” 444 B view raw
run.sh β€” 721 B view raw
build.log β€” 5.6 KB view raw
run.log β€” 1.5 KB view raw
run.2.log β€” 592 B view raw
run.patched.log β€” 624 B view raw
fix.diff β€” 3.6 KB view raw
fix_build.log β€” 5.6 MB ↓ download
env.txt β€” 270 B view raw
verdict.json β€” 5.7 KB view raw

DF-2865 β€” pbuf reservation counters: unsynchronized check-then-decrement race

sys/vm/vm_pager.c β€” getpbuf() / getpbuf_kva() / getpbuf_mem() / trypbuf() / trypbuf_kva().

The bug

The per-subsystem pbuf reservation counters (pfreecnt: nsw_rcount, nsw_wcount_sync/async, vp->v_pbuf_count, mp->mnt_pbuf_count, nfs/smbfs/vinum counters) are meant to cap how many pbufs one subsystem holds concurrently. The allocator gates on the counter without any lock (vm_pager.c:418/463/512):

while (pfreecnt && *pfreecnt <= 0) {      /* unlocked read */
        tsleep_interlock(pfreecnt, 0); ...
}
... bucket scan ...
spin_lock(&bswspin_kva[iter]);            /* per-BUCKET spinlock */
TAILQ_REMOVE(...);
if (pfreecnt)
        atomic_add_int(pfreecnt, -1);     /* :489-490 */

Because the gate and the decrement are not atomic together β€” and the spinlock is per-bucket (iter = mycpuid & BSWHMASK), so two CPUs are typically under different locks β€” N CPUs can each observe *pfreecnt == 1, each pass the gate, and each decrement, driving a counter that should never go below 0 to 1-N and putting N pbufs in flight for a reservation of 1.

Consequences: 1. Subsystem pbuf caps are violable (over-consumption of the shared kva/raw pools by up to ncpu-1 concurrent allocators). 2. Negative excursions make getpbuf* sleepers block on *pfreecnt <= 0 while pbufs are actually free β€” an additional recovery delay stacked on the (separately filed, DF-0953) wake threshold defect.

Unprivileged reachability: trypbuf_kva(&mnt_pbuf_count) / getpbuf_kva(&mnt_pbuf_count) run for every clustered file read/write (sys/kern/vfs_cluster.c:924-927, 1525-1527); nsw_rcount runs for swap-in under memory pressure (sys/vm/swap_pager.c:1447).

Distinct from DF-0953 (known): DF-0953 is the release-side wake threshold in relpbuf(); this is the allocator-side check/decrement atomicity. Different function, different lines, different fix.

PoC

pbufres.c β€” KLD harness. Six kernel threads (one per CPU) hammer getpbuf_kva()/relpbuf() against a reservation counter with cap 1, tracking the minimum observed counter value and the maximum number of simultaneous pbuf holders.

./build.sh          # build pbufres.ko on the guest
./run.sh            # load, spin 10 s, read stats, rescue, unload

Success criterion (stock kernel): vm.pbres_neg > 0, vm.pbres_min < 0, vm.pbres_maxinflight > 1. On a kernel with the cmpxchg reservation fix: all three clean (neg=0, min >= 1, maxinflight <= 1).

vm.pbres_bump=100 is harness plumbing only: it wakes sleepers left gated by the known DF-0953 wake-threshold defect so the module can be unloaded cleanly (the stall it relieves is DF-0953's, not this finding's).

Artifacts

  • run.2.log β€” decisive stock-kernel run: pbres_neg: 2, pbres_min: -1, pbres_maxinflight: 2, console PBUFRES: counter NEGATIVE (-1), cap=1.
  • run.log β€” first stock run (same numbers; the unload step timed out because cap=1 also exercises DF-0953's missed wakeup β€” 5 threads asleep while vm.pbuf_kva_count: 512, i.e. the pool 100 % idle).
  • fix.diff β€” cmpxchg reservation (all five allocators) and the DF-2866 pagertab[] OBJT_MARKER slot (combined kernel build).
  • run.patched.log β€” same harness on the fixed kernel.
VERDICT.md
↓ download raw

DF-2865 β€” VERDICT

Status: reproduced (stock kernel) / fixed (patched kernel). Impact: dos (reservation-counter integrity + transient I/O throttling corruption). Not memory corruption.

What was run

KLD harness pbufres.c (6 kernel threads, one per CPU, reservation counter cap = 1, getpbuf_kva()/relpbuf() hammered for 10 s) on the single-tenant QEMU guest, stock INVARIANTS kernel DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (env.txt).

Baseline (stock kernel) β€” run.2.log (and run.log, independent boot)

vm.pbres_neg: 2            # counter observed < 0 twice in 10 s
vm.pbres_min: -1           # reservation counter driven below zero
vm.pbres_maxinflight: 2    # TWO pbufs held simultaneously, cap was 1
PBUFRES: counter NEGATIVE (-1), cap=1, inflight=1     (console)

maxinflight = 2 with cap = 1 is the direct, race-proven statement of the finding: two CPUs passed the *pfreecnt <= 0 gate (vm_pager.c:463) with the counter at 1 and both decremented (vm_pager.c:489-490), exactly as read from the source. The negative counter is the observable residue of the same race.

Incidentally, run.log (first run, before the rescue sysctl existed) also captured the known DF-0953 behaviour end-to-end: 5 threads asleep on the reservation gate while vm.pbuf_kva_count: 512 β€” pool 100 % idle β€” because relpbuf() only wakes pfreecnt sleepers on the 1β†’2 transition. That stall is DF-0953's defect (do-not-re-report); this finding is the allocator-side atomicity hole that additionally drives the counter negative and past its cap.

Patched kernel (fix.diff: cmpxchg reservation in all five allocators)

Combined kernel build make -j6 nativekernel KERNCONF=X86_64_GENERIC (+ the DF-2866 pagertab hunk), see run.patched.log / fix_build.log:

vm.pbres_neg: 0
vm.pbres_min: 1            # never below the floor
vm.pbres_maxinflight: 1    # cap respected

The race window is closed: the reservation is taken with atomic_cmpset_int(pfreecnt, c, c-1) re-validating c > 0; on failure the buffer is put back and the outer gate re-entered.

Why not higher severity

The counter is self-correcting (each over-decrement is matched by its release), so the effect is transient over-consumption and extra blocking latency on subsystems whose throttles exist to keep the shared pools fair (swap-in, clustered VFS I/O). No memory-safety consequence; impact ceiling is local I/O-latency DoS β€” the same family DF-0953 occupies, with a distinct root cause and fix.

Kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff to /usr/src in-guest (patch -p1, 6/6 hunks incl. the DF-2866 hunk), make -j6 nativekernel KERNCONF=X86_64_GENERIC + installkernel, rebooted into kernel #1. Exact same harness: baseline neg=2/min=-1/maxinflight=2; patched neg=0/min=0/maxinflight=1 with clean rescue/unload and normal ssh/dmesg operation. Reservation invariant holds; no regression seen.

["findings/poc/DF-2865/run.patched.log: 'vm.pbres_neg: 0 / vm.pbres_min: 0 / vm.pbres_maxinflight: 1' + 'PBUFRES: done. neg=0 min=0 maxinflight=1'", 'findings/poc/DF-2865/fix_build.log: full nativekernel build log (BUILD-OK)', 'findings/poc/DF-2865/fix.diff: git-apply-able (verified --check against pristine sys/)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 14:41:38 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user: (1) generate concurrent clustered file I/O (reads/writes on any filesystem, vfs_cluster.c:924-927/1525-927 trypbuf_kva/getpbuf_kva against mnt_pbuf_count or v_pbuf_count) and/or force swap-in under memory pressure (swap_pager.c:1447 getpbuf_kva(&nsw_rcount)); (2) when such a counter sits near 0 under load, CPUs racing through the unlocked gate over-decrement it: the subsystem transiently exceeds its pbuf cap (over-consumption of the shared kva/raw pools, squeezing unrelated subsystems) and the counter dips negative, so getpbuf sleepers block on *pfreecnt<=0 while pbufs are actually free, extending stalls already made likely by DF-0953's wake thresholds; (3) self-correcting per release, so ceiling is local I/O-latency DoS, not memory corruption.

Evidence (decisive lines)

["run.2.log: 'vm.pbres_neg: 2 / vm.pbres_min: -1 / vm.pbres_maxinflight: 2' + console 'PBUFRES: counter NEGATIVE (-1), cap=1, inflight=1'", 'run.log: independent stock boot, same numbers; also shows 5 threads asleep on the reservation gate while vm.pbuf_kva_count=512 (pool 100% idle) - the known DF-0953 stall semantics this finding compounds', "run.patched.log (kernel #1 with fix.diff): 'vm.pbres_neg: 0 / vm.pbres_min: 0 / vm.pbres_maxinflight: 1', clean rescue+unload", 'build.log / fix_build.log: module build and full nativekernel build logs']

PoC changes

PoC authored from scratch (no seed). Iterations: (1) volatile-qualified counters tripped -Werror=cast-qual in the module build -> plain int + atomics, matching kernel style; (2) first stock run wedged kldunload because cap=1 also exercises the known DF-953 wake-threshold stall (all sleepers gated, pbuf pool idle) -> added vm.pbres_bump rescue sysctl (pbuf_adjcount-style add+wakeup) for clean teardown; harness semantics unchanged.

Verified recommended fix

Reserve the pfreecnt slot atomically at the point of dequeue: cmpxchg loop re-validating c>0 before c-1 (atomic_cmpset_int), put the buffer back on the free list and re-enter the gate when the reservation is lost; applied to getpbuf/getpbuf_kva/getpbuf_mem/trypbuf/trypbuf_kva - see fix.diff.

Verdict

Reproduced on the 6-cpu stock INVARIANTS guest with a KLD harness that gives getpbuf_kva()/relpbuf() a per-subsystem reservation counter of cap 1 hammered by one kernel thread per CPU: the counter was observed NEGATIVE (vm.pbres_min=-1, vm.pbres_neg=2 in 10 s, console 'PBUFRES: counter NEGATIVE (-1)') and TWO pbufs were held simultaneously against the cap-1 reservation (vm.pbres_maxinflight=2), proving the <=0 gate at vm_pager.c:463 is not atomic with the decrement at :489-490 (per-bucket spinlocks give no cross-CPU exclusion for the counter). This is the allocator-side atomicity hole; it is distinct from known DF-0953 (release-side wake thresholds in relpbuf), though both manifest as unprivileged I/O latency (vfs_cluster.c mnt_pbuf_count / v_pbuf_count and swap_pager.c nsw_rcount are the real-world counters). fix.diff (atomic_cmpset_int reservation loop in all five allocators, buffer put back on lost race) validated on an in-guest rebuilt kernel #1: same harness shows neg=0, min=0, maxinflight=1 and clean unload.