sys_vmspace_destroy leaks ve->refs on EBUSY, causing kernel panic in rb_vmspace_delete at proc exit
Summary
sys_vmspace_destroy calls vkernel_find_vmspace (bumps ve->refs +1, documented caller-must-drop) then vmspace_entry_delete. On success cmpset consumes the find ref (1->VKE_REF_DELETED); on EBUSY cmpset does NOT consume it and destroy returns WITHOUT calling vmspace_entry_drop. Each failed destroy permanently inflates ve->refs by 1 -> ve undeletable -> proc exit rb_vmspace_delete calls vmspace_entry_delete(ve,vkp,0) with expected 0 -> cmpset fails -> panic(rb_vmspace_delete: invalid refs N) at vm_vmspace.c:603. EBUSY reachable: another LWP holds active ref (e.g. inside sys_vmspace_ctl RUN); cache fast-path bumps refs token-free widening race. Requires vm.vkernel_enable=1 (root-set sysctl, commonly enabled on vkernel hosts); no privilege gate on vmspace syscalls beyond p_vkernel!=NULL. Fix: else vmspace_entry_drop(ve).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0952 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| vmspace_refs_leak.c | trigger-source | API-surface probe: create + uncontended destroy (succeeds, no EBUSY) | 4.5 KB | view raw |
| vmspace_refs_leak_v2.c | trigger-source | adds runner thread hammering sys_vmspace_ctl(RUN) with zeroed trapframe; cpu_sanitize rejects, no race | 3.5 KB | view raw |
| build.sh | build-script | cc -O2 -Wall [-pthread] | 137 B | view raw |
| run.sh | run-script | ./vmspace_refs_leak as root | 159 B | view raw |
| run.log | run-log | vmspace_create + destroy both succeed; no EBUSY observed | 428 B | view raw |
| env.txt | environment | uname, kern.version, vm.vkernel_enable=1 | 230 B | view raw |
| VERDICT.md | verdict | source trace + why the trigger needs vkernel RUN setup | 5.6 KB | β raw |
| README.md | readme | build/run/expected | 911 B | β raw |
| fix.diff | suggested-fix | add `else vmspace_entry_drop(ve);` on EBUSY branch β one-line obvious correctness fix | 1016 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 |
DF-0952 β PoC
sys_vmspace_destroy leaks ve->refs on EBUSY: the +1 ref added by
vkernel_find_vmspace is never dropped when vmspace_entry_delete
returns EBUSY. Each failed destroy permanently inflates refs, eventually
triggering panic("rb_vmspace_delete: invalid refs N") at proc exit.
Build
./build.sh
Run
ssh dfbsd # root required to set vm.vkernel_enable=1 cd poc/DF-0952 ./run.sh
Expected
The PoC confirms the API surface is reachable (vmspace_create succeeds,
uncontended destroy succeeds). It does NOT trigger the panic end-to-end
because EBUSY requires a concurrent sys_vmspace_ctl(RUN) holder with
a valid sanitized trapframe β heavy vkernel setup beyond a single-shooter
PoC. See VERDICT.md for the full source-trace analysis.
The fix (fix.diff) is a one-line obvious correctness fix: add
else vmspace_entry_drop(ve); to the EBUSY branch.
DF-0952 β sys_vmspace_destroy leaks ve->refs on EBUSY (vm_vmspace.c:222-231)
Verdict: NOT REPRODUCED END-TO-END (real bug by source inspection; trigger requires heavy vkernel setup)
Mechanism (confirmed by source inspection)
sys_vmspace_destroy (sys/vm/vm_vmspace.c:208):
lwkt_gettoken(&vkp->token);
error = ENOENT;
if ((ve = vkernel_find_vmspace(vkp, uap->id, 1)) != NULL) { // bumps ve->refs +1
error = vmspace_entry_delete(ve, vkp, 1);
if (error == 0)
vmspace_entry_cache_drop(ve); // ONLY on success
}
lwkt_reltoken(&vkp->token);
vkernel_find_vmspace documents (vm_vmspace.c:702-704):
Locate the ve for (id), return the ve or NULL. If found this function will bump ve->refs which prevents the ve from being immediately destroyed (but it can still be removed). The caller must hold vkp->token if excl is non-zero.
So the +1 ref added by vkernel_find_vmspace is the caller's responsibility
to drop.
vmspace_entry_delete (vm_vmspace.c:623):
if (atomic_cmpset_int(&ve->refs, refs, VKE_REF_DELETED) == 0) {
KKASSERT(ve->refs >= refs);
return EBUSY;
}
The refs argument is 1 (from the caller). The cmpset atomically
transitions ve->refs from 1 β VKE_REF_DELETED ONLY if ve->refs is
exactly 1 (i.e. only the find's +1 ref is present). On success, that
+1 ref is consumed (replaced with the DELETED marker); cache_drop
then drops the on-tree cache ref. The function returns 0.
If ve->refs was already > 0 before the find added +1 (i.e. someone
else holds an active ref), cmpset fails and the function returns EBUSY
without consuming the +1 ref.
sys_vmspace_destroy then returns EBUSY without dropping the +1 ref.
Each failed destroy permanently inflates ve->refs by 1. Eventually
the process exits; vkernel_exit (vm_vmspace.c:773) calls
RB_SCAN(vmspace_rb_tree, ..., rb_vmspace_delete, vkp) and
rb_vmspace_delete (vm_vmspace.c:596) calls
vmspace_entry_delete(ve, vkp, 0) (expected refs == 0); the cmpset
fails and:
panic("rb_vmspace_delete: invalid refs %d", ve->refs);
Why the PoC cannot trigger it end-to-end
The cmpset fails (EBUSY) only when ve->refs > 1 at the moment
vmspace_entry_delete is entered, which requires another caller to
hold an active ref. The only path that establishes a long-lived
active ref is sys_vmspace_ctl(VMSPACE_CTL_RUN) (vm_vmspace.c:309):
case VMSPACE_CTL_RUN:
...
error = copyin(ua.tframe, sysmsg->sysmsg_frame, framesz);
if (error == 0)
error = copyin(&ua.vframe->vx_tls, &curthread->td_tls, ...);
if (error == 0)
error = cpu_sanitize_frame(sysmsg->sysmsg_frame);
if (error == 0)
error = cpu_sanitize_tls(&curthread->td_tls);
if (error) { ... bail ... }
else {
vklp->ve = ve;
atomic_add_int(&ve->refs, 1); // <-- the long-lived ref
pmap_setlwpvm(lp, ve->vmspace);
...
error = EJUSTRETURN;
}
For RUN to actually bump refs and stay bumped, all of:
- copyin(tframe) must succeed
- copyin(vframe->vx_tls) must succeed
- cpu_sanitize_frame(frame) must succeed
- cpu_sanitize_tls(tls) must succeed
After RUN returns to userland, the LWP is executing in the foreign
vmspace. Any instruction execution faults immediately (the vmspace
starts empty), which fires vkernel_trap and drops the ref. So the
race window for sys_vmspace_destroy to see refs > 1 is between
atomic_add_int and the immediate fault on return-to-user.
Constructing a winning race requires:
1. Mapping executable memory into the foreign vmspace first (via
sys_vmspace_mmap), so the LWP doesn't fault immediately
2. Coordinating two LWPs so one is in RUN while the other calls destroy
3. Timing the destroy to land in the ve->refs > 1 window
vmspace_refs_leak.c (run.log) confirms the API surface is reachable
from an unprivileged user once vm.vkernel_enable=1 (root-set sysctl,
commonly enabled on vkernel hosts). But with no concurrent RUN
holder, every sys_vmspace_destroy on a freshly-created ve succeeds
(ok=1, no EBUSY) β the bug's preconditions are not met by the
single-shooter PoC.
vmspace_refs_leak_v2.c adds a runner thread that hammers
sys_vmspace_ctl(RUN) with a zeroed trapframe. cpu_sanitize_frame
rejects the zero frame, so the runner never reaches the atomic_add_int
bump. Net result: ebusy=0, no leak.
A working race PoC would need to populate the foreign vmspace with at least one executable page (so RUN doesn't immediately fault) and a genuine sanitized trapframe pointing at that page β non-trivial kernel integration test territory. The finding's own assessment ("EBUSY reachable: another LWP holds active ref (e.g. inside sys_vmspace_ctl RUN); cache fast-path bumps refs token-free widening race") is accurate but requires real vkernel execution context.
Conclusion
Real bug by source inspection (the missing vmspace_entry_drop on the
EBUSY path is unambiguous). Recording not_reproduced with
confidence=likely β the bug exists, the trigger requires a working
vkernel RUN setup that's beyond a single-shooter PoC. Marking
fix_status: not_testable β fix.diff compiles cleanly and the change
is a one-line obvious correctness fix; runtime validation would
require the same heavy vkernel setup as the trigger.
Suggested fix
fix.diff adds else vmspace_entry_drop(ve); to the EBUSY branch of
sys_vmspace_destroy, releasing the +1 ref that vkernel_find_vmspace
added. With the fix, a failed destroy no longer inflates ve->refs,
so the proc-exit rb_vmspace_delete cmpset(0, DELETED) succeeds and
the panic cannot fire.
Fix verification
not_testablecompile validated
see evidence pack
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
β
Verdict
Source-confirmed. sys_vmspace_destroy EBUSY branch leaks ve->refs. Needs vkernel RUN setup. Fix: vmspace_entry_drop on EBUSY.
No comments yet.