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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3235 Β· 28 files
FileTypeDescriptionSize
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)

VERDICT.md
↓ download raw

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:

  1. victim (pinned to cpu1) alternately execs itself with a small argv (~176 bytes β‡’ struct pargs lands 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 guaranteed kfree(M_PARGS) with no same-process realloc to hide it.
  2. fillers (pinned cpu1/cpu2) exec themselves with a byte-count- identical MARKER argv β€” constant same-zone churn that recycles the victim's freed chunk.
  3. 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) and 184B 5.27ms β€” 'A'~93 then 'F'*~90 (HIT; controller printed RESULT: 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

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

With 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']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Wed Sep 9 02:37:12 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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//cmdline, 184x1B iovecs) into never-touched 4KB-strided pages of a 3.5GB file: each iovec copyout takes a cold disk fault, blocking mid-read for 35-500ms with p_token dropped; (4) resumed copy returns the recycled chunk's bytes = another process's argv (any local uid). Ceiling: repeated cross-user argv/stale-heap disclosure (M_PARS zone, default <=256B per chunk); bounded read, no write primitive, no uid=0 chain.

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//cmdline, torn mid-read). Same flaw reaches lwp_lpmap/p_upmap titles (freed by lwp_userunmap/proc_userunmap during exec/exit).