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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0952 Β· 11 files
FileTypeDescriptionSize
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
README.md readme build/run/expected
↓ download 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.

VERDICT.md verdict source trace + why the trigger needs vkernel RUN setup
↓ download raw

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_testable

compile 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.