lwpsignal() writes p->p_sigacts->ps_frominfo[] on the token-swapped delivery path holding only the lwp token, racing kern_execve()'s sigacts replacement/free (UAF write)
| Field | Value |
|---|---|
| ID | DF-2692 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-416 UAF, CWE-662 Improper Synchronization |
| File | sys/kern/kern_sig.c |
| Lines | 1425 (sink 233-242; token swap 1348-1351; exec free kern_exec.c:413-421) |
| Area | kern |
| Confidence | likely |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
After find_lwp_for_signal() token-swaps p_token away (:1348-1351), and on the whole lwp-targeted entry path, lwpsignal() calls sigsetfrompid() (:1425) which loads p->p_sigacts and stores pid/uid into ps_frominfo[sig] (:233-242) with no p_token held. kern_execve() replaces p_sigacts and kfrees the old one under p_token alone. No common lock β 8-byte write of sender pid/uid at a selectable offset (sig 1..127) into a freed M_SUBPROC chunk. Same-process variants are excluded by killalllwps() ordering; only foreign kill()/lwp_kill() is exposed. Window is nanoseconds β full trace in the pack, not staged.
Threat model & preconditions
Unprivileged local user racing an execing, sigacts-sharing (vfork) victim with kill()/lwp_kill() hammers from multiple CPUs: kernel heap corruption of a freed ~4.2KB M_SUBPROC allocation.
Recommended fix
Hold p->p_token across the final-path sigsetfrompid() call, or acquire it shared inside sigsetfrompid() itself (also hardening the SSTOP/PPWAIT call sites).
Timeline
- 2026-08-30 Discovered during pass-2 audit of kern_sig.c (GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2692 Β· 3 files| File | Type | Description | Size | |
|---|---|---|---|---|
| VERDICT.md | β | 3.9 KB | β raw | |
| verdict.json | β | 2.0 KB | view raw | |
| manifest.json | β | 636 B | view raw |
DF-2692 - lwpsignal() touches p->p_sigacts without p_token on the final delivery path, racing kern_execve()'s sigacts free (UAF write)
STATUS: untested - structural analysis; race window is nanoseconds-wide and staging it requires a vfork-shared sigacts being freed inside the victim's exec-swap window. Reported on code inspection with full trace.
DEFECT (all citations into this tree):
- sys/kern/kern_sig.c:1336-1354 - when a generic (untargeted) signal is
routed to a specific LWP, lwpsignal() does:
if (lp == NULL) { sigirefs_hold(p); lp = find_lwp_for_signal(p, sig); if (lp) { if (SIGISMEMBER(lp->lwp_sigmask, sig)) { ... lp = NULL ... } else { lwkt_token_swap(); lwkt_reltoken(&p->p_token); <-- p_token DROPPED sigirefs_drop(p); } } }
From here on (and for the whole lwp-targeted entry path at kern_sig.c:1151-1153) the function holds ONLY lp->lwp_token.
-
sys/kern/kern_sig.c:1425 calls sigsetfrompid(curthread, p, sig) on that path. sigsetfrompid (kern_sig.c:228-242) dereferences p->p_sigacts without any token:
if ((sap = p->p_sigacts) == NULL) return; ... sap->ps_frominfo[sig].pid = td->td_proc->p_pid; <-- 8-byte write sap->ps_frominfo[sig].uid = td->td_ucred->cr_uid; -
sys/kern/kern_exec.c:413-421 replaces and potentially frees p->p_sigacts while holding ONLY p->p_token:
ops = p->p_sigacts; if (ops->ps_refcnt > 1) { nps = kmalloc(sizeof(*nps), M_SUBPROC, M_WAITOK); bcopy(ops, nps, sizeof(*nps)); <-- ~4.2 KB copy refcount_init(&nps->ps_refcnt, 1); p->p_sigacts = nps; if (refcount_release(&ops->ps_refcnt)) { kfree(ops, M_SUBPROC); <-- free under p_token } }
There is NO common lock between (2) and (3): p_token does not serialize the lwpsignal() final path, and lwp_token does not serialize exec. A killer() racing an exec therefore loads the old sigacts pointer and can store pid/uid into it after kfree(). The same unlocked p_sigacts access exists in trapsignal() (kern_sig.c:946,964-980) and postsig() (kern_sig.c:2258,2281+), but those are same-process paths that exec's killalllwps() (kern_exit.c:186-249, waiting for p_nthreads to drop to 1 inside exec_new_vmspace) shuts down before the swap; the foreign kill()/lwp_kill() path has no such exclusion.
PRIMITIVE (if the race is won): - 8-byte write of two small semi-controlled ints (sender pid, sender uid) at offsetof(struct sigacts, ps_frominfo) + 8*sig, sig selectable 1..127, into a freed M_SUBPROC chunk (sizeof(struct sigacts) = 4248, 1280-class... actually 4608-class slab chunk). - Realistically: kernel memory corruption / potential panic; full exploitation would require winning a nanosecond window repeatedly.
REACHABILITY (why it is more than theoretical): - sigacts sharing requires vfork/RFSIGSHARE (kern_fork.c:521-528) - unprivileged. - The exec-swap free requires the co-holder's reaping to complete inside the kmalloc+bcopy window (kern_wait kern_exit.c:1301-1306). - Attacker hammers kill(victim_pid, sig) from many CPUs while the vfork-cluster churns exec/reap.
RECOMMENDED FIX (one-line summary: make the final lwpsignal delivery path hold p_token across sigsetfrompid, or read p_sigacts once under p_token):
kern_sig.c: take lwkt_gettoken(&p->p_token) before sigsetfrompid() at line 1425 (and around the lwp_siglist add at 1426-1428), releasing it at out:; alternatively sigsetfrompid() itself can be made to acquire p->p_token shared. The same protection then covers the 1236/1365/1396 call sites which already run under p_token.
Fix verification
not_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['VERDICT.md - full path:line trace of the missing common lock and the staging requirements']
PoC changes
no runnable PoC - race window too narrow to stage reliably in the available budget; kept as inspection finding
Verified recommended fix
Hold p->p_token across sigsetfrompid() on the final lwpsignal delivery path (line 1425), or acquire it shared inside sigsetfrompid()
Verdict
Structural use-after-free race, proven by inspection but not staged on the guest: lwpsignal()'s final delivery path holds only lp->lwp_token after the find_lwp_for_signal token swap (kern_sig.c:1348-1351), yet sigsetfrompid() at kern_sig.c:1425 reads and writes p->p_sigacts->ps_frominfo[sig] (kern_sig.c:233-241) without p_token, while kern_execve() replaces and can kfree() p->p_sigacts under p_token alone (kern_exec.c:413-421, free path requires a vfork/RFSIGSHARE co-holder's reaping to complete inside the kmalloc+bcopy window). Winning window is nanoseconds; primitive is an 8-byte semi-controlled write (sender pid+uid) at a selectable offset (sig 1..127) inside a freed M_SUBPROC chunk. Same-process variants (trapsignal/postsig/kern_sigaction) are excluded by killalllwps() running before the exec swap; only the foreign kill()/lwp_kill() path is exposed.
No comments yet.