KERN_PROC_PGRP/KERN_PROC_TTY sysctl filters dereference the p_pgrp->pg_session->s_ttyp->t_dev chain with no p_token/pgref β TOCTOU use-after-free read against pgrp teardown (dead p_session NULL guard)
Summary
Inside the allproc scan holding only the bucket token shared, the PGRP filter re-reads p->p_pgrp twice unlocked and the TTY filter walks the full chain to t_dev; p_session is a macro through p_pgrp so the NULL guard at 1743 is dead code. enterpgrp/leavepgrp swap p_pgrp under p_token+pg_token and the trailing pgrel frees the pgrp while the member process is still scannable β a scanner that loaded p_pgrp just before the transition dereferences freed memory. Honest runtime verdict: 17.09M filter-executing polls (~95k/s, 180s) against 24 churn workers produced no panic β every hop lands in never-unmapped dedicated-zone memory so the realistic outcome is a stale/garbage compare; race statically certain, wild-pointer ceiling not reached. p_token-shared + explicit-chain-walk fix git-apply-clean.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5823 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df5823_churn.c | β | 3.7 KB | view raw | |
| build.sh | β | 92 B | view raw | |
| run.sh | β | 99 B | view raw | |
| run.log | β | 41 B | view raw | |
| VERDICT.md | β | 3.0 KB | β raw | |
| verdict.json | β | 3.2 KB | view raw | |
| fix.diff | β | 1.6 KB | view raw | |
| manifest.json | β | 776 B | view raw |
DF-5823 VERDICT
status: not_reproduced (runtime) / race statically certain / impact: none observed / confidence: likely
Mechanism (path:line)
- Filters run under only
prg->proc_tokenshared: sys/kern/kern_proc.c:1710-1711 (lwkt_gettoken_shared,LIST_FOREACH). - KERN_PROC_PGRP re-reads
p->p_pgrptwice with no lock: kern_proc.c:1736-1738. - KERN_PROC_TTY walks
p_pgrp->pg_session->s_ttyp->t_dev: kern_proc.c:1741-1747.p_sessionis#define p_session p_pgrp->pg_session(sys/sys/proc.h:344) so thep->p_session == NULLcheck at 1743 is dead code β the NULL-ness it pretends to guard is one hop deeper. - The pgrp pointer is mutated by
enterpgrp()(kern_proc.c:814-826) andleavepgrp()(kern_proc.c:859-872) underp->p_token+pg_token, and the lastpgrel()frees the pgrp (kern_proc.c:656-690) while the member proc remains scannable on allproc. The mover's manualpgref()(kern_proc.c:815, 860) protects only its own window, not a scanner that already loaded the pointer. - Zombies keep
p_pgrpuntil reap (leavepgrpat kern_exit.c:1271 runs afterproc_remove_zombie), and list removal is serialized against the scan by the bucket token β so the freed-pgrp variant (member moves pgrps while on-list) is the reachable race; a NULLp_pgrpis not reachable for on-list procs.
What was run
./df5823_churn 180 as unprivileged maxx on the audit guest: 24 churn
workers, each forking grandchildren that setpgid(0,0) (fresh pgrp, sole
member, on-list) then setsid() (leaves that pgrp as last member β pgrel
frees it mid-scan); half the workers additionally acquire a controlling
tty (posix_openpt + auto-TIOCSCTTY) so the s_ttyp hop of KERN_PROC_TTY
is raced. Scanner polled kern.proc.pgrp.1 and kern.proc.tty.0
continuously: 17,090,388 iterations (~94,942/s), load average 22.
Why no panic (honest impact ceiling)
Every object in the raced chain (struct pgrp, struct session, struct
tty) lives in a dedicated kmalloc zone whose backing memory is never
unmapped; freed chunks retain (or get same-type) contents, so the stale
reads degrade to wrong pg_id/t_dev comparisons instead of faults. A
page-faulting wild hop requires a freed chunk field to alias a
non-resident pointer β possible only through same-zone reuse patterns
that keep producing valid objects. Consequence class: incorrect filter
results (cosmetic), theoretical panic (not demonstrated in 17M polls).
Reproduce
cc -O2 -o df5823_churn df5823_churn.c ./df5823_churn 180 # guest stays up, rig prints "scanner iterations: NNNN (~95k/s)" # a kernel panic during the run would upgrade this finding
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 but not kernel-build validated: runtime impact did not reproduce, so there is no observable to confirm flipped.
fix.diff (p_token shared around both filter cases; dead p_session NULL check replaced with explicit p_pgrp->pg_session chain checks)
Confirmed kernel references
Detail
Evidence (decisive lines)
VERDICT.md (mechanism + run narrative); run.log (full 180s run: 'scanner iterations: 17090388 (94942.5/s)', guest up afterwards); df5823_churn.c (churn unit: setpgid(0,0) -> setsid() frees a pgrp while the proc is on allproc; ctty variant races s_ttyp hop)
PoC changes
Authored from scratch (no seed). Churn unit chosen after establishing that zombies keep p_pgrp until reap (kern_exit.c:1271) so the on-list pgrp destruction must come from a live member doing setsid(), not from exit.
Verified recommended fix
Hold p->p_token shared (or pgref a p_pgrp snapshot) across the KERN_PROC_PGRP/KERN_PROC_TTY filter chain reads in sysctl_kern_proc.
Verdict
The race is statically certain: sysctl_kern_proc's KERN_PROC_PGRP/KERN_PROC_TTY pre-filters (sys/kern/kern_proc.c:1736-1747) dereference the p_pgrp->pg_session->s_ttyp->t_dev chain under only the allproc-bucket proc_token (shared), with no p->p_token and no pgref, while enterpgrp()/leavepgrp() swap p_pgrp and the final pgrel() pair frees the pgrp (kern_proc.c:643-690) with the member still scannable on allproc; the p->p_session == NULL guard at 1743 is dead code because p_session is a macro through p_pgrp (sys/sys/proc.h:344). Runtime: 17,090,388 filter-executing sysctl polls (~95k/s for 180s) against 24 churn workers doing setpgid(0,0)+setsid() plus controlling-tty acquisition produced no panic and no observable corruption - every hop of the freed chain lands in never-unmapped dedicated-zone memory (M_PGRP/M_SESSION), so the realistic outcome is a stale/garbage compare, not a fault. Race real, impact ceiling not reached on this guest.
No comments yet.