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)
PoC verification
Evidence pack
findings/poc/DF-5822 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
DF-5822 VERDICT
status: reproduced / impact: visibility-bypass info disclosure (kernleak) / confidence: certain / fix validated (fixed)
Root cause (path:line)
sysctl_kern_proc()caches an unreferenced credential: sys/kern/kern_proc.c:1669struct ucred *cr1 = curproc->p_ucred;- It dereferences
cr1per scanned row across blockingSYSCTL_OUTcopyouts: kern_proc.c:1715-1721 (p_trespass(cr1, crcache)underps_showallprocs==0), 1768 (PRISON_CHECK), 1789 (jailed(cr1)). - 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.
- 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). 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 seescr_uid == 0and hitsif (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): fullkinfo_procrows (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 arePRISON_CHECK(1768) andjailed()(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
fixedApplied 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)
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.
No comments yet.