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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2745 Β· 13 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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)

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 own p_token only).

  • sys/kern/kern_exit.c:326-342 β€” the leader's peer-kill walk reads q->p_pid / q->p_peers across the same list while members unlink and are reaped.

Race

List X -> A -> B (X leader; A, B members; B walker, A unlinker):

  1. B loads X->p_peers β†’ sees A (A still linked), advances q = A.
  2. B is descheduled (interrupt/preemption; window is the dependent load pair (1)β†’(2)).
  3. A unlinks itself: X->p_peers = A->p_peers = B. A continues exit1() and (being X's child, X reaping) becomes a zombie and is reaped β†’ kfree(p, M_PROC) (kern_exit.c:1336). A's p_peers field is never cleared, still points to B.
  4. B resumes, loads A->p_peers β†’ B β†’ believes A is its predecessor, stores A->p_peers = B->p_peers β€” a write through a stale (possibly freed) predecessor β€” and returns believing it unlinked itself.
  5. B remains linked from X (X->p_peers == B), completes exit1, is reaped, freed while still linked.

Manifestations (all observed or structurally forced)

  1. Leader hangs forever, unkillable β€” nothing ever clears X->p_peers; X sleeps in tsleep(p, 0, "exit1", 0) (kern_exit.c:341) β€” no PCATCH, so signals (incl. SIGKILL) do nothing; the sleep is before fdfree() (382) and vmspace_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's wait*() never returns. Observed: 6 processes in D state, wchan=exit1, surviving kill -9, forever (until reboot).

  2. 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_peers of freed members; on this run it silently walked the stale chain (no panic; slab still mapped) and sys_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 new rfork(RFTHREAD) insert at kern_fork.c:485 chasing p1->p_peers) dereferences the dangling/freed pointers β€” wild pointer chase β†’ panic or corruption of whatever now occupies the M_PROC slab.

  3. 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 at offsetof(struct proc, p_peers) into a freed M_PROC chunk β€” if that chunk has been reallocated as a live struct 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), then ps shows four fresh D?-state leaders with wchan=exit1, PPID=1.
  • kill -9 delivered to the wedged leaders β†’ no effect (still D0E/D1E/…, wchan=exit1) β€” see run.log / env.txt.
  • Trigger: unprivileged maxx user; rfork(RFPROC|RFFDG|RFTHREAD) (SYS_rfork=251, no privilege check β€” sys_rfork only rejects RFKERNELONLY, 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;
  • RFTHREAD insert (kern_fork.c) under peers_token;
  • leader kill-walk snapshots the list (with PHOLDs) under peers_token and issues sys_kill()s outside the token β€” holding peers_token across sys_killβ†’ksignal (which takes the victim's p_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_waitgen protocol (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's p_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_session under the parent's q->p_token + PHOLD(p); the reap path stalls (PSTALL) before freeing ucred β†’ no UAF.
  • exit1:612 p->p_pptr->p_sigacts read β€” safe (a parent cannot be reaped while we are still its child; its exit1 reparents us first under our token).
  • vfork P_PPWAIT/p_upmap read (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_reparent token 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Aug 31 14:40:37 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.