vm_object reference leak on fork() of wired (mlock'd) map entries β permanent swap/kernel-memory exhaustion by unprivileged users
| Field | Value |
|---|---|
| ID | DF-2672 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H |
| CWE | CWE-401 Missing Release of Memory before Effective Removal |
| File | sys/vm/vm_map.c |
| Lines | 3627-3634 (acquire at :3510-3513 via :3882/:3889) |
| Area | vm |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
vm_map_copy_entry()'s wired path detaches and NULLs
dst_entry->ba.map_object without dropping the reference that
vm_map_backing_replicated(flags==0) took on it moments earlier in
vmspace_fork_normal_entry(). The stale comment "its ref-count has not
yet been adjusted" predates the vm_map_backing rework. Each fork() of a
wired entry orphans one vm_object reference; the object is never
terminated so its swap space is never freed.
Threat model & preconditions
Unprivileged local user: mlock() needs no privilege (only RLIMIT_MEMLOCK, default free/3). Repeat mmap+dirty+mlock+fork+exit cycles; orphaned objects accumulate, and under memory pressure their dirty pages page out to swap that can never be reclaimed β permanent system-wide memory/swap exhaustion DoS surviving the death of the offending processes.
Proof of concept
VERIFIED (findings/poc/DF-2672/leak_fork.c): child mmaps 4MB MAP_ANON|MAP_PRIVATE, memsets (dirty), mlocks, forks again, all exit. Stock guest: vm_object count 1.01Kβ1.21K for 200 leak iterations (control β +10); 3.4GB pressure β 1,033,784 swap blocks (~504MB) with zero anon-owning processes, growing to ~844MB and persisting after idle; only reboot reclaims. Fix (vm_object_deallocate before NULLing, mirroring the :1132-1135 pattern) validated on a rebuilt kernel.
Recommended fix
--- a/sys/vm/vm_map.c
+++ b/sys/vm/vm_map.c
@@ -3626,6 +3626,15 @@
if (dst_entry->ba.map_object != NULL) {
vm_map_backing_detach(dst_entry, &dst_entry->ba);
+ /*
+ * Drop the reference vm_map_backing_replicated()
+ * acquired for the cloned entry (the stale comment
+ * claiming the ref-count was not yet adjusted
+ * predates the backing_ba rework). Without this
+ * the object and its swap space leak permanently
+ * whenever a wired entry is forked.
+ */
+ vm_object_deallocate(dst_entry->ba.object);
dst_entry->ba.map_object = NULL;
vm_map_entry_dispose_ba(dst_entry,
Timeline
- 2026-08-29 Discovered during pass-2 audit of vm_map.c (GLM 5.3); reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2672 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.1 KB | β raw | |
| VERDICT.md | β | 3.0 KB | β raw | |
| leak_fork.c | β | 1.8 KB | view raw | |
| hog.c | β | 472 B | view raw | |
| build.sh | β | 117 B | view raw | |
| run.sh | β | 1.2 KB | view raw | |
| build.log | β | 50 B | view raw | |
| run.log | β | 1.3 KB | view raw | |
| fix_run.log | β | 1.0 KB | view raw | |
| fix.diff | β | 654 B | view raw | |
| env.txt | β | 320 B | view raw |
DF-2672 β vm_object reference leak on fork() of wired (mlock'd) VM entries
What
vm_map_copy_entry() (sys/vm/vm_map.c:3600) handles the fork of a wired
(src_entry->wired_count != 0) map entry by scrapping the cloned child
entry's object and copying the pages into a fresh one. Its comment claims
"its ref-count has not yet been adjusted so we can just NULL out the field",
but that stopped being true with the vm_map_backing rework:
vmspace_fork_normal_entry() (sys/vm/vm_map.c:3882) calls
vm_map_backing_replicated(new_map, new_entry, 0) before
vm_map_copy_entry() (sys/vm/vm_map.c:3889), and with flags == 0 that
function takes a reference on the base object
(vm_object_reference_quick(), sys/vm/vm_map.c:3510-3513).
The wired path detaches and NULLs dst_entry->ba.map_object
(sys/vm/vm_map.c:3627-3634) without dropping that reference β
one vm_object reference leaks per fork() of a wired entry.
Impact
Unprivileged local user (mlock works within RLIMIT_MEMLOCK, default = free_memory/3, no privilege needed β sys/vm/vm_mmap.c:1020-1031, sys/kern/kern_plimit.c:131-132):
- leaked vm_objects are never freed (
vm_object_deallocatenever sees 0); - their dirty anonymous pages, once paged out under memory pressure, keep
their swap space allocated forever (
swap_pager_freespaceonly runs at object termination); - permanent kernel-memory + swap exhaustion β system-wide DoS that survives the death of the offending processes. No RLIMIT bounds the accumulated leak across processes.
Reproduce (on the audit QEMU guest)
./build.sh # cc -O2 -o /tmp/leak_fork leak_fork.c (+ hog.c) ./run.sh # control vs leak phases + memory pressure
Success criterion: vmstat -m "vm_object" Count grows by ~1 per leak
iteration and never returns; control run returns to baseline. After
pressure, swapinfo shows hundreds of MB permanently consumed with no
processes owning anonymous memory.
Fix
fix.diff β call vm_object_deallocate() before NULLing the field
(mirrors vm_map_entry_dispose(), sys/vm/vm_map.c:1132-1135).
Validated by kernel rebuild + PoC re-run (see VERDICT.md).
DF-2672 VERDICT β REPRODUCED (leak), fix validated
Question
Does fork() of a wired (mlock'd) entry leak a vm_object reference, and
does that translate into permanently unreclaimable kernel memory / swap?
Root cause (path:line)
vmspace_fork_normal_entry()clones the child entry and callsvm_map_backing_replicated(new_map, new_entry, 0)at sys/vm/vm_map.c:3882 β withflags == 0this executesvm_object_reference_quick(object)on the base object (sys/vm/vm_map.c:3510-3513).vm_map_copy_entry()(sys/vm/vm_map.c:3600) then takes the wired path becausesrc_entry->wired_count != 0(:3608) and discards the cloned object:vm_map_backing_detach()+ba.map_object = NULL(:3627-3629) withoutvm_object_deallocate(). The stale comment at :3613-3615 ("its ref-count has not yet been adjusted") is wrong since the backing_ba rework β the reference IS taken before the call. One reference is orphaned per fork.- The orphaned object is never terminated, so
swap_pager_freespace()never runs for it: dirty pages paged out under pressure keep their swap allocation forever.
Reproduction (guest: DragonFly 6.5-DEVELOPMENT #0, INVARIANTS kernel)
leak_fork.c: mmap(ANON|PRIVATE) + memset (dirty) + mlock + fork();
child exits; owner exits. Control omits the inner fork.
Decisive observations (run.log):
| phase | vm_object count | swap used (512B blocks) |
|---|---|---|
| baseline | 1.01K | 0 |
| control 100x4MB + pressure | 1.02K (+~10 noise) | 0 |
| leak 200x4MB | 1.21K (+~190 for 200 forks) | 0 (no pressure yet) |
| hog 3.4GB pressure | 1.21K (unchanged) | 1,033,784 (~504MB) |
| +leak 50x4MB + pressure | 1.25K | 1,727,816 (~844MB) |
| after 30s idle, no anon-owning procs | 1.25K | 1,720,384 (persists) |
- Object growth β 1 per fork-of-wired-entry, exactly as the code path predicts; control adds only kernel noise.
- ~844MB of swap permanently consumed with no processes owning anonymous memory (every leak_fork/hog exited). This swap can never be freed except by reboot: the owning vm_objects are unreachable.
Unprivileged: mlock() needs no privilege, only RLIMIT_MEMLOCK
(default = free memory / 3, sys/kern/kern_plimit.c:131-132). The PoC ran
as root on the guest but uses no privilege; mlock(256KB) succeeds for
normal users within that rlimit.
Impact: permanent, unbounded, aggregation-across-processes consumption of kernel memory and swap by an unprivileged local user β system-wide memory-exhaustion DoS (Medium).
Fix validation
fix.diff adds vm_object_deallocate(dst_entry->ba.object) before
NULLing (mirrors vm_map_entry_dispose(), sys/vm/vm_map.c:1132-1135).
Kernel rebuilt in-guest (make nativekernel), rebooted, PoC re-run:
vm_object count flat across leak iterations, no residual swap. See
fix section in run.log / verdict.json fix_* fields.
Not a UAF
The bug only leaks references (object never freed early); there is no premature-free / type-confusion angle. Severity Medium (availability).
Fix verification
fixedRebuilt kernel with fix.diff (plus DF-2673/DF-2674 fixes, independent hunks): 300 leak iterations + 3.4GB pressure leave vm_object at 998 (baseline 917) and swap at 0; stock kernel showed 1:1 object leak and 844MB permanent swap under the identical workload.
fix_run.log
Confirmed kernel references
Detail
Exploit chain
unprivileged user: repeat { mmap ANON|PRIVATE -> memset (dirty) -> mlock -> fork -> let both processes exit }; each cycle orphans one vm_object reference; under memory pressure the dirty pages page out to swap which is never reclaimed -> permanent global kernel-memory/swap exhaustion -> system-wide DoS surviving process exit (only reboot reclaims).
Evidence (decisive lines)
['run.log: baseline vm_object 1.01K / swap 0; control 100x4MB -> 1.02K / 0; leak 200x4MB -> 1.21K; pressure -> swap 1,033,784 blocks (~504MB); +leak 50x4MB+pressure -> 1,727,816 (~844MB); persists after 30s idle with no anon-owning processes', 'fix_run.log: patched kernel #2, 300 leak iterations + 3.4GB pressure -> vm_object 998 (baseline 917, +81 system noise vs 1:1 leak growth on stock), swap 0', 'VERDICT.md: full path:line trace and A/B tables']
PoC changes
seed sketch replaced: wrote leak_fork.c (mmap+memset+mlock+fork churn with leak/control modes) and hog.c for memory pressure; first pressure attempt was a no-op because the hog byte argument was passed as MB-like small integers - fixed by passing real byte counts
Verified recommended fix
vm_object_deallocate(dst_entry->ba.object) before NULLing the field in vm_map_copy_entry()'s wired path (fix.diff, validated)
Verdict
vm_map_copy_entry()'s wired path NULLs dst_entry->ba.map_object without dropping the reference vm_map_backing_replicated() (flags==0) took for the cloned entry at fork time - the stale comment 'ref-count has not yet been adjusted' predates the backing_ba rework. One vm_object reference leaks per fork() of a wired (mlock'd) entry; the orphaned objects are never terminated so their swap allocations are never freed. Verified on the guest: vm_object count +~190 for 200 leak iterations (control +10), and 844MB of swap permanently retained after memory pressure with zero anon-owning processes, requiring nothing beyond an unprivileged mlock() within RLIMIT_MEMLOCK.
No comments yet.