procfs_docmdline() use-after-free read of freed pargs / lwp_lpmap / p_upmap across a blocking uiomove β cross-process argv disclosure past the CHECKIO gate
Summary
procfs_docmdline() captures bp=p->p_args->ar_args and buflen=ar_length (:241-242) and later copies from that pointer via uiomove_frombuf() (:292), whose copyout can block on the reader's destination page fault. Blocking drops every LWKT token the thread holds, including the target's p_token acquired by the caller pfs_pfind(). During the block a concurrent execve() in the target frees the pargs chunk and any process's next exec recycles the same slab chunk and overwrites it with its own argv; the resumed copyout then delivers those bytes to the unprivileged reader. The sibling sysctl path sysctl_kern_proc_args() already implements the correct discipline - refcount_acquire(&pa->ar_ref) around its blocking copy - which this function omitted. The same capture-then-block flaw covers lwp_lpmap->thread_title (:213-217) and p_upmap->proc_title (:230-234), freed during target exec/exit with no refcount at all. VERIFIED on stock kernel: victim alternates small/large argv execs, fillers recycle the freed chunk, reader uses 184x1-byte iovecs into cold-faulted pages of a 3.5GB file (35-500ms post-snapshot window) -> 4 torn reads across 3 runs ('...AAA...FFF...' mid-buffer tear, bytes from a recycled chunk = a DIFFERENT process's argv), bypassing the CHECKIO/p_trespass confidentiality gate. Repeatable cross-user argv disclosure (secrets in argv); bounded read, no write primitive, no uid=0 route. Fix validated in-guest (ar_ref acquire + snapshot copy): 2x300s re-runs of the exact PoC -> zero anomalies over ~22k blocked reads vs 4 torn on stock.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3235 Β· 28 files| File | Type | Description | Size | |
|---|---|---|---|---|
| racer.c | β | 11.7 KB | view raw | |
| build.sh | β | 193 B | view raw | |
| run.sh | β | 111 B | view raw | |
| build.log | β | 71 B | view raw | |
| run.log | β | 11.2 KB | view raw | |
| run.2.log | β | 103 B | view raw | |
| run.3.log | β | 239 B | view raw | |
| run.5.log | β | 93 B | view raw | |
| run.6.log | β | 237 B | view raw | |
| run.7.log | β | 235 B | view raw | |
| run.8.log | β | 80.4 MB | β download | |
| run.9.log | β | 2.4 KB | view raw | |
| run.10.log | β | 263 B | view raw | |
| run.11.log | β | 2.4 KB | view raw | |
| run.12.log | β | 537 B | view raw | |
| run.13.log | β | 392 B | view raw | |
| run.14.log | β | 257 B | view raw | |
| run.15.log | β | 564 B | view raw | |
| run.fix.1.log | β | 246 B | view raw | |
| run.fix.2.log | β | 250 B | view raw | |
| leak_sample.txt | β | 1.5 KB | view raw | |
| env.txt | β | 298 B | view raw | |
| fix.diff | β | 2.7 KB | view raw | |
| fix_build.log | β | 1.2 KB | view raw | |
| VERDICT.md | β | 7.4 KB | β raw | |
| README.md | β | 2.3 KB | β raw | |
| manifest.json | β | 1.4 KB | view raw | |
| verdict.json | β | 4.6 KB | view raw |
DF-3235 β procfs_docmdline() UAF read of struct pargs / lpmap / upmap
sys/vfs/procfs/procfs_status.c β /proc/<pid>/cmdline
Unprivileged local user reads another process's kernel-cached argv (and potentially any local user's argv) out of a freed-and-recycled kernel heap chunk, bypassing the CHECKIO / p_trespass confidentiality gate. Kernel heap use-after-free READ (CWE-416/CWE-125 via CWE-362).
Build (inside the guest, as unprivileged user maxx)
cc -O2 -Wall -pthread -o /home/maxx/racer /home/maxx/racer.c
One-time setup (also unprivileged; the reset snapshot wipes it):
dd if=/dev/urandom of=/home/maxx/.racer_big bs=4m count=880 # 3.5GB, > RAM
Run
cd /home/maxx && ./racer 240 6 # seconds, #fillers
What it does
victim(pinned cpu1): loop { exec self with small argv (~176B, cached in a 192-byte M_PARGS chunk); run ~0.1-1ms; exec self with a 6KB argv (> ps_arg_cache_limit=256, so kern_exec.c FREES p_args and does NOT reallocate); run; repeat }.fillers(pinned cpu1/cpu2): loop { exec self with a byte-identical- sized MARKER argv } β constant M_PARGS 192-byte-zone churn that recycles the victim's freed chunk.readers: readv(/proc//cmdline, 184 x 1-byte iovecs) into a freshly mmap'd, never-touched 4KB-strided window of the 3.5GB file. Every iovec copyout takes a cold page fault (real disk I/O), so the read blocks mid-copy for tens/hundreds of ms β dropping the LWKT p_token held by procfs_rw β while the victim's execve frees the pargs chunk the reader already snapshotted ( bp = p->p_args->ar_args, procfs_status.c:241). The later iovecs are then copied from the freed/recycled chunk.
Expected output (stock kernel)
[reader NNNN] HIT: 184B 5.27ms: /home/maxx/racer.victim.A.AAAA...FFFFF... RESULT: UAF-READ REPRODUCED (MARKER from recycled pargs chunk)
The 'F' tail (filler argv pad bytes) cannot legally appear in the victim's cmdline β the victim never passes an 'F' byte. Observed 4 torn reads across 3 stock-kernel runs (runs 12/13/15 in this pack).
Expected output (kernel with fix.diff)
[reader NNNN] timeout: args=NNN(slow=NNN,anom=0) ... RESULT: no hit within timeout
(two 300s runs, ~12k blocked p_args reads, zero anomalies)
DF-3235 VERDICT β reproduced (kernel heap UAF read β cross-process info disclosure)
Bottom line
procfs_docmdline() snapshots a pointer to the target's p_args
(sys/vfs/procfs/procfs_status.c:241-242), then performs a potentially
blocking uiomove_frombuf() (procfs_status.c:292) that copies from that
pointer to userland. The whole procfs read path runs under the target's
p_token (acquired in pfs_pfind(), sys/vfs/procfs/procfs_subr.c:298),
but DragonFly LWKT tokens are dropped whenever the holding thread blocks
(sys/kern/lwkt_token.c:40: "If the thread blocks all tokens are released,
then reacquired"). A page fault on the reader's destination buffer inside
uiomove()'s copyout() is such a block. During the block, a concurrent
execve() in the target frees the pargs chunk
(sys/kern/kern_exec.c:589-596: p->p_args = NULL;
refcount_release(&pa->ar_ref) β kfree(pa, M_PARGS)), and any process's
next exec re-allocates the same slab chunk and fills it with ITS argv.
When the reader resumes, it copies the recycled chunk's bytes to userland:
a kernel heap use-after-free READ that discloses another process's
argument vector, bypassing the CHECKIO/p_trespass gate that
procfs_docmdline itself enforces (procfs_status.c:203-205, 220-222,
237-239).
The identical structural flaw exists for the two other data sources in the
same function: lp->lwp_lpmap->thread_title (procfs_status.c:202,213-217)
and p->p_upmap->proc_title (procfs_status.c:219,230-234), freed by
lwp_userunmap()/proc_userunmap() during target exec/exit
(sys/kern/kern_exec.c:432-433,981-982; sys/kern/kern_exit.c:688,1269)
β also with no reference that survives a block.
The kernel itself proves the required discipline in the sibling sysctl
path: sysctl_kern_proc_args() takes refcount_acquire(&pa->ar_ref)
around its (blocking) SYSCTL_OUT for exactly this reason
(sys/kern/kern_proc.c:1943-1949). procfs_docmdline omitted it.
How it was reproduced (guest, unprivileged user maxx)
racer.c stages three unprivileged actors:
- victim (pinned to cpu1) alternately execs itself with a small argv
(~176 bytes β
struct pargslands in the 192-byte slab zone, cached because 8+176 β€ ps_arg_cache_limit=256) and with a 6KB argv (> 256 β kern_exec.c:601 does NOT cache β p_args freed and left NULL). Each smallβbig exec is one guaranteedkfree(M_PARGS)with no same-process realloc to hide it. - fillers (pinned cpu1/cpu2) exec themselves with a byte-count- identical MARKER argv β constant same-zone churn that recycles the victim's freed chunk.
- readers
readv()/proc//cmdline with 184 single-byte iovecs into 184 never-touched 4KB-strided pages of a 3.5GB file (> guest RAM, so every fault is a cold disk fault). Each iovec copyout faults and blocks; the victim's exec+free+filler-realloc land inside the block; the remaining iovecs copy the recycled chunk.
Detection: the victim's argv alphabet is only {'/','home','maxx','racer', 'victim','A','A'-padding}. Any run of 'F' bytes (the filler's pad) or the MARKER string in the returned 184 bytes is impossible content β proof the bytes came from a different process's recycled pargs chunk.
Observed torn reads (stock kernel #0):
- run.12:
184B 8.00msβ 'A'134 then 'F'~49 (tear mid-chunk) - run.13:
184B 4.62msβ 'A'~107 then 'F'~75 (HIT) - run.15:
184B 3.38msβ 'F'2 at the very tail (2-byte tear) and184B 5.27msβ 'A'~93 then 'F'*~90 (HIT; controller printedRESULT: UAF-READ REPRODUCED)
4 torn reads in 3 consecutive runs (~780s of racing) β reproducible, not a
fluke. reproduced=1, impact=leak (kernel heap UAF read disclosed to
unprivileged userland; confidentiality of other processes' argv broken;
the recycled chunk can belong to ANY local process, any uid β the PoC's
filler is same-uid only to satisfy the gate on the victim).
Not a write primitive: buflen is snapshotted (victim's ar_length), so
the overrun direction is a bounded read of one β€ps_arg_cache_limit-byte
M_PARS chunk (default 256B; sysctl-raisable). No uid=0 chain; ceiling is
repeated cross-user argv disclosure plus stale-heap content within the
M_PARS zone.
Why earlier attempts missed (engineering notes)
- single-page faults into cached/BCACHE-resident files never sleep (tokens never dropped) β needed a 3.5GB backing file and strided cold pages;
- ms-scale latencies measured on plain reads were pre-snapshot p_token waits (victim's exec holds p_token), useless for the race β the 184Γ1B readv turns the post-snapshot copy into ~184 serialized cold faults (35-500ms windows);
- the victim's smallβsmall exec reallocates the same chunk itself β the big-argv exec (no realloc) is what exposes the freed chunk to the filler;
- slab zones are per-16-bytes in the 128-255 range (kern_slaballoc.c:55) β victim and filler argv byte counts must be exactly equal (184) to share the 192 zone;
usched_set(syscall 481, USCHED_SET_CPU) pins victim+fillers so the filler's kmalloc sees the victim's kfree through the per-CPU magazine.
Fix validation (mandatory: memory-corruption class)
fix.diff (regenerated clean, patch -p1 --dry-run validated):
* pargs branch: refcount_acquire(&pa->ar_ref) + snapshot-copy (or hold
the ref across uiomove when ar_length > snapshot buffer) β mirrors
sysctl_kern_proc_args() (kern_proc.c:1943-1949);
* lpmap/upmap branches: non-blocking bcopy of the title into a
pre-allocated snapshot buffer while p_token is genuinely held (no
allocation between scan and copy), then uiomove from the snapshot.
Guest: vm.sh reset with-src, patch applied to /usr/src, first build
failed on a redundant MALLOC_DECLARE(M_PARGS) (already declared in
sys/proc.h:454) β removed; make nativekernel KERNCONF=X86_64_GENERIC
(1117s) + make installkernel + reboot into kernel
#1: Wed Sep 9 02:37:12 UTC 2026.
Re-run of the exact PoC on the patched kernel: 2 Γ 300s runs
(run.fix.1.log, run.fix.2.log): ~22k legit p_args reads, every one of
them blocked ms-scale in the copyout fault (slow==args), zero torn/F/
MARKER bytes, RESULT: no hit within timeout both times. cmdline
functionality regression-checked (/proc/self/cmdline, ps, status
nodes all fine). Baseline (stock) reproduced 4 torn reads in less total
racing time. fix_status=fixed.
Guest reset to clean with-src snapshot after validation.
Kernel references
- sys/vfs/procfs/procfs_status.c:202-247 (three snapshot branches), :292 (blocking uiomove_frombuf from the captured pointers)
- sys/vfs/procfs/procfs_subr.c:281-306 (pfs_pfind: p_token held), :346-433 (procfs_rw)
- sys/kern/kern_exec.c:226,589-608 (pargs free/realloc under p_token), :432-433,981-982 (lwp_userunmap/proc_userunmap during exec)
- sys/kern/kern_proc.c:1943-1949 (correct refcount discipline, the model)
- sys/kern/kern_proc.c:1300-1310,1356-1383 (proc_userunmap/lwp_userunmap)
- sys/kern/kern_exit.c:688,1269 (teardown call sites)
- sys/kern/lwkt_token.c:40 (tokens dropped on block)
- sys/kern/kern_subr.c:96-160 (uiomove copyout), :272-284 (uiomove_frombuf)
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
fixedWith fix.diff applied (pargs refcount + title snapshot), the exact PoC produced zero torn/reused reads over 600s (2 runs, ~22k ms-blocked p_args reads, slow==args, anom=0) vs 4 torn reads in ~780s on stock; cmdline functionality regression-checked.
['run.fix.1.log', 'run.fix.2.log', 'fix_build.log', 'fix.diff']
Confirmed kernel references
- sys/vfs/procfs/procfs_status.c:202-247
- sys/vfs/procfs/procfs_status.c:292
- sys/vfs/procfs/procfs_subr.c:281-306
- sys/kern/kern_exec.c:589-608
- sys/kern/kern_exec.c:432-433
- sys/kern/kern_exec.c:981-982
- sys/kern/kern_proc.c:1943-1949
- sys/kern/kern_proc.c:1300-1310
- sys/kern/kern_proc.c:1356-1383
- sys/kern/kern_exit.c:688
- sys/kern/kern_exit.c:1269
- sys/kern/lwkt_token.c:40
- sys/kern/kern_subr.c:96-160
Detail
Exploit chain
unpriv user: (1) victim process cycles exec small-argv (pargs cached, 192B slab zone) / exec big-argv (pargs freed, no realloc); (2) filler processes exec MARKER-argv of identical byte count (same slab zone, same-CPU magazine) to recycle the freed chunk; (3) reader readv(/proc/
Evidence (decisive lines)
["run.13.log / run.15.log: '[reader] HIT: 184B ...AAAA...FFFF...' + 'RESULT: UAF-READ REPRODUCED'", "run.12.log: torn read '184B 8.00ms' A-tail->F-tail mid-chunk", 'leak_sample.txt: all four torn reads collected', 'run.fix.1.log + run.fix.2.log: patched kernel, ~22k blocked p_args reads, anom=0, no hit (fix validated)', 'VERDICT.md: full path:line trace and engineering notes']
PoC changes
Seed concept fully rewritten: added usched_set CPU pinning (magazine locality), exact argv byte-count matching to share the 192-byte slab zone (kern_slaballoc.c chunking), big-argv exec phase so p_args is freed WITHOUT same-process realloc, 3.5GB cold backing file (guest RAM 4GB), 184x1-byte readv into strided cold pages so the copyout faults serialize into a 35-500ms post-snapshot window, sigsetjmp SEGV guard, tail-anomaly + MARKER detection, controller SIGCHLD fix.
Verified recommended fix
Hold a pargs reference across the blocking copy (refcount_acquire/release exactly like sysctl_kern_proc_args, kern_proc.c:1943-1949) and bcopy the lpmap/upmap titles into a pre-allocated snapshot buffer before uiomove; full diff in fix.diff.
Verdict
procfs_docmdline() snapshots bp=p->p_args->ar_args (procfs_status.c:241) and later copies from it via a potentially blocking uiomove_frombuf() (procfs_status.c:292); blocking drops the caller's p_token (lwkt_token.c:40), so a concurrent execve in the target frees the pargs chunk (kern_exec.c:589-596) and any process's next exec recycles the slab chunk, whose bytes are then copied to the unprivileged reader: kernel heap UAF read disclosing other processes' argv past the CHECKIO/p_trespass gate. Reproduced 4x on stock kernel #0 (impossible 'F'/MARKER filler bytes returned from /proc/
No comments yet.