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

inferior() strands one PHOLD on proc0 per returning-0 call β€” unprivileged setpgid grows proc0's p_lock without bound, overflowing the PLOCK flag word (demonstrated: PLOCK_WAITRES bit set live)

Summary

inferior walks the target's ancestry transferring a PHOLD node-to-node; the pid==0 early return exits without PRELE, stranding the hold on proc0. Every process descends from init whose parent is proc0, so every inferior()==0 call leaks exactly one hold; the unprivileged entry is sys_setpgid on any non-descendant pid (pfind + inferior -> ESRCH). Demonstrated live: 310,000,000 setpgid(1,0) ESRCH returns (~1.54M strands/sec, deterministic not a race) grew proc0's p_lock past PLOCK_MASK (0x0FFFFFFF), setting the PLOCK_WAITRES flag bit in the live lock word; guest stayed healthy because proc0 is immortal and nothing PSTALLs/pholdzombs it today β€” real but dormant corruption, a latent wedge for any future consumer. One-line PRELE fix git-apply-clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5824 Β· 9 files
FileTypeDescriptionSize
df5824_leak.c β€” 1.3 KB view raw
build.sh β€” 90 B view raw
run.sh β€” 130 B view raw
run.log β€” 214 B view raw
README.md β€” 2.4 KB ↓ raw
VERDICT.md β€” 2.6 KB ↓ raw
verdict.json β€” 2.7 KB view raw
fix.diff β€” 235 B view raw
manifest.json β€” 788 B view raw

DF-5824 β€” inferior() strands a PHOLD on proc0 per failed setpgid (unbounded p_lock growth)

What

inferior() (sys/kern/kern_proc.c:470-493) walks the target's ancestry holding each node; when the walk reaches the pid==0 node it returns 0 without PRELE on that node:

475:  PHOLD(p);
476:  lwkt_gettoken_shared(&p->p_token);
477:  while (p != curproc) {
478:      if (p->p_pid == 0) {
479:          lwkt_reltoken(&p->p_token);
480:          return (0);           /* <-- PHOLD(proc0) stranded */
481:      }
482:      p2 = p->p_pptr;
483:      PHOLD(p2);                /* hold transfers each iteration */
...

Every process descends from init (pid 1) whose parent is proc0, so every inferior() call that returns 0 leaks exactly one hold on proc0. The unprivileged entry is sys_setpgid (sys/kern/kern_prot.c:372): setpgid(<any pid that is not our descendant>, 0) β†’ pfind ok β†’ inferior() walks to proc0 β†’ returns 0 β†’ ESRCH.

p_lock holds PLOCK_ZOMB|PLOCK_WAITRES|PLOCK_WAITING in bits 28-30 with PLOCK_MASK == 0x0FFFFFFF (kern_proc.c:263-266); phold() is a plain atomic_add_int(&p->p_lock, 1) (kern_proc.c:339-342). After 2^28 leaked holds the count overflows into the flag bits of proc0's lock word.

Result on the guest (run.log)

  • 10,000,000/10,000,000 setpgid(1,0) calls returned ESRCH (the leak path) in 6.5 s β†’ ~1.54M stranded holds/sec, unprivileged.
  • 300,000,000 more in 194.7 s β†’ proc0 p_lock grew by 310,000,000 (0x1274F0C0) β€” past PLOCK_MASK (0x0FFFFFFF): the PLOCK_WAITRES flag bit is now literally set in proc0's p_lock.
  • Guest remained healthy throughout (proc0 is immortal and nothing waits on its p_lock today), i.e. the corruption is real but currently dormant.

Why it still matters

The flag word of the kernel's primary synchronization primitive for process reaping is corrupted by an unprivileged syscall at ~1.5M/s; any future code that PSTALLs/pholdzombs proc0 (or a refactor that makes the pid==0 walk reach a reapable proc) turns this into a wedge/panic. The counter also never wraps back cleanly (it cycles through all flag combinations as it passes 2^29, 2^30, 2^31...).

Fix

One line: PRELE(p) before the return (0):

    if (p->p_pid == 0) {
        lwkt_reltoken(&p->p_token);
+       PRELE(p);
        return (0);
    }

Files

df5824_leak.c, build.sh, run.sh, run.log, VERDICT.md, manifest.json, verdict.json, fix.diff

VERDICT.md
↓ download raw

DF-5824 VERDICT

status: reproduced (leak mechanism, deterministic) / impact: none observable (latent flag corruption) / confidence: certain

Root cause (path:line)

inferior() sys/kern/kern_proc.c:470-493 β€” the pid==0 early-return at 478-481 exits without releasing the hold the walk just transferred onto the pid==0 node (proc0). Balanced paths: normal loop transfer (482-487) and the p == curproc exit (489-490). Unbalanced path: 480.

Unprivileged reachability: sys_setpgid sys/kern/kern_prot.c:372 β€” if ((targp = pfind(uap->pid)) == NULL || !inferior(targp)) β€” any non-descendant pid (e.g. 1) walks inferior() to proc0 and returns via the leaking branch; the caller's own pfind hold IS released (373), only inferior's internal hold on proc0 strands.

Effect: phold() is atomic_add_int(&p->p_lock, 1) (kern_proc.c:341); PLOCK flags live at bits 28-30 (kern_proc.c:263-266, PLOCK_MASK = 0x0FFFFFFF). Unbounded unprivileged growth overflows the count into the flag bits of proc0's p_lock.

Runtime proof

  • run.log (10M): setpgid(1,0) x 10,000,000 β†’ ESRCH 10,000,000 (100% β€” every single call executes the leaking branch; this is a deterministic leak, not a race), 6.49 s wall β†’ ~1.54M/s.
  • run.log (300M): +300,000,000 in 194.74 s. Total stranded holds on proc0 after both runs: 310,000,000 = 0x1274F0C0 β‰₯ 0x10000000 β†’ PLOCK_WAITRES bit is set in proc0's p_lock on the live guest.
  • Guest healthy before/after (uptime continuous, sysctl responsive): nothing in the current kernel PSTALLs or pholdzombs proc0, so the corrupted flag word has no consumer β€” dormant corruption.

Impact assessment (honest)

No crash is reachable today because proc0 is immortal and never passes through proc_move_allproc_zombie/wait. The finding is filed as a correctness/refcount-discipline defect with a demonstrated unprivileged unbounded-growth primitive against a kernel synchronization word β€” a latent wedge/panic for any future consumer, plus a permanent accounting lie (proc0's hold count can never be trusted again after the attack).

Reproduce

cc -O2 -o df5824_leak df5824_leak.c
time ./df5824_leak 10000000     # ~6.5s, 100% ESRCH
time ./df5824_leak 300000000    # ~195s, crosses 2^28 -> flag bit set

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored (one line) but not rebuild-validated: the leak has no runtime-observable failure to flip on this guest (dormant corruption only).

fix.diff
↓ fix.diffper-fix-DF-5824

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (both bursts: 100% ESRCH = leaking branch executed on every call; arithmetic shows p_lock += 310M crossing 2^28); VERDICT.md (root cause path:line, impact ceiling); fix.diff (one-line PRELE fix)

PoC changes

Authored from scratch (no seed). Target pid 1 chosen for the shortest ancestry walk (init -> proc0, single iteration).

Verified recommended fix

Add PRELE(p) before the return(0) in inferior()'s pid==0 branch (sys/kern/kern_proc.c:480).

Verdict

Deterministic refcount-discipline bug, reproduced at runtime: inferior() (sys/kern/kern_proc.c:477-481) returns 0 at the pid==0 node without PRELE, stranding one PHOLD on proc0 per call; the unprivileged entry is sys_setpgid on any non-descendant pid (sys/kern/kern_prot.c:372). 10,000,000 setpgid(1,0) calls returned ESRCH 10,000,000 times in 6.5s (~1.54M stranded holds/sec) and 300,000,000 more in 194.7s, growing proc0's p_lock by 310,000,000 (0x1274F0C0) - past PLOCK_MASK (0x0FFFFFFF), i.e. the PLOCK_WAITRES flag bit is now set in proc0's lock word on the guest. No crash results today because proc0 is immortal and nothing PSTALLs/pholdzombs it: the corruption is real but dormant, hence impact=none. Filed Low: unprivileged unbounded growth against a kernel synchronization word is a latent wedge/panic for any future consumer and permanently corrupts proc0's hold accounting.