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)
PoC verification
Evidence pack
findings/poc/DF-5824 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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_testablefix.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
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.
No comments yet.