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

sysctl_kern_proc() uses an unreferenced curproc->p_ucred (cr1) across blocking copyouts β€” ucred UAF read defeats ps_showallprocs=0 visibility filtering (unprivileged info disclosure)

Summary

sysctl_kern_proc caches cr1 = curproc->p_ucred with no crhold and dereferences it per scanned process across many blocking SYSCTL_OUT copyouts (p_trespass when security.ps_showallprocs=0, PRISON_CHECK, jailed()). Any other thread of the same process performing a setuid-family syscall replaces and frees p_ucred via cratom_proc, leaving cr1 dangling; the freed M_CRED chunk recycled by crget (M_ZERO) reads cr_uid==0 and p_trespass grants blanket no-trespass β€” emitting other users' (incl. root) kinfo_proc rows to an unprivileged poller. Reproduced on guest: 60k-80k leaked root rows per run (~12% of polls) under ps_showallprocs=0 across 3 runs with churn using setegid swaps that never modify cr_uid (every leaked row attributable to the stale pointer); 719k-iteration zero-hit control; td_ucred fix validated by kernel rebuild: 0 bypass iterations across 1.8M patched polls. Default-config impact limited to stale prison checks; same latent pattern at the args and cwd handlers.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5822 Β· 14 files
FileTypeDescriptionSize
df5822_rig.c β€” 3.3 KB view raw
build.sh β€” 97 B view raw
run.sh β€” 96 B view raw
run.log β€” 5.8 MB ↓ download
run.2.log β€” 4.8 MB ↓ download
control.log β€” 617 B view raw
run_patched.log β€” 358 B view raw
build_fix.log β€” 1.2 KB view raw
env.txt β€” 693 B view raw
README.md β€” 3.5 KB ↓ raw
VERDICT.md β€” 4.5 KB ↓ raw
verdict.json β€” 5.0 KB view raw
fix.diff β€” 1.2 KB view raw
manifest.json β€” 1.1 KB view raw

DF-5822 β€” sysctl_kern_proc uses un-held cr1 = curproc->p_ucred across blocking copyouts β†’ ucred UAF read β†’ visibility bypass (ps_showallprocs=0)

What

sysctl_kern_proc() caches a raw, unreferenced credential pointer:

1669:   struct ucred *cr1 = curproc->p_ucred;

and then uses it across dozens of blocking SYSCTL_OUT copyouts inside the per-process loop:

1715:   if (ps_showallprocs == 0) {
1716:       crcache = pcredcache(crcache, p);
1717:       if (crcache == NULL ||
1718:           p_trespass(cr1, crcache)) {      /* cr1 dereferenced per row */
1768:   if (!PRISON_CHECK(cr1, crcache))
1789:   if (!ps_showallthreads || jailed(cr1))

The trap layer caches td_ucred with a held reference per syscall (sys/platform/pc64/x86_64/trap.c:181-206), but that protects only the cred current at entry: any cred installed between entry and line 1669 is returned by the raw read with no reference held by this thread. When another thread of the same process then replaces credentials β€” cratom_proc() (sys/kern/kern_prot.c:1170-1190) installs newcr and crfree(oldcr) unconditionally on every setuid-family change β€” the pointed-to struct ucred is freed while cr1 keeps dereferencing it for the remainder of the (multi-millisecond, user-copy-blocked) sysctl.

p_trespass() (sys/kern/kern_prot.c:1023-1059) then reads the freed chunk; a recycled chunk (crget() zeroes on allocation, kern_prot.c:1065) reads cr_uid == 0, hitting:

1057:   if (cr1->cr_uid == 0)
1058:       return (0);      /* blanket no-trespass */

β†’ other users' kinfo_proc rows are emitted to a poller whose real euid is unprivileged.

Reproduced on the guest

Under security.ps_showallprocs=0 (the documented hardening config for multi-user boxes):

run poll iterations bypass iterations root rows leaked
1 (30 s) 632,433 78,481 (12.4 %) 80,584
2 (25 s) 845,892 73,310 73,620
3 (25 s) 854,135 60,297 60,635
control (churn disabled, 719,556 iters) β€” 0 0

The churn uses setegid(0) <-> setegid(1001) (legal for the setuid-root scaffold with svgid 0) which never touches cr_uid: a fresh cr1 always has cr_uid == 1001 and can never legitimately pass p_trespass() against root-owned processes β€” every observed root row is therefore attributable to the stale-pointer read.

Default-config impact (ps_showallprocs=1): the p_trespass gate is skipped; remaining consumers are PRISON_CHECK/jailed() comparisons against freed memory β€” wrong jail visibility decisions on multi-jail hosts, no crash.

Rig

df5822_rig.c β€” setuid-root scaffold (mode 6755 so svgid==0 survives the euid drop): main drops to euid 1001, thread B churns setegid, thread A polls kern.proc.uid.0. Root scaffolding (one-time): sysctl -w security.ps_showallprocs=0; chown root:wheel df5822_rig; chmod 6755 df5822_rig.

Fix

-   struct ucred *cr1 = curproc->p_ucred;
+   struct ucred *cr1 = curthread->td_ucred;

(td_ucred is the trap-layer-cached, held credential β€” the same idiom kern_descrip.c:984/2095 already use; likewise for the two other raw caches at kern_proc.c:1878 and kern_proc.c:2038.)

Validated by kernel rebuild + re-run: bypass disappears (see VERDICT.md).

Files

df5822_rig.c, build.sh, run.sh, run.log, run.2.log, control.log, VERDICT.md, manifest.json, verdict.json, fix.diff, env.txt, build_fix.log, run_patched.log

VERDICT.md
↓ download raw

DF-5822 VERDICT

status: reproduced / impact: visibility-bypass info disclosure (kernleak) / confidence: certain / fix validated (fixed)

Root cause (path:line)

  1. sysctl_kern_proc() caches an unreferenced credential: sys/kern/kern_proc.c:1669 struct ucred *cr1 = curproc->p_ucred;
  2. It dereferences cr1 per scanned row across blocking SYSCTL_OUT copyouts: kern_proc.c:1715-1721 (p_trespass(cr1, crcache) under ps_showallprocs==0), 1768 (PRISON_CHECK), 1789 (jailed(cr1)).
  3. The per-syscall trap-layer credential cache (sys/platform/pc64/x86_64/trap.c:181-206) only guarantees a held reference for the cred installed at entry; a cred installed between entry and line 1669 is returned by the raw read with no reference.
  4. Any other thread of the same process replacing credentials frees it: cratom_proc() sys/kern/kern_prot.c:1170-1190 (p->p_ucred = newcr; ... crfree(oldcr) β€” invoked by every setuid-family change, e.g. change_euid/change_egid).
  5. p_trespass() sys/kern/kern_prot.c:1023-1059 dereferences the freed chunk; crget() (kern_prot.c:1065, M_ZERO) recycles chunks zeroed, so the stale read sees cr_uid == 0 and hits if (cr1->cr_uid == 0) return (0); (kern_prot.c:1057-1058) β€” blanket no-trespass.

The dangling cr1 persists for the whole multi-millisecond sysctl (the pointer is read once at 1669), so each poisoned call misfilters every row it scans.

Why the rows prove the UAF (not a legit euid window)

The churn is setegid(0) <-> setegid(1001), which goes through cratom_proc() but never modifies cr_uid: a fresh cr1 always has cr_uid == 1001 (ruid 1001 as well), so p_trespass(fresh, root) can never return 0 against root-owned processes. Every emitted root row under ps_showallprocs=0 is therefore attributable solely to the stale (freed/recycled) pointer read.

Baseline results (unpatched snapshot kernel, 2026-10-13)

run duration poll iters bypass iters root rows
1 30 s 632,433 78,481 (12.4%) 80,584
2 25 s 845,892 73,310 73,620
3 25 s 854,135 60,297 60,635
control 30 s (churn inert: setegid EPERM, scaffold mode 4555) 719,556 0 0

Marker line (run.log): [rig] CHURN iter 632204: 1 ROOT rows visible with euid=1001 <== STALE cr1 BYPASS.

Fix validation (same guest, rebuilt kernel)

  • fix.diff applied to the guest's /usr/src (all 3 sites: kern_proc.c 1669/1878/2038 cr1 = curthread->td_ucred), make nativekernel KERNCONF=X86_64_GENERIC + installkernel, reboot into #1: Tue Oct 13 06:36:10 UTC 2026.
  • Same rig, same scaffold, same ps_showallprocs=0:
  • patched run 1: 922,345 poll iterations β†’ 0 bypass, 0 root rows
  • patched run 2: 894,484 poll iterations β†’ 0 bypass, 0 root rows (run_patched.log)
  • Baseline vs patched: 60k-78k bypass iterations per ~25-30 s β†’ zero. The visibility bypass is gone; churn rate and poll rate unchanged (poll iterations actually higher β€” no row copies happening).

Impact / severity reasoning

  • Under security.ps_showallprocs=0 (the documented hardening used on shared multi-user machines): full kinfo_proc rows (argv-adjacent metadata, rusage, tty, exit status) of every other uid β€” including root β€” disclosed to an unprivileged poller, ~12% of polls. Info leak, no crash, no memory-content disclosure beyond the row data.
  • Default config (ps_showallprocs=1): the p_trespass gate is skipped; remaining uses of the stale pointer are PRISON_CHECK (1768) and jailed() (1789) β€” on multi-jail hosts these can mis-judge prison visibility from freed memory; no crash observed or expected (M_CRED zone-resident).
  • UAF read only; no write primitive; no uid change. Severity: Medium.

Reproduce

# one-time root scaffolding
cc -O2 -pthread -o df5822_rig df5822_rig.c
chown root:wheel df5822_rig && chmod 6755 df5822_rig
sysctl -w security.ps_showallprocs=0
# unprivileged
./df5822_rig 25
# unpatched: "[rig] CHURN ... <== STALE cr1 BYPASS" + "root rows observed: NNNNN"
# patched:   "root rows observed   : 0"

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

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff (cr1 = curthread->td_ucred at all three kern.proc handler sites) to the guest's /usr/src, rebuilt with nativekernel, rebooted into the new kernel, re-ran the identical rig and scaffold: baseline 60k-78k bypass iterations per 25-30s run with 60k-80k root rows -> patched 0 bypass iterations / 0 root rows across 922,345 + 894,484 polls. Bypass eliminated.

run_patched.log (0/922345 and 0/894484); VERDICT.md fix-validation section; build_fix.log (kernel install completion lines); env.txt (both kernel versions)
↓ fix.diffper-fix-DF-5822

Confirmed kernel references

Detail

Exploit chain

unprivileged multithreaded process (or any process whose threads legally churn credentials, e.g. setuid-root or ruid!=svuid binaries) -> thread B: setegid churn forcing cratom_proc replace+crfree of p_ucred -> thread A: sysctl(kern.proc.uid.0) whose handler caches the soon-to-be-freed cred at kern_proc.c:1669 -> blocking SYSCTL_OUTs extend the window across the whole call -> per-row p_trespass() reads the freed chunk (recycled M_ZERO -> cr_uid==0 -> return 0) -> other users' kinfo_proc rows emitted to the unprivileged poller

Evidence (decisive lines)

run.log / run.2.log (baseline: 'STALE cr1 BYPASS' marker lines, 73,310/60,297 bypass iterations, root rows 73,620/60,635); control.log (0 rows / 719,556 iters with churn inert); run_patched.log (0 bypass / 922,345 + 894,484 iters after fix); VERDICT.md (full mechanism, table, fix-validation); fix.diff (3-site td_ucred fix, applies clean with git apply --check)

PoC changes

Authored from scratch (no seed). First scaffold attempt (mode 4555) left svgid=1001 so setegid(0) failed EPERM - kept as the control run; final scaffold is mode 6755 (setgid bit keeps svgid=0 through the euid drop). Churn deliberately uses setegid (never touches cr_uid) so bypass rows are attributable to the stale pointer alone.

Verified recommended fix

Use the held per-syscall credential: cr1 = curthread->td_ucred instead of raw curproc->p_ucred at kern_proc.c:1669/1878/2038.

Verdict

Reproduced and fix-validated on the live guest. sysctl_kern_proc() caches an unreferenced credential pointer (kern_proc.c:1669 'cr1 = curproc->p_ucred') and dereferences it per-row across blocking SYSCTL_OUT copyouts (p_trespass at 1715-1721, PRISON_CHECK at 1768, jailed() at 1789). cratom_proc() (kern_prot.c:1170-1190) replaces and crfree()s p_ucred on every setuid-family change from another thread, leaving cr1 dangling; the freed M_CRED chunk recycled by crget() (M_ZERO) reads cr_uid==0 and p_trespass() returns blanket no-trespass (kern_prot.c:1057-1058). Rig: setegid(0)<->setegid(1001) churn never touches cr_uid, so any root kinfo_proc row emitted to the euid-1001 poller under security.ps_showallprocs=0 is provably from the stale pointer: 78,481/632,433 (12.4%), 73,310/845,892 and 60,297/854,135 bypass iterations across three baseline runs (80,584+73,620+60,635 root rows); control (churn inert): 0 rows in 719,556 iterations. After applying fix.diff (cr1 = curthread->td_ucred, the held trap-layer cache, at all three handler sites 1669/1878/2038), rebuilding and booting the patched kernel: 922,345 + 894,484 poll iterations with ZERO bypass iterations and ZERO root rows. Impact ceiling: unprivileged disclosure of other users' (incl. root) full kinfo_proc metadata under the ps_showallprocs=0 hardening config; on default config the stale reads only degrade PRISON_CHECK/jailed decisions (relevant on jail hosts). UAF read only - no write primitive.