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

dsp_clone() funnels concurrently-running clone handlers through one shared `static struct cdev *dev` β€” an unprivileged open of /dev/dspN can be handed another card's clone cdev (reproduced), and the stale static can hand devfs a destroyed cdev under concurrent pcm_unregister (traced UAF)

Summary

pcm_register installs one devfs clone handler per pcm unit; devfs invokes clone handlers with the devfs lock released, so handlers for different units run fully concurrently - yet dsp_clone keeps the outgoing cdev in a single function-static. Last writer wins: REPRODUCED on the guest (ES1370+intel-hda) - an open of /dev/dsp1 returned card 0's clone cdev (fstat minor 0; AUDIOINFO devnode /dev/dsp0.vp0), unprivileged, 1 hit in ~118k opens. Cross-card channel confusion (all nodes 0666). Additionally the stale-static races snd_clone_destroy under concurrent unregister - a traced cdev UAF. FreeBSD uses a local variable - a DF port regression. Local-variable fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4322 Β· 12 files
FileTypeDescriptionSize
dsp_race.c β€” 3.2 KB view raw
build.sh β€” 103 B view raw
run.sh β€” 174 B view raw
build.log β€” 892 B view raw
run.log β€” 232 B view raw
run.2.log β€” 132 B view raw
run.3.log β€” 133 B view raw
run.4.log β€” 133 B view raw
VERDICT.md β€” 4.5 KB ↓ raw
fix.diff β€” 493 B view raw
verdict.json β€” 4.4 KB view raw
manifest.json β€” 1.2 KB view raw
VERDICT.md
↓ download raw

DF-4322 β€” dsp_clone() shared static struct cdev *dev race (cross-card clone handoff)

File: sys/dev/sound/pcm/dsp.c (dsp_clone), reached from the pcm core registry created in sys/dev/sound/pcm/sound.c (pcm_register -> devfs_clone_handler_add(devtoname(d->dsp_clonedev), dsp_clone), sound.c:1138, plus the global "dsp" handler from sound_modevent, sound.c:1425).

Kernel: DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (X86_64_GENERIC)

How to reproduce

Guest needs TWO registered pcm units (here: pcm0 = QEMU ES1370 -> snd_es137x, pcm1 = intel-hda+hda-duplex -> snd_hda). Launch with dfbsd-qemu/vm_snd.sh, then as root: kldload snd_es137x; kldload snd_hda. Verify /dev/dsp0 and /dev/dsp1 exist.

build.sh   # cc -O2 -pthread -o /tmp/dsp_race /tmp/dsp_race.c   (as user maxx)
run.sh     # /tmp/dsp_race                                       (as user maxx)

Expected (race hit): a line

[!] RACE HIT: opened /dev/dsp1, fd is clone of card 0 (devnode /dev/dsp0.vp0, name pcm0:virtual:dsp0.vp0)

plus N cross-card mismatches >= 1 and exit status 1. The race is rare (observed ~1 hit per 10^5 open cycles); repeat runs or longer RUN_SECONDS if no hit in the first window. run.2.log/run.3.log/run.4.log show hit-free characterization runs (~36k/56k/38k cycles).

What the PoC does

Parallel threads hammer open("/dev/dsp0") and open("/dev/dsp1"). Every successful open goes through the devfs clone handler dsp_clone(); after each open the fd is fstat()ed and the unit byte of the minor (minor & 0xff, PCMUNIT, sound.h:115) is compared against the unit that was actually opened. On mismatch, SNDCTL_AUDIOINFO (dev=-1) is issued to name the backing channel (ai.devnode/ai.name).

Observed hit: an open("/dev/dsp1") returned a cdev whose minor encodes unit 0 and whose channel is pcm0:virtual:dsp0.vp0 β€” i.e. the clone handler for card 1 handed card 0's clone cdev to devfs for the open.

Root cause

dsp_clone() keeps the outgoing cdev in a function-static variable:

static struct cdev *dev = NULL;            /* dsp.c:2381 */
...
ce = snd_clone_alloc(d->clones, &dev, &cunit, udcmask);   /* dsp.c:2495 */
if (ce != NULL) {
        ...
        dev = make_only_dev(...);                          /* dsp.c:2506 */
        ...
}
...
ap->a_dev = dev;                                          /* dsp.c:2520 */

devfs invokes clone handlers with devfs_lock released (sys/vfs/devfs/devfs_core.c:2329-2336), and each pcm unit has its own snddev_info lock/busy-flag, so two opens on two different pcm units run dsp_clone() fully concurrently while sharing this one global. Whichever thread stores dev last before the other's ap->a_dev = dev read wins, and the wrong card's cdev is returned to devfs for the open. FreeBSD's dsp_clone uses a local struct cdev *dev = NULL; β€” the static is a DragonFly port regression.

Consequences: 1. Demonstrated (unprivileged, this PoC): cross-card channel confusion β€” an fd opened via /dev/dsp1 operates on pcm0's channel (and vice versa). 2. Traced, not demonstrated: if snd_clone_alloc() returns NULL via its early exits (clone manager disabled mid-flight, e.g. during pcm_unregister's snd_clone_disable() at sound.c:1200) it does not set *dev (clone.c:627-629), so ap->a_dev is the stale static pointer β€” possibly a cdev already destroyed by snd_clone_destroy()/GC (sound.c:1226, clone.c:431/457) β€” handing devfs a freed cdev (UAF).

Both pcm devices are 0666 by default, so (1) crosses no permission boundary on a default install; its security weight is that /dev/dspN opens are not bound to card N. (2) requires a concurrent pcm_unregister (driver detach β€” root) racing an open.

Fix

Make dev local again (matches FreeBSD):

--- a/sys/dev/sound/pcm/dsp.c
+++ b/sys/dev/sound/pcm/dsp.c
@@ -2379,7 +2379,7 @@ dsp_clone(struct dev_clone_args *ap)
    int i, unit, udcmask, cunit, devtype, devhw, devcmax, tumax;
    size_t len;
    char sname[64];
    int err = EBUSY;
-   static struct cdev *dev = NULL;
+   struct cdev *dev = NULL;

fix.diff carries this patch (authored against the read-only sys/ tree; not applied there).

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

not_testable
baseline reproduced→ patch + rebuild →patched clean

Structural one-line fix (localize dev) eliminates the shared state; kernel-build fix validation was not performed because the baseline race probability (~1 hit per 1e5 opens) makes a single patched run statistically uninformative.

fix.diff; VERDICT.md root-cause section
↓ fix.diffper-fix-DF-4322

Confirmed kernel references

Detail

Exploit chain

Unprivileged: open("/dev/dsp0") and open("/dev/dsp1") in parallel loops -> devfs drops devfs_lock and invokes both dsp_clone() handlers concurrently -> shared static dev last-writer-wins -> wrong card's clone cdev returned to devfs -> open("/dev/dsp1") yields a pcm0 channel (proved via fstat minor + SNDCTL_AUDIOINFO devnode).

Evidence (decisive lines)

run.log (decisive run: 'RACE HIT: opened /dev/dsp1, fd is clone of card 0 (devnode /dev/dsp0.vp0, name pcm0:virtual:dsp0.vp0)', 117879 cycles, 1 mismatch); run.2/3/4.log (hit-free characterization); VERDICT.md (full trace with dsp.c/clone.c/sound.c/devfs_core.c line refs); fix.diff (one-line fix: make `dev` local, matching FreeBSD)

PoC changes

none -- PoC authored fresh for this finding (fstat minor-byte check per open for speed; SNDCTL_AUDIOINFO confirmation on mismatch)

Verified recommended fix

Make the outgoing cdev a local variable in dsp_clone(): struct cdev *dev = NULL; instead of static struct cdev *dev = NULL; (dsp.c:2381), matching FreeBSD.

Verdict

Reproduced on DragonFly 6.5-DEVELOPMENT (X86_64_GENERIC) with two QEMU sound cards (pcm0=ES1370, pcm1=intel-hda). dsp_clone() stores the outgoing cdev in a function-static global (dsp.c:2381); devfs runs clone handlers with devfs_lock dropped (devfs_core.c:2329-2336) and each pcm unit has independent locks, so handlers for different units run concurrently while sharing that global. An unprivileged open loop (4+4 threads, ~118k opens) produced a /dev/dsp1 open whose fd carried card 0's clone cdev (minor unit byte 0, SNDCTL_AUDIOINFO devnode /dev/dsp0.vp0, channel pcm0:virtual:dsp0.vp0). Three further ~36-56k-open runs were hit-free, characterizing the race as rare (~1e-5 per open) but real. Demonstrated impact is cross-card channel confusion; both dsp nodes are 0666 by default so no permission boundary is crossed on a default box, hence impact=none in the enum (logic defect). Traced but not demonstrated: snd_clone_alloc()'s early NULL returns (clone.c:627-629) do not set *dev, so a dsp_clone() racing pcm_unregister()'s snd_clone_disable() (sound.c:1200) can return the stale static -- potentially a cdev already destroyed by snd_clone_destroy/GC (sound.c:1226, clone.c:431/457) -- handing devfs a freed cdev (UAF/panic ceiling, requires root-side detach concurrency).