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

/dev/devctl FIOASYNC stores unreferenced curproc in devsoftc.async_proc; devclose never clears it -> ksignal() on freed struct proc (UAF write)

Field Value
ID DF-2680
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:L/I:L/A:H
CWE CWE-416 UAF, CWE-772 Missing Protection of Alternate Path
File sys/kern/subr_bus.c
Lines 412-417 (store), 352-361 (missing clear), 521-523 (ksignal)
Area kern
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

devioctl() FIOASYNC stores raw curproc into devsoftc.async_proc with no reference (subr_bus.c:412-417). When the owner exits, devclose() resets inuse but leaves async_proc dangling (:352-361); the chunk is freed at reap (kern_exit.c:1336). Every later devctl event runs ksignal(stale_proc, SIGIO) (:521-523): lwpsignal() executes PHOLD(p), lwkt_gettoken(&p->p_token) and the lwp walk on freed/reused memory.

Threat model & preconditions

Root-gated setup (devctl is 0600 + SYSCAP_RESTRICTEDROOT) but the corruption persists and triggers after the owner exits: demonstrated cross-process SIGIO delivery to an unrelated process that recycled the freed proc chunk (5/5 rounds); with non-proc reuse, PHOLD/token writes corrupt foreign kernel objects.

Proof of concept

findings/poc/DF-2680/run_seq.sh: victim (root) opens /dev/devctl, FIOASYNC=1, exits; catcher processes (never open devctl) recycle the freed struct proc; kldload of the DF-2679 churn module fires 1024 devctl events → GOT_SIGIO pid=<victim+1> (never opened devctl) in 5/5 rounds; live-path control confirms the FIOASYNC→ksignal path.

Clear async_proc under devsoftc.lock in devclose() (two-line diff in findings/poc/DF-2680/fix.diff); full fix holds a PHOLD/PRELE reference in devioctl or converts to sigio-list semantics.

Timeline

  • 2026-08-29 Discovered during pass-2 audit of subr_bus.c (GLM 5.3); reproduced 5/5 same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2680 Β· 9 files
FileTypeDescriptionSize
victim.c β€” 785 B view raw
victim2.c β€” 774 B view raw
catcher.c β€” 757 B view raw
run_seq.sh β€” 1.3 KB view raw
run.log β€” 1.5 KB view raw
env.txt β€” 532 B view raw
fix.diff β€” 470 B view raw
VERDICT.md β€” 2.6 KB ↓ raw
verdict.json β€” 3.2 KB view raw
VERDICT.md
↓ download raw

DF-2680 β€” VERDICT

status: reproduced, impact: dos (kernel UAF write; benign-but- visible manifestation), confidence: certain.

Reproduced how

  1. Control (live path) β€” victim2 (root) opens /dev/devctl, sets FIOASYNC=1 with a SIGIO handler installed, and stays alive. Loading the dfrace churn module (512 device attaches β†’ devadded β†’ devaddq β†’ devctl_queue_data) delivers SIGIO: victim2: GOT_SIGIO (live path works) pid=1961. (Necessary because SIGIO's default disposition in DragonFly is ignore β€” a handler is required to observe delivery.)

  2. Stale path β€” victim (root) sets FIOASYNC=1 and exits without clearing it. Its struct proc is reaped and freed (kern_exit.c:1336). devclose() leaves devsoftc.async_proc pointing at the freed chunk. The next fork β€” a catcher process that never opened /dev/devctl β€” recycles the chunk. Firing devctl events then executes ksignal(async_proc, SIGIO) on the recycled memory and the catcher receives SIGIO:

victim: pid=1993 enabled FIOASYNC on /dev/devctl, exiting GOT_SIGIO pid=1994 (never opened devctl)

5/5 rounds reproduced (run.log).

Why this is the bug and not something else

  • devopen() clears async_proc on the next open, so during the window the pointer is exactly the exited victim's proc.
  • The catcher demonstrably never opened /dev/devctl; SIGIO can only have arrived via devctl_queue_data()'s ksignal(devsoftc.async_proc).
  • lwpsignal() wrote PHOLD(p) / token state into the recycled chunk before delivering β€” i.e. kernel writes through a dangling pointer occurred; the signal delivery is merely the visible edge.

Impact ceiling

If the freed chunk is recycled by a non-proc kernel allocation (any M_PROC-bucket-sized object) instead of a new process, PHOLD(p) (a refcount increment at the chunk's p_lock offset) and lwkt_gettoken(&p->p_token) corrupt that object. No escalation chain was developed because the setup itself requires root (devopen is SYSCAP_RESTRICTEDROOT-gated and the node is 0600), so the finding is filed Low severity; the demonstrated runtime effect is cross-process signal delivery through freed kernel memory (a correctness/security defect any devd-adjacent root daemon can trip over by exiting while FIOASYNC is set).

Fix validation

Not rebuilt for this Low-severity finding (fix is a two-line close-path clear + optional PHOLD/PRELE); fix.diff provided for the record and reasoned through: clearing async_proc under devsoftc.lock in devclose removes the dangling target; devopen already clears it on reuse.

Fix verification

not_testable
↓ fix.diffper-fix-DF-2680

Confirmed kernel references

Detail

Exploit chain

root opens /dev/devctl + FIOASYNC -> exits without clearing -> async_proc dangles -> any later devctl event ksignal()s freed memory -> PHOLD/token writes into whatever recycled the chunk (cross-process SIGIO demonstrated; foreign-object corruption if recycled by non-proc data). No uid0 chain (root-gated setup).

Evidence (decisive lines)

run.log (control GOT_SIGIO pid=1961 live path; stale rounds GOT_SIGIO pid=1994/2032/2048/2075/2091 'never opened devctl'); victim.c/victim2.c/catcher.c; VERDICT.md; fix.diff

PoC changes

PoC authored fresh. Iterations: initial runs showed no SIGIO because (a) SIGIO's default disposition is ignore, so both the control and the catcher need handlers installed, and (b) the DF-2679 churn module's children never attached, so no devadded/devremoved devctl events fired at all - fixed by adding a real driver to the churn module; devd must be stopped because /dev/devctl allows a single reader.

Verified recommended fix

devclose(): devsoftc.async_proc = NULL under devsoftc.lock (and hold a PHOLD/PRELE reference on the stored proc in devioctl if SIGIO-after-exit is ever desired).

Verdict

Use-after-free of devsoftc.async_proc reproduced on the stock kernel with a visible, benign manifestation: a root process opened /dev/devctl, issued FIOASYNC=1 (devioctl stores raw curproc, subr_bus.c:412-417, no reference) and exited; devclose (subr_bus.c:352-361) left async_proc dangling after the struct proc was reaped and kfree'd (kern_exit.c:1336). The next fork recycled the freed chunk, and every subsequent devctl event (device attach/detach -> devctl_queue_data, subr_bus.c:521-523) executed ksignal() on the recycled memory: lwpsignal() ran PHOLD(p) and lwkt_gettoken(&p->p_token) (kernel writes through the dangling pointer) and delivered SIGIO to a process that never opened /dev/devctl. 5/5 rounds reproduced (catcher pid = victim pid + 1 each time); a live-path control (victim stays alive, handler installed) confirmed the FIOASYNC->ksignal delivery path first. Trigger requires root to open the 0600 RESTRICTEDROOT-gated device, hence Low severity despite the kernel-memory-write primitive.