# 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_exit`s 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).
