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