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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2908 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 beforekillalllwps(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 insidesys_varsym_set(VARSYM_PROC,...)finishes its syscall regardless.sys/kern/kern_varsym.c:457βvarsymmake()takes onlyvss->vx_lock(EXCLUSIVE); it neither takesp_tokennor checksp->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:
exit1cleans exactly once; when the struct proc is torn down the entries'vs_refs == 1is never dropped β permanentM_VARSYMleak (varsyment~24B +varsym24+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 betweenvarsymset_clean()'s internalLK_RELEASE(kern_varsym.c:545) and the assert read βpanic("assertion ... failed"). Guest kernel config hasoptions 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
fixedfixed β 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']
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
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.
No comments yet.