exit1() p_peers unlink race: lost unlink leaves leader permanently wedged in uninterruptible exit (pre-fdfree, pinning fds+vmspace) and members freed while still linked β kernel heap UAF read/write
| Field | Value |
|---|---|
| ID | DF-2745 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:H |
| CWE | CWE-362 β CWE-416 (unsynchronized list removal) |
| File | sys/kern/kern_exit.c |
| Lines | 384-390 (walk :326-342; insert kern_fork.c:484-486) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The rfork(RFTHREAD) peer list (p_peers) is mutated with no lock anywhere: members self-unlink in exit1 holding only their own p_token, the leader kill-walks and waits on the same list, and fork1() inserts at the head equally unlocked. When two members exit concurrently, a walker caches predecessor A, A unlinks between the walker's two dependent loads, and the walker "unlinks" itself through the stale A β remaining linked from the real list. Nothing ever clears the leader's p_peers after that.
Threat model & preconditions
Any unprivileged local user (rfork(2) is un-gated). Consequences:
(a) the leader blocks forever in the PCATCH-less tsleep(p, 0,
"exit1", 0) BEFORE fdfree()/vmspace_relexit() β unkillable (kill -9
ineffective), pinning its fd table and entire address space
(repeatable unpriv memory-exhaustion + process-slot leak; its parent's
wait() also hangs); (b) the lost member is reaped and kfree'd while
still linked β the leader's kill-walk dereferences freed struct proc
memory (including sys_kill() of stale/recycled pids β SIGKILL
collateral on unrelated processes) and any later walker or rfork
insert reads/writes through the dangling link, including a store of a
kernel-heap pointer into freed M_PROC memory that may be reallocated
as a live proc (heap corruption). uid=0 chain not developed (write
value/address only partially controlled) β demonstrated impact is
reliable unpriv unkillable resource-pinning DoS plus UAF reads/writes
of freed kernel heap.
Proof of concept
VERIFIED on the stock guest (findings/poc/DF-2745/peersrace.c):
fork leader + rfork three members with per-member jitter so their
exit1 unlinks race β RACE HIT within seconds β 4/4 workers hit in
521 attempts/3s β and ps shows D-state leaders with wchan=exit1 that
survive kill -9 (kill-proof.txt). Fix (dedicated peers_token around
every p_peers mutation, leader snapshot-then-kill outside the token)
validated on a rebuilt kernel: 200,000 attempts / 0 hits vs 4-in-521
on baseline.
Recommended fix
Serialize every p_peers mutation behind a dedicated global token (peers_token) β member unlink, RFTHREAD insert, and a leader kill-walk that snapshots the list with PHOLDs under the token and sys_kills outside it (avoiding a p_token ordering inversion). Full git-apply-able diff in findings/poc/DF-2745/fix.diff (validated).
Timeline
- 2026-08-30 Discovered during pass-2 audit of kern_exit.c (GLM 5.3); unpriv race reproduced 4/4 + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2745 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| peersrace.c | β | 7.1 KB | view raw | |
| trigger.c | β | 7.1 KB | view raw | |
| build.sh | β | 99 B | view raw | |
| run.sh | β | 345 B | view raw | |
| build.log | β | 1.0 KB | view raw | |
| run.log | β | 821 B | view raw | |
| run.fixed.log | β | 282 B | view raw | |
| kill-proof.txt | β | 221 B | view raw | |
| env.txt | β | 534 B | view raw | |
| kernel_build_tail.log | β | 871 B | view raw | |
| fix.diff | β | 3.1 KB | view raw | |
| VERDICT.md | β | 6.8 KB | β raw | |
| verdict.json | β | 5.9 KB | view raw |
DF-2745 β VERDICT
Finding: exit1() (sys/kern/kern_exit.c) manipulates the
rfork(RFTHREAD) p_peers peer list with no lock; concurrent member
exits race the unlink walk β lost unlink β permanent uninterruptible
exit hang of the leader + use-after-free on reaped members.
Status: REPRODUCED β unprivileged local user, stock INVARIANTS kernel (DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC), 6 vCPU QEMU/KVM guest.
Root cause (path:line)
sys/kern/kern_exit.c:384-390β member self-unlink:
c
if (p->p_leader->p_peers) {
q = p->p_leader;
while(q->p_peers != p) /* (1) load L->p_peers -> A */
q = q->p_peers; /* (2) load A->p_peers */
q->p_peers = p->p_peers; /* (3) store through pred */
wakeup((caddr_t)p->p_leader);
}
Each exiting member holds only its own p_token β there is no
common lock over the singly-linked list.
-
sys/kern/kern_fork.c:484-490β insertion (RFTHREAD) is equally unlocked (held: rforker's ownp_tokenonly). -
sys/kern/kern_exit.c:326-342β the leader's peer-kill walk readsq->p_pid/q->p_peersacross the same list while members unlink and are reaped.
Race
List X -> A -> B (X leader; A, B members; B walker, A unlinker):
- B loads
X->p_peersβ seesA(A still linked), advancesq = A. - B is descheduled (interrupt/preemption; window is the dependent load pair (1)β(2)).
- A unlinks itself:
X->p_peers = A->p_peers = B. A continuesexit1()and (being X's child, X reaping) becomes a zombie and is reaped β kfree(p, M_PROC) (kern_exit.c:1336). A'sp_peersfield is never cleared, still points to B. - B resumes, loads
A->p_peersβBβ believes A is its predecessor, storesA->p_peers = B->p_peersβ a write through a stale (possibly freed) predecessor β and returns believing it unlinked itself. - B remains linked from X (
X->p_peers == B), completes exit1, is reaped, freed while still linked.
Manifestations (all observed or structurally forced)
-
Leader hangs forever, unkillable β nothing ever clears
X->p_peers; X sleeps intsleep(p, 0, "exit1", 0)(kern_exit.c:341) β noPCATCH, so signals (incl. SIGKILL) do nothing; the sleep is beforefdfree()(382) andvmspace_relexit()(433), so the wedged leader also pins its fd table and its whole address space (unreclaimable RSS β repeatable memory-exhaustion vector). Its own parent'swait*()never returns. Observed: 6 processes inDstate,wchan=exit1, survivingkill -9, forever (until reboot). -
Use-after-free reads (guaranteed on every hit) β after step 5, X's exit peer-kill walk (kern_exit.c:330-339) reads
q->p_pid/q->p_peersof freed members; on this run it silently walked the stale chain (no panic; slab still mapped) andsys_kill()d stale pids β with pid recycling under a fork storm this signals unrelated processes (SIGKILL collateral). Any later peer-group walker (another member exiting, or a newrfork(RFTHREAD)insert at kern_fork.c:485 chasingp1->p_peers) dereferences the dangling/freed pointers β wild pointer chase β panic or corruption of whatever now occupies the M_PROC slab. -
Use-after-free write (timing-dependent) β step 4's store
A->p_peers = ...lands in freed memory whenever A was reaped before B resumed: a kernel-heap pointer written atoffsetof(struct proc, p_peers)into a freed M_PROC chunk β if that chunk has been reallocated as a livestruct proc, it corrupts that proc's peer linkage (cascading corruption); as a fresh allocation of different content it is a limited heap-corruption primitive.
Escalation to uid=0 was not developed: the write value is a kernel-heap pointer and the target is slab-reuse-dependent; the reliable, fully-demonstrated impact is the unprivileged unkillable resource-pinning DoS plus guaranteed freed-slab reads. The bug class (lost unlink on an unlocked kernel list) is nonetheless memory corruption (memcorrupt bucket).
Reproduction evidence
run.logβ decisive run: 4/4 workers hit within 521 attempts / 3 seconds (RACE HIT: worker=1 iter=72 β¦ worker=0 iter=247), thenpsshows four freshD?-stateleaders withwchan=exit1, PPID=1.kill -9delivered to the wedged leaders β no effect (stillD0E/D1E/β¦,wchan=exit1) β seerun.log/env.txt.- Trigger: unprivileged
maxxuser;rfork(RFPROC|RFFDG|RFTHREAD)(SYS_rfork=251, no privilege check βsys_rforkonly rejectsRFKERNELONLY, kern_fork.c:195).
Fix validation
fix.diff serializes every p_peers mutation behind a global
peers_token:
- member unlink (kern_exit.c) under
peers_token; RFTHREADinsert (kern_fork.c) underpeers_token;- leader kill-walk snapshots the list (with
PHOLDs) underpeers_tokenand issuessys_kill()s outside the token β holdingpeers_tokenacrosssys_killβksignal(which takes the victim'sp_token) would create an AB-BA inversion against member exit1 (p_tokenβpeers_token).
Baseline (vulnerable kernel): reproduced as above.
Patched kernel (make nativekernel in-guest, rebooted): re-run of the
identical PoC β see run.fixed.log (no hit, no exit1-wchan processes,
system stable).
Negative result weight (what else was checked in kern_exit.c pass 2)
- reap interlock PHOLDZOMB/PWAITRES/PSTALL/prelezomb β sound (kern_exit.c:1066,1154,1170,1241; kern_proc.c:272-296,386-463); WNOWAIT correctly releases the WAITRES reservation.
p_waitgenprotocol (kern_exit.c:1060,1437-1444) β bump-then-wakeup (exit1:530 + lwp_exit:803) with tsleep_interlock/PINTERLOCKED closes the lost-wakeup window for exit events; stop/continue wakeups are taken under the parent'sp_token(kern_sig.c:1277-1295,1620-1631), serialized against the waiter β no missed wakeup.- P_UID/P_GID/P_JAILID/P_SID filters deref
p_ucred/p_sessionunder the parent'sq->p_token+PHOLD(p); the reap path stalls (PSTALL) before freeing ucred β no UAF. - exit1:612
p->p_pptr->p_sigactsread β safe (a parent cannot be reaped while we are still its child; its exit1 reparents us first under our token). - vfork
P_PPWAIT/p_upmapread (509-513) β token-serialized against the reparent loop; upmap freed only at reap. - reparent loop (549-588) revalidates under the child's token β sound.
proc_reparenttoken order oldβchildβnew consistent at both call sites β no inversion.- reap-path frees (1286-1337) all behind PSTALL ref-drain and
proc_remove_zombie β no premature free; rusage accumulation under
q->p_tokenβ no torn adds. - DF-0027 (WNOHANG uninit status/rusage) β known, not re-reported.
Fix verification
fixedApplied fix.diff to a fresh clean-source guest, rebuilt (make nativekernel, 0 errors), installed and rebooted into kernel #1. Re-ran the identical PoC: 200,000 attempts / 122s / hit=0 / no processes in exit1 wait / system healthy - versus baseline 4 hits in 521 attempts. The lost-unlink race and the freed-proc peer-list dereferences are gone.
['run.fixed.log: attempts=200000 elapsed=122s hit=0, zero peersrace processes remaining', 'kernel_build_tail.log: build/install/reboot transcript, kernel #1']
Confirmed kernel references
Detail
Exploit chain
unpriv rfork(RFPROC|RFTHREAD) x3 -> leader X + members A,B,C sharing the p_peers list -> members exit simultaneously with jitter -> one member's exit1 unlink walk stalls between its two dependent loads while the upstream member completes its own unlink (write X->p_peers=next) -> walker resumes, reads the unlinked (stale) predecessor's still-intact p_peers, and writes pred->p_peers instead of unlinking itself -> walker stays linked; leader's p_peers never clears -> leader wedges forever in uninterruptible exit1 tsleep BEFORE fdfree/vmspace_relexit (pins fds + full RSS; kill -9 useless; parent wait() hangs; zombie children leak) -> leader's exit peer-kill walk dereferences freed members (freed-slab reads + SIGKILL to recycled pids) and later walkers/fork inserts read/write through the dangling link (heap UAF; corruption of a reallocated struct proc possible).
Evidence (decisive lines)
["run.log: decisive run - 4x 'RACE HIT ... stuck in exit1' within attempts=521 elapsed=3s, followed by ps showing 6 leaders in D state, wchan=exit1, PPID=1", 'kill-proof.txt: kill -9 delivered to wedged leaders; ps shows them still D-state wchan=exit1 (unkillable)', 'env.txt: guest uname (kernel #0 baseline), hw.ncpu=6, and the D-state exit1 process listing', 'VERDICT.md: full interleaving analysis (lost unlink) + impact analysis + negative notes', 'run.fixed.log: patched kernel (fix.diff, kernel #1) - attempts=200000 elapsed=122s hit=0, zero peersrace processes remaining', 'kernel_build_tail.log: fix kernel build/install evidence (0 errors, install completed 14:47:24, rebooted into #1)']
PoC changes
trigger authored from scratch (no seed PoC): SYS_rfork(251) with RFPROC|RFFDG|RFTHREAD; leader + 3 members per attempt, members released simultaneously from a MAP_SHARED flag with per-member jitter loops; detection = waitpid(X, WNOHANG) timeout => leader stuck in exit1; 4 parallel workers.
Verified recommended fix
Serialize every p_peers mutation (member unlink in exit1, leader kill-walk snapshot, RFTHREAD insert in fork1) behind a dedicated global lwkt token; PHOLD peers while signaling outside the token to avoid a p_token inversion (see fix.diff).
Verdict
exit1() unlinks rfork(RFTHREAD) peer-group members from the p_peers singly-linked list with no common lock (kern_exit.c:384-390; insertion kern_fork.c:484-486 is equally unlocked). Two concurrently exiting members race the walk: the walker caches a predecessor that unlinks between the walker's two dependent loads, then 'unlinks' itself through the stale predecessor and stays linked. Nothing ever clears the leader's p_peers afterwards, so the leader blocks FOREVER in the PCATCH-less tsleep at kern_exit.c:341 - before fdfree()/vmspace_relexit(), pinning its fd table and entire address space - and kill -9 has no effect. The lost member is reaped and freed while still linked; the leader's peer-kill walk (kern_exit.c:330-339) then dereferences the freed struct proc (guaranteed freed-slab reads incl. sys_kill() of stale, possibly recycled pids) and any later walker/insert chases or writes through the dangling link (UAF write into freed M_PROC slab when the stale predecessor was reaped first). Demonstrated as an unprivileged user (rfork is un gated): 4/4 workers hit within 521 attempts / 3 seconds on the stock kernel; six unkillable D-state wchan=exit1 leaders accumulated and survived kill -9. uid=0 escalation not developed (write value is a kernel-heap pointer at slab-reuse-dependent address); demonstrated impact is the reliable unprivileged unkillable resource-pinning DoS plus guaranteed freed-slab dereferences. fix.diff (global peers_token serializing all three mutation sites; leader kill-walk snapshots under the token and kills outside it) validated in-guest: 200,000 attempts / 0 hits / no stuck processes on the rebuilt kernel vs 4 hits in 521 attempts on baseline.
No comments yet.