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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2692 Β· 3 files
FileTypeDescriptionSize
VERDICT.md β€” 3.9 KB ↓ raw
verdict.json β€” 2.0 KB view raw
manifest.json β€” 636 B view raw
VERDICT.md
↓ download 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):

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

  1. 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;
    
  2. 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_testable
per-fix-DF-2692

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