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)
PoC verification
Evidence pack
findings/poc/DF-4634 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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:
- 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; theopen()that triggered the clone is still in flight (betweendevfs_spec_open's clone dispatch anddsp_open'ssnd_clone_acquire, sys/dev/sound/pcm/dsp.c:481). - BUSY β
snd_clone_acquiremarked 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)
- 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; latersnd_clone_refβc = ce->parent(read from freed memory) β++c->refcountthrough 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 astruct dsp_cdevinfo(48 B β same malloc bucket),ce->parentreadscdi->rdchand the increment lands onpcm_channel->speed). - Cdev + devfs-node leak (persistent): the prune's
destroy_devruns 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.Nentries accumulate ~3 per race round, unboundedly (measured: 282 leaked nodes after ~30 rounds; modulekldunload soundthen fails withDevice busyforever β 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.
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)
- Guest booted with
-device es1370 -audiodev none,id=snd0;kldload snd_es137xβ pcm0,/dev/dsp/dev/dsp00666 (clone handler registered as "dsp0" by pcm_register, sys/dev/sound/pcm/sound.c:1138). probe2.cestablished 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 entirechn_flushβchn_syncdrain β measured 16.3 s (128 KB at 8 KB/s); 3x4096 fragments give the ~2.09 s used by the race.race2.c(unprivileged usermaxx): each round fills the staller, spawns 4 victimopen("/dev/dsp0")threads staggered around the staller'sclose(). Victims whose dev_clone handler completed before the close acquired SD_F_BUSY sleep inPCM_WAIT(dsp.c:475) with their entries in the pending-INVOKE state β the harness logstrapped=4/4, closeβ2090ms, vopenβ2090ms.- When the staller's
dsp_closefinishes the drain it runssnd_clone_release+snd_clone_unref(dsp.c:806-813); refcount hits 0 andsnd_clone_gc(clone.c:421-437) prunes every victim entry that is !BUSY, INVOKE, and >750 ms old β the victims are ~2 s old β executingdestroy_dev(ce->devt)+kfree(ce)while the openers are still sleeping inside their opens. - The victims then wake and run
snd_clone_acquire/snd_clone_ref(dsp.c:481,688) on the freedsnd_clone_entry(cdev->si_drv2 still points at it): UAF write (ce->flagsRMW) + 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 asstruct dsp_cdevinfo(48 B, same malloc bucket) βce->parentreadscdi->rdchand++c->refcountincrementspcm_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). - Visible, persistent damage (the reproduced DoS): each prune's
destroy_devfires while the victim still holds the node's vnode locked in its VOP_OPEN. The/dev/dsp0.Nnode 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 soundfail withDevice busyforever (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/dsp0opens 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
parentpointer β 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
- sys/dev/sound/clone.c:421-437 (snd_clone_gc prune of INVOKE entries)
- sys/dev/sound/clone.c:381-384 (SND_CLONE_EXPIRED 750ms timing assumption)
- sys/dev/sound/clone.c:46-58 (design comment admitting the assumption)
- sys/dev/sound/clone.c:444-465 (snd_clone_destroy same gap)
- sys/dev/sound/pcm/dsp.c:475-481 (PCM_WAIT before snd_clone_acquire)
- sys/dev/sound/pcm/dsp.c:806-813 (close-side release/unref β gc)
- sys/dev/sound/pcm/channel.c:983-999 (chn_flush β chn_sync sleeping drain)
- sys/vfs/devfs/devfs_core.c:1440-1460 (destroy worker skips release_dev on unlink error β compounding leak)
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
fixedA/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)
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.
No comments yet.