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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5823 Β· 8 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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_token shared: sys/kern/kern_proc.c:1710-1711 (lwkt_gettoken_shared, LIST_FOREACH).
  • KERN_PROC_PGRP re-reads p->p_pgrp twice 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_session is #define p_session p_pgrp->pg_session (sys/sys/proc.h:344) so the p->p_session == NULL check 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) and leavepgrp() (kern_proc.c:859-872) under p->p_token+pg_token, and the last pgrel() frees the pgrp (kern_proc.c:656-690) while the member proc remains scannable on allproc. The mover's manual pgref() (kern_proc.c:815, 860) protects only its own window, not a scanner that already loaded the pointer.
  • Zombies keep p_pgrp until reap (leavepgrp at kern_exit.c:1271 runs after proc_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 NULL p_pgrp is 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_testable
baseline no→ patch + rebuild →patched clean

fix.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)
↓ fix.diffper-fix-DF-5823

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.