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

varsymset lifetime race at process exit: sibling LWP uses p_varsymset after final varsymset_clean/lockuninit β€” permanent unprivileged M_VARSYM kernel-memory leak (plus INVARIANTS panic window in lockuninit)

Field Value
ID DF-2908
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-667 / CWE-401 / CWE-362
File sys/kern/kern_varsym.c
Lines 437-487, 283-325 (teardown: kern_exit.c:311-312 vs :319)
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket kernleak
Reported pending
Known CVE none
CVE match novel

Summary

exit1() destroys the process varsym set before the process's remaining LWPs are dead: varsymset_clean(&p->p_varsymset) + lockuninit() at kern_exit.c:311-312 run before killalllwps(0) at kern_exit.c:319. A sibling LWP concurrently inside sys_varsym_set(VARSYM_PROC,...) β€” which takes only vx_lock, never p_token, never checks P_WEXIT β€” re-inserts entries into the already-cleaned set. exit1 cleans exactly once and nothing drops those entries' vs_refs at proc teardown, so each raced insert permanently leaks a varsyment + varsym M_VARSYM allocation. Additionally, a sibling holding vx_lock exclusive in the few-instruction gap between varsymset_clean()'s internal LK_RELEASE and lockuninit()'s KKASSERT panics INVARIANTS kernels (stock X86_64_GENERIC has INVARIANTS).

Threat model & preconditions

Any unprivileged local user: fork a child with pthreads spinning varsym_set(VARSYM_PROC) while the main thread exits. Each won race leaks ~290B+ of kernel heap; measured linear scaling to 17.5MB/115K allocations from one unprivileged driver β€” unbounded kernel-memory exhaustion (memory-pressure DoS/deadlock), plus a narrow kernel-panic window on INVARIANTS builds. No corruption/UAF and no uid=0 route: leaked objects are standalone immutable allocations nothing dereferences afterwards.

Proof of contest

VERIFIED (findings/poc/DF-2908/): control run leaves M_VARSYM at 248 allocs/8.90K; race run grows it 9.06K/1.35M per 2000 iterations, permanent and linear (17.8K/2.68M at 4000; 115K/17.5M under a 20000-iter 6-spinner hammer), stock INVARIANTS kernel #0. Panic flavor attempted 26,500 races β€” not observed (few-instruction window), documented source-reasoned. Fix validated in-guest (patched kernel: move varsymset_clean+lockuninit after killalllwps in exit1): zero bytes leaked over 4000 identical race iterations.

Validated fix.diff (kern_exit.c resequencing) in findings/poc/DF-2908/.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of kern_varsym.c (GLM 5.3); leak reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2908 Β· 9 files
FileTypeDescriptionSize
varsym_exit_race.c β€” 2.7 KB view raw
build.sh β€” 152 B view raw
run.sh β€” 530 B view raw
build.log β€” 906 B view raw
run.log β€” 772 B view raw
run.patched.log β€” 443 B view raw
env.txt β€” 271 B view raw
fix.diff β€” 843 B view raw
VERDICT.md β€” 4.1 KB ↓ raw
VERDICT.md
↓ download raw

DF-2908 VERDICT β€” varsymset lifetime race at process exit

Bottom line

reproduced β€” unprivileged kernel memory leak (M_VARSYM) via a sibling-LWP race against exit1()'s varsym teardown; impact dos (unbounded kernel-memory exhaustion). The adjacent INVARIANTS panic window (lockuninit KKASSERT on a lock held by the racing sibling) is real at the source level but was not observed in ~26,500 attempts (window is a few instructions wide). Fix authored and validated in-guest: patched kernel shows zero leak under the identical workload.

Root cause (path:line)

  • sys/kern/kern_exit.c:311-312 β€” varsymset_clean(&p->p_varsymset) + lockuninit(&p->p_varsymset.vx_lock) run before killalllwps(0) (sys/kern/kern_exit.c:319). killlwps() (kern_exit.c:253-282) only then signals the sibling LWPs and waits (while (p->p_nthreads > 1) tsleep). A sibling already inside sys_varsym_set(VARSYM_PROC,...) finishes its syscall regardless.
  • sys/kern/kern_varsym.c:457 β€” varsymmake() takes only vss->vx_lock (EXCLUSIVE); it neither takes p_token nor checks p->p_flags & P_WEXIT, so nothing stops the post-clean insert (TAILQ_INSERT_TAIL, kern_varsym.c:471) into the already-cleaned set.
  • Nothing ever frees those entries: exit1 cleans exactly once; when the struct proc is torn down the entries' vs_refs == 1 is never dropped β†’ permanent M_VARSYM leak (varsyment ~24B + varsym 24+namelen+datalen+2 bytes each; our PoC leaks ~290B/win + bucket slop).
  • Panic flavor: lockuninit() (sys/kern/kern_lock.c:1325-1331) KKASSERTs the count is idle; a sibling holding EXCLUSIVE in the gap between varsymset_clean()'s internal LK_RELEASE (kern_varsym.c:545) and the assert read β†’ panic("assertion ... failed"). Guest kernel config has options INVARIANTS (sys/config/X86_64_GENERIC:56), so the assert is compiled in; window too narrow to hit from userland in our attempts.

Why the exec path is NOT affected (checked)

kern_exec.c:521 (varsymset_clean for set-id images) runs after exec_new_vmspace() β†’ killalllwps(1) (kern_exec.c:899-928) already made the process single-threaded; no sibling can race there.

Reproduction (stock INVARIANTS kernel #0, Thu Jul 2 06:02:54, 6 vCPU)

varsym_exit_race.c: child forks K pthreads spinning syscall(450, VARSYM_PROC=1, "leak2908", <232-byte value>), sleeps 2ms, then _exit(0). control mode joins the spinners before exiting.

==BASELINE==            varsym  248   8.90K
==CONTROL== (500)       varsym  248   8.90K          <- no growth
==RACE== (2000)         varsym  9.06K 1.35M          <- LEAK
(5 s later)             varsym  9.06K 1.35M          <- permanent
==RACE-2== (2000)       varsym 17.8K 2.68M           <- linear
hammer (20000, 6 spin)  varsym  115K 17.5M           <- scales

Panic flavor: 20,000-iter hammer with 6 spinners β€” no panic (guest stayed up). Honest status: not observed.

Fix validation (patched kernel #1, Thu Sep 3 06:18:40)

fix.diff moves varsymset_clean + lockuninit to after a successful killalllwps(0) (the EALREADY loser path lwp_exits and is covered by the winning exit1). Applied in-guest to /usr/src, make nativekernel + make installkernel, rebooted:

==PATCHED-BASELINE==    varsym  248   8.90K
==PATCHED-CONTROL==     varsym  248   8.90K
==PATCHED-RACE== (2000) varsym  248   8.90K          <- ZERO growth
==PATCHED-RACE-2==(2000)varsym  248   8.90K          <- ZERO growth

Baseline kernel leaked 1.34M under the same 2000-iter workload; patched kernel leaks 0 bytes across 4000 iterations. fix_status: fixed.

Exploit chain

Not a corruption primitive: leaked objects are standalone heap allocations whose references are simply lost; no UAF (entries are immutable once created, and no path dereferences them after proc teardown). Ceiling is unprivileged kernel-memory exhaustion (memory-pressure DoS), plus a narrow INVARIANTS panic window. No route to uid=0 from this bug.

Attempts

7 measurement runs total (control 500 + race 2000Γ—2 + hammer 20000 on stock; control 500 + race 2000Γ—2 on patched).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fixed β€” stock kernel #0 leaks (248->9.06K allocs / 1.35M per 2000 iters, linear to 17.5M); patched kernel #1 with fix.diff leaks ZERO bytes over 4000 identical race iterations (M_VARSYM stays 248/8.90K).

['run.patched.log: ==PATCHED-BASELINE== 248/8.90K; ==PATCHED-RACE== (2000) 248/8.90K; ==PATCHED-RACE-2== (2000) 248/8.90K', 'build.log: kernel build+install completed (make nativekernel, make installkernel), uname #1 Sep 3 06:18:40']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Thu Sep 3 06:18:40 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user -> fork child with N spinning pthreads issuing varsym_set(VARSYM_PROC) syscalls -> main thread _exit() -> sibling inserts post-clean entries -> M_VARSYM entries orphaned at proc teardown -> repeat to exhaust kernel memory (dos). No corruption/UAF path: leaked objects are standalone immutable allocations; no uid0 route.

Evidence (decisive lines)

['run.log: baseline 248/8.90K -> control unchanged -> race 2000 iters = 9.06K allocs/1.35M -> permanent (5s recheck) -> 17.8K/2.68M (linear) -> 20000-iter hammer 115K/17.5M', 'run.patched.log: patched kernel #1 (fix.diff) race 2000x2 = 248/8.90K unchanged (zero growth)', 'VERDICT.md: full narrative incl. exec-path safety analysis (killalllwps(1) at kern_exec.c:923 precedes the setuid varsymset_clean at kern_exec.c:521)', 'fix.diff: moves varsymset_clean+lockuninit after successful killalllwps(0)']

PoC changes

added #include (sig_atomic_t); otherwise the PoC is as drafted

Verified recommended fix

Move varsymset_clean(&p->p_varsymset) + lockuninit() in exit1() to after a successful killalllwps(0), so the set is destroyed only once no sibling LWP can reach it (see fix.diff).

Verdict

exit1() cleans+lockuninits p_varsymset (kern_exit.c:311-312) before killalllwps (kern_exit.c:319) kills sibling LWPs; a sibling LWP inside sys_varsym_set(VARSYM_PROC) inserts varsym entries into the already-cleaned set (varsymmake kern_varsym.c:457-473) which are never freed -> permanent unprivileged M_VARSYM kernel-memory leak, reproduced linearly to 17.5MB/115K allocs; an INVARIANTS KKASSERT panic window in lockuninit (kern_lock.c:1325) is source-real but not observed in ~26,500 attempts. fix.diff (teardown after killalllwps) built in-guest and validated: zero leak under the identical workload.