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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2865 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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, consolePBUFRES: 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 whilevm.pbuf_kva_count: 512, i.e. the pool 100 % idle).fix.diffβ cmpxchg reservation (all five allocators) and the DF-2866pagertab[]OBJT_MARKER slot (combined kernel build).run.patched.logβ same harness on the fixed kernel.
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
- sys/vm/vm_pager.c:417-422 (unlocked gate, getpbuf), :442-447 (raw decrement under per-bucket lock)
- sys/vm/vm_pager.c:462-467, :487-491 (getpbuf_kva)
- sys/vm/vm_pager.c:511-516, :536-540 (getpbuf_mem)
- sys/vm/vm_pager.c:564-578 (trypbuf), :596-610 (trypbuf_kva)
- Real-world counters: sys/vm/swap_pager.c:1447,163-166; sys/kern/
vfs_cluster.c:924-927,1525-1527; sys/sys/mount.h:221 (u_int
mnt_pbuf_count β also type-punned through the
int *API); sys/sys/vnode.h:153.
Fix verification
fixedApplied 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/)']
Confirmed kernel references
- sys/vm/vm_pager.c:417
- sys/vm/vm_pager.c:418
- sys/vm/vm_pager.c:444
- sys/vm/vm_pager.c:445
- sys/vm/vm_pager.c:463
- sys/vm/vm_pager.c:489
- sys/vm/vm_pager.c:490
- sys/vm/vm_pager.c:512
- sys/vm/vm_pager.c:538
- sys/vm/vm_pager.c:539
- sys/vm/vm_pager.c:570
- sys/vm/vm_pager.c:578
- sys/vm/vm_pager.c:602
- sys/vm/vm_pager.c:610
- sys/vm/swap_pager.c:1447
- sys/kern/vfs_cluster.c:924
- sys/kern/vfs_cluster.c:1525
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.
No comments yet.