# DF-4634 — snd_clone_gc()/snd_clone_destroy() free clone entries whose open() is still in flight (UAF + unprivileged cdev/devfs-node leak)

## File / root cause

`sys/dev/sound/clone.c:421-437` (`snd_clone_gc`) and `:444-465`
(`snd_clone_destroy`).

An `snd_clone_entry` lives in two phases:

1. **INVOKE** — the dev_clone handler (`dsp_clone`, sys/dev/sound/pcm/dsp.c:2495-2510)
   created/reused the entry and handed its cdev to devfs; the `open()` that
   triggered the clone is **still in flight** (between
   `devfs_spec_open`'s clone dispatch and `dsp_open`'s
   `snd_clone_acquire`, sys/dev/sound/pcm/dsp.c:481).
2. **BUSY** — `snd_clone_acquire` marked it; gc skips it.

`snd_clone_gc` treats "INVOKE and older than the 750 ms deadline" as garbage
(`SND_CLONE_EXPIRED`, clone.c:381-384) and destroys the entry:
`TAILQ_REMOVE` + `destroy_dev(ce->devt)` + `devfs_clone_bitmap_put` +
`kfree(ce)` (clone.c:429-434). The 750 ms figure is only a *timing
assumption* about the dev_clone→open gap (clone.c:55-58) — the gap is
**unbounded**, because `dsp_open` first does `PCM_WAIT`
(sys/dev/sound/pcm/dsp.c:475) and sleeps until the per-card SD_F_BUSY sleep
lock is released, and any concurrent `dsp_close` holds SD_F_BUSY for the
whole `chn_flush()`→`chn_sync()` drain (dsp.c:721-797, channel.c:983-999) —
attacker-controllable to tens of seconds by filling the playback buffer at a
low sample rate (8 kHz u8 mono ⇒ 16 KB = 2 s of drain; the PoC measures
2.09 s closes).

`snd_clone_destroy` (pcm_unregister path) has the same gap: it is only
guarded by `snd_clone_busy` (BUSY entries), so a pending-INVOKE opener can be
underway when the whole list is kfree'd.

## Consequences (both demonstrated on the QEMU guest, unprivileged user)

1. **Use-after-free**: after the gc prune, the stalled opener wakes and runs
   `snd_clone_acquire` → `ce = dev->si_drv2` (freed) → `ce->flags &=
   ~SND_CLONE_INVOKE; ce->flags |= SND_CLONE_BUSY` — UAF write into a freed
   64-byte M_DEVBUF chunk; later `snd_clone_ref` → `c = ce->parent` (read
   from freed memory) → `++c->refcount` through that pointer. With heap
   reuse this is a cross-object corruption primitive (analysis: if the chunk
   is recycled as another entry, a *live* entry gets BUSY-flagged; if as a
   `struct dsp_cdevinfo` (48 B → same malloc bucket), `ce->parent` reads
   `cdi->rdch` and the increment lands on `pcm_channel->speed`).
2. **Cdev + devfs-node leak (persistent)**: the prune's `destroy_dev` runs
   while the opener still holds the node's vnode (locked LK_EXCLUSIVE across
   its open). The node is not removed and the cdev is never fully released —
   `/dev/dsp0.N` entries accumulate **~3 per race round, unboundedly**
   (measured: 282 leaked nodes after ~30 rounds; module `kldunload sound`
   then fails with `Device busy` forever — the leaked cdevs pin the module).
   Pure memory-exhaustion DoS from an unprivileged user (device is 0666).

## PoC

See `race2.c` (+ `race.c` single-victim version, `probe*.c` timing probes).
Build/run via `build.sh` / `run.sh` — all as the unprivileged `maxx` user.
Expected output on a vulnerable kernel: `trapped=4/4` rounds (victims
blocked ~2 s behind the staller's close = pending-INVOKE state pruned at the
staller's unref-gc), and `ls /dev/dsp0.* | wc -l` growing without bound.
With `fix.diff` applied (sound.ko rebuilt + reloaded): traps still occur but
the node count stays bounded at 0-1 and unload works after reboot.

## Fix

`fix.diff`: `snd_clone_gc` must never free entries that still have
SND_CLONE_INVOKE set — only BUSY-less **and** INVOKE-less entries are safe
to reap; INVOKE entries are recycled later through the normal reuse paths
(bounded by maxunit). Hardening companions (not in the minimal diff):
`snd_clone_destroy` should revoke/drain pending invokes before freeing, and
`devfs_destroy_dev_worker` (sys/vfs/devfs/devfs_core.c:1440-1460) leaks the
cdev link reference whenever `devfs_unlink_dev` returns an error — it should
release unconditionally or handle the unlink-failure case.
