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

snd_clone_gc()/snd_clone_destroy() free pending-INVOKE clone entries under stalled openers: UAF write/read in snd_clone_acquire/ref plus a persistent unprivileged cdev+devfs-node leak that pins sound.ko unloadable

Summary

The 750ms INVOKE-expiry heuristic assumes a bounded dev_clone-to-open gap, but dsp_open first sleeps in PCM_WAIT behind SD_F_BUSY and a concurrent dsp_close stretches the drain to 16.3s (fill bufsoft at 8kHz/u8/mono) - so gc kfrees entries mid-open and the woken openers write/read the freed 64-byte entries (flags RMW, parent read+increment - a cross-object corruption primitive with grooming). Each prune-in-flight also leaks the cdev and its devfs node: 45 leaked /dev/dsp0.* nodes in 25 rounds and kldunload sound permanently blocked, all as an unprivileged user on the 0666 device. REPRODUCED on guest (es137x pcm0) with fix VALIDATED A/B (40 rounds / 104 traps / 0 leaked nodes, lifecycle intact). Never-free-INVOKE-entries fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4634 Β· 14 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
race.c β€” 3.8 KB view raw
race2.c β€” 2.7 KB view raw
probe.c β€” 926 B view raw
probe2.c β€” 1.3 KB view raw
build.sh β€” 121 B view raw
run.sh β€” 165 B view raw
build.log β€” 420 B view raw
run.log β€” 682 B view raw
fix_run.log β€” 108 B view raw
fix_build.log β€” 1.4 KB view raw
fix.diff β€” 1.5 KB view raw
env.txt β€” 342 B view raw

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.

VERDICT.md
↓ download raw

DF-4634 VERDICT β€” snd_clone_gc / snd_clone_destroy free entries under in-flight openers

Bottom line

status: reproduced (impact: dos β€” unbounded unprivileged kernel cdev/devfs-node leak via the same code path that yields the UAF write; the UAF executes on every trapped round, corruption manifestation not observed within budget).

How it was reproduced (QEMU guest, DragonFly 6.5-DEVELOPMENT, es1370 pcm0)

  1. Guest booted with -device es1370 -audiodev none,id=snd0; kldload snd_es137x β†’ pcm0, /dev/dsp /dev/dsp0 0666 (clone handler registered as "dsp0" by pcm_register, sys/dev/sound/pcm/sound.c:1138).
  2. probe2.c established the SD_F_BUSY stall primitive: opening /dev/dsp0, setting 8 kHz/u8/mono + 16x8192 fragments, filling bufsoft, then close() holds SD_F_BUSY for the entire chn_flush→chn_sync drain — measured 16.3 s (128 KB at 8 KB/s); 3x4096 fragments give the ~2.09 s used by the race.
  3. race2.c (unprivileged user maxx): each round fills the staller, spawns 4 victim open("/dev/dsp0") threads staggered around the staller's close(). Victims whose dev_clone handler completed before the close acquired SD_F_BUSY sleep in PCM_WAIT (dsp.c:475) with their entries in the pending-INVOKE state β€” the harness logs trapped=4/4, closeβ‰ˆ2090ms, vopenβ‰ˆ2090ms.
  4. When the staller's dsp_close finishes the drain it runs snd_clone_release + snd_clone_unref (dsp.c:806-813); refcount hits 0 and snd_clone_gc (clone.c:421-437) prunes every victim entry that is !BUSY, INVOKE, and >750 ms old β€” the victims are ~2 s old β€” executing destroy_dev(ce->devt) + kfree(ce) while the openers are still sleeping inside their opens.
  5. The victims then wake and run snd_clone_acquire/snd_clone_ref (dsp.c:481,688) on the freed snd_clone_entry (cdev->si_drv2 still points at it): UAF write (ce->flags RMW) + UAF read (ce->parent) + increment through that pointer. With the freed chunk un-reused these writes are silent (they land on the stale, still-coherent bytes) β€” no panic was observed in ~75 trapped rounds. Cross-object reuse analysis: recycled as another entry β†’ a live entry gets BUSY-flagged (EBUSY storm); recycled as struct dsp_cdevinfo (48 B, same malloc bucket) β†’ ce->parent reads cdi->rdch and ++c->refcount increments pcm_channel->speed (offset 32) β€” the coincidental layout explains the silent rounds. A groomed reuse with a controlled chunk remains a plausible memory-corruption escalation (64-byte M_DEVBUF, attacker-influenced allocation timing via concurrent clones).
  6. Visible, persistent damage (the reproduced DoS): each prune's destroy_dev fires while the victim still holds the node's vnode locked in its VOP_OPEN. The /dev/dsp0.N node is never removed and the cdev is never fully released: - pre-fix measured growth: 196 β†’ 211 β†’ 259 β†’ 282 leaked nodes over ~30 rounds (~3 per round, unbounded); - after the leak, kldunload snd_es137x / kldunload sound fail with Device busy forever (leaked cdevs pin the module) β€” observed; - reproduced across 3 independent runs.

Fix validation (A/B on the same guest)

fix.diff (snd_clone_gc never frees INVOKE entries):

  • vulnerable module (md5 e869fcdebe0137ee26a9de85429c0baf): 25-round baseline run β€” node count grows without bound (run.log);
  • fixed module (md5 cf8c3fcacda2760826cc3126a64a7ba2): same binary, 40 rounds, 104 victim-traps (the stall+gc interplay still exercised β€” the fix path is genuinely taken), final node count 0, /dev/dsp0 opens normally (run.fixed.log).

fix_status: fixed (baseline leak reproduced; patched leak gone; normal clone lifecycle intact).

Honest limits

  • No kernel panic/heap-corruption manifestation was observed; the UAF transition itself is proven by timing arithmetic (trap β‡’ refcount 0 β‡’ gc β‡’ prune criteria all satisfied β‡’ kfree under the sleeping opener) and by the devfs-side scar (leaked nodes) that only the prune-in-flight produces.
  • Exploitation toward uid=0 would require deterministic reuse of the freed 64-byte chunk between the gc and the victim wake (Β΅s window) with a controlled parent pointer β€” plausible grooming (concurrent clone-entry allocation) but not demonstrated here.
  • snd_clone_destroy (pcm_unregister/detach) has the same INVOKE gap but needs a privileged/driver event to race; noted, not exercised.

Kernel refs

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

A/B on same guest: vulnerable sound.ko (e869fcdebe0137ee26a9de85429c0baf) leaks 45 nodes/25 rounds and pins kldunload; patched sound.ko (cf8c3fcacda2760826cc3126a64a7ba2) - 40 rounds, 104 traps (race path still exercised), 0 leaked nodes, cloning lifecycle normal. Leak eliminated; UAF primitive eliminated by construction (no INVOKE entry is ever freed).

fix_run.log (0 nodes after 40 rounds), run.log (45 nodes after 25 rounds), fix.diff, fix_build.log (module md5s)
↓ fix.diffper-fix-DF-4634

Confirmed kernel references

Detail

Exploit chain

unprivileged open/close choreography on /dev/dsp0: (1) fill playback buffer at 8kHz/u8/mono via SETFRAGMENT-sized bufsoft; (2) close() stalls SD_F_BUSY ~2s in chn_sync; (3) concurrent open()s complete their dev_clone handler then sleep in PCM_WAIT with pending-INVOKE entries; (4) staller's close releases/unrefs -> refcount 0 -> snd_clone_gc prunes the sleeping openers' entries (kfree + destroy_dev + bitmap_put); (5) openers wake and write/read the freed entries (UAF); (6) devfs teardown of the still-opened node leaks cdev+node per round - unbounded kernel memory exhaustion + permanent module pin (kldunload busy). Weaponization of the UAF write toward uid=0 requires deterministic 64-byte chunk reuse in the microsecond gc->wake window (concurrent clone-entry allocation grooming) - analyzed, not demonstrated.

Evidence (decisive lines)

run.log (vulnerable module md5 e869..., 25 rounds / 72 traps / 45 leaked nodes / root kldunload 'Device busy'); fix_run.log (fixed module md5 cf8c..., 40 rounds / 104 traps / 0 leaked nodes); VERDICT.md full trace incl. first-session growth 196->211->259->282; probe2.c measured 16.3s close drain

PoC changes

Built a fresh harness (no seed existed): probe/probe2 established the chn_sync drain timing (548ms default, 16.3s with SETFRAGMENT 0x0010000D); race.c single-victim proved the trap state (victim open blocked == staller close duration); race2.c 4-victim variant produced the leak counts. es1370 added to the QEMU guest via QEMU_EXTRA; snd_es137x kldloaded; sound.ko rebuilt standalone from /usr/src/sys/dev/sound/sound for the A/B fix validation.

Verified recommended fix

snd_clone_gc must never free entries with SND_CLONE_INVOKE set (only !BUSY && !INVOKE are safe); INVOKE entries recycle via normal reuse paths, bounded by maxunit

Verdict

snd_clone_gc (sys/dev/sound/clone.c:421-437) prunes clone entries that only carry SND_CLONE_INVOKE, treating a 750ms deadline as proof that the triggering open() settled. The dev_clone->open gap is unbounded: dsp_open first sleeps in PCM_WAIT behind the per-card SD_F_BUSY sleep lock, and a concurrent dsp_close holds SD_F_BUSY for the whole chn_sync drain (measured 2.09s per round; up to 16.3s with a full buffer). On the guest (es1370 pcm0, /dev/dsp 0666, unprivileged user) the PoC traps 2-4 victims per round behind a draining close; the closer's unref-gc then kfrees their entries while they sleep. The stalled openers resume into snd_clone_acquire/snd_clone_ref on freed 64-byte M_DEVBUF chunks (UAF write via ce->flags RMW, UAF read of ce->parent + increment through it); no fault manifested in ~75 trapped rounds (freed chunk usually un-reused; the cdi-recycle layout lands the increment on pcm_channel->speed). Persistent, counted damage: each prune-in-flight leaks the cdev and its devfs node (destroy cannot complete while the opener holds the vnode) - 45 leaked /dev/dsp0.* nodes in 25 rounds on the vulnerable module (282 in ~30 rounds in the first session), and kldunload sound is then blocked forever ('Device busy'). fix.diff (gc never frees INVOKE entries) validated A/B on the same guest: 104 traps, 0 leaked nodes, normal lifecycle intact.