/dev/dsp mmap vs chn_resizebuf use-after-free: dsp_mmap_single maps bufsoft inside chn_resizebuf's lock-drop windows and the resize then kfrees the mapped buffer β persistent userland RW over freed M_DEVBUF kernel heap
Summary
chn_resizebuf validates CHN_F_MMAP only at entry and then drops the channel lock three times (parent-notify, SETBLOCKSIZE, sndbuf_remalloc kmalloc window) before freeing the old bufsoft storage. dsp_mmap_single maps bs->buf under the same channel lock - so a mapper running inside one of those windows validates and maps the OLD buffer that the resize kfree()s microseconds later; the device-pager mapping survives the free, leaving a persistent RW alias over freed kernel heap. LIVE-VERIFIED on the audio-enabled guest as unprivileged: hit within round 0-73 every session; alias proof (stale-mapping 0xDF write appears in victim's live kernel buffer); mutation proof (31/32 stale pages rewritten by kernel after free). Fix validated on patched sound.ko: 3000 rounds zero hits vs stock <=73; legit mmap/fragment behavior preserved. uid0 route: groom the freed slab block with a privileged object in the same size class (target selection open). Distinct sink from DF-1337 (uiomove) and DF-3879 (offset validation).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4026 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.0 KB | β raw | |
| VERDICT.md | β | 5.6 KB | β raw | |
| mmap_resize_uaf.c | β | 8.4 KB | view raw | |
| build.sh | β | 123 B | view raw | |
| build.log | β | 544 B | view raw | |
| run.log | β | 719 B | view raw | |
| run.2.log | β | 795 B | view raw | |
| run.3.log | β | 512 B | view raw | |
| run.fix.log | β | 562 B | view raw | |
| fix.diff | β | 2.0 KB | view raw | |
| env.txt | β | 233 B | view raw | |
| panic.sndstat-unload.txt | β | 1.1 KB | view raw | |
| manifest.json | β | 1.1 KB | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-4026 β /dev/dsp mmap vs chn_resizebuf use-after-free (userland RW over freed kernel heap)
File: sys/dev/sound/pcm/channel.c (+ sys/dev/sound/pcm/buffer.c sink window) β pass 2 (GLM 5.3), live-verified 2026-09-17 on the audio-enabled audit guest (QEMU intel-hda + hda-duplex, snd_hda.ko, /dev/dsp 0666).
THE BUG chn_resizebuf() (channel.c:1753) validates CHN_F_MMAP only once, at entry (channel.c:1762), and then releases the channel lock three times before the resize completes:
1. parent-notify window channel.c:1815-1819
(CHN_UNLOCK(c); CHN_LOCK(c->parentchannel); chn_notify(...); ...; CHN_LOCK(c))
2. SETBLOCKSIZE window channel.c:1858-1864
(CHN_UNLOCK(c); CHANNEL_SETFRAGMENTS/CHANNEL_SETBLOCKSIZE; CHN_LOCK(c))
3. sndbuf_remalloc kmalloc window buffer.c:224-228
(CHN_UNLOCK(b->channel); 3x kmalloc(M_WAITOK); CHN_LOCK(b->channel))
and finally frees the old bufsoft storage:
sndbuf_remalloc() -> kfree(b->buf); kfree(b->tmpbuf); kfree(b->shadbuf)
(buffer.c:229-237, called from channel.c:1910).
dsp_mmap_single() (dsp.c:2297-2364) maps bs->buf into the user's
page tables while holding the same channel lock (getchns, dsp.c:210-
213). Therefore the *only* way a mapper can run against a channel
that is mid-resize is inside one of the three windows above β and
the buffer it maps is exactly the one kfree()d a few microseconds
later. The resulting mapping is a userland RW alias over freed
M_DEVBUF kernel heap.
This is the same resizebuf lifecycle that DF-1337 abuses from the
write()/read() side; the mmap sink is new (DF-3879 is a different,
offset-validation bug in the same function).
THREAT Unprivileged local user (no group membership needed; /dev/dsp* is 0666). The mapping survives the free indefinitely (device-pager mapping; nothing unmaps it), giving a persistent window over recycled kernel memory.
BUILD (on the guest) cc -O2 -Wall -pthread -o /tmp/mmap_resize_uaf /tmp/mmap_resize_uaf.c
RUN (unprivileged; guest booted with audio, snd_hda loaded) /tmp/mmap_resize_uaf 3000
EXPECTED β stock kernel/sound.ko * A "HIT" within seconds (observed round 0-73 across sessions): the shrinking SNDCTL_DSP_SETFRAGMENT returned 0 while the racer thread holds a 128K mapping, and the channel's live buffer is now 256 bytes => the mapping covers the freed 128K block. * Then one of the proofs: - alias proof: a fresh dsp channel's live kernel buffer aliases the stale mapping (0x5A fill visible both ways; 0xDF written via the stale mapping appears in the victim's live buffer) β run.3.log, - mutation proof: 31/32 stale pages rewritten by the kernel after the free (read of recycled kernel heap) β run.log. Result line: "RESULT: UAF READ+WRITE over freed M_DEVBUF kernel heap CONFIRMED" (alias) or "... UAF read ... CONFIRMED (mutation)".
EXPECTED β kernel with fix.diff applied (patched sound.ko) "RESULT: no window caught in 3000 attempts" (EXIT=1) β see run.fix.log; legit mmap of the dsp buffer still works (feature preserved).
FILES mmap_resize_uaf.c PoC (race + proofs) build.sh / build.log run.log stock-module baseline run (mutation proof) run.2.log earlier stock runs (2 more HITs) run.3.log stock-module alias+write proof run (verbatim) run.fix.log patched-module validation run + sanity fix.diff git-applyable fix (channel.c + buffer.c) env.txt guest uname, compiler, module md5s panic.sndstat-unload.txt unrelated side observation: kldunload sound panics in sndstat_sysuninit (sbuf corruption); root-only module-unload path, NOT part of this finding. manifest.json / verdict.json / VERDICT.md
DF-4026 VERDICT β mmap-vs-chn_resizebuf use-after-free
Status: REPRODUCED (alias read+write proof and mutation read proof,
stock INVARIANTS X86_64_GENERIC kernel + stock sound.ko, audio-enabled
QEMU guest, unprivileged user maxx (uid 1001), 2026-09-17).
Fix: VALIDATED β patched sound.ko (fix.diff) closes the window;
identical PoC cannot catch it in 3000 rounds; legit dsp mmap preserved.
Root cause (line-verified)
chn_resizebuf() checks CHN_F_MMAP exactly once, at entry
(channel.c:1762). It then executes three lock-drop windows before the
resize takes effect (parent-notify at channel.c:1815-1819, driver
SETBLOCKSIZE at channel.c:1858-1864, and sndbuf_remalloc()'s kmalloc
window at buffer.c:224-228) and only afterwards frees the old
buf/tmp/shadbuf trio (buffer.c:229-237, invoked from channel.c:1910).
dsp_mmap_single() (dsp.c:2297) acquires the same channel lock via
getchns() (dsp.c:210-213) before validating
(*offset + size) > sndbuf_getsize(c->bufsoft) (dsp.c:2342) and mapping
bs->buf (dsp.c:2355-2357). Because a mapper needs the lock, it can
only slip in while the resizer holds no lock β i.e. inside one of the
windows β and at that moment bufsize is still the pre-resize value,
so the mapper validates and maps the OLD buffer which the resizer
kfree()s moments later. The device-pager mapping is never torn down
by the free: the user keeps a RW alias over freed M_DEVBUF heap.
Relation to knowns: DF-1337 is the write()/read() uiomove sink of the same resizebuf lifecycle; DF-3879 is an offset-validation bug in the same dsp_mmap_single(). This finding is the mmap lifetime sink and was not previously reported.
Reproduction (see README.md for exact commands)
Round protocol: fresh /dev/dsp clone grown to 128K
(SNDCTL_DSP_SETFRAGMENT 2x64K), racer thread spinning
mmap(128K, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0), main thread
issuing the shrinking SETFRAGMENT (2x16B -> 256B, which takes
sndbuf_remalloc's realloc branch since 256 < 131072>>5). Discriminator:
shrink ioctl returned 0 and the racer holds a 128K mapping and
the channel's live size is 256B β the mapping must cover freed memory.
- Stock module: HIT observed within round 0β73 in every session (run.log round 2; run.2.log rounds 9 and 2; run.3.log round 0).
- Alias proof (run.3.log): after the HIT, fresh dsp channels grown to 128K reclaim the freed block; spray channel 0's live kernel buffer aliases the stale mapping. 0x5A written via the victim's own legit mmap is read through the stale mapping (read-after-free), and 0xDF written through the stale mapping appears in the victim's live kernel buffer at 0x1000 (write-after-free into a live kernel object).
- Mutation proof (run.log): 31/32 stale pages were rewritten by kernel activity after the free (0x80 silence -> post-free content), demonstrating read access to recycled kernel heap.
- No panic is required or expected: the corruption is silent (the INVARIANTS kernel has no assertion covering this).
Exploit chain (status)
Primitive proven: persistent userland RW over a 128K freed kmem-slab block (DF ZoneLimit=32K, so 128K kmallocs are page-granular kmem slabs whose pages return to the VM free pool). Demonstrated: controlled content write into a live kernel object that reclaims the block (the victim channel's bufsoft). Not completed this run: targeting a privileged victim object (e.g. a structure containing function pointers or credentials in the same size class) to convert the alias into uid=0. The remaining step is target grooming within the 128K class, not a new vulnerability class.
Fix validation
fix.diff adds two guards: (1) chn_resizebuf() re-checks CHN_F_MMAP after the last relock, immediately before sndbuf_remalloc(); (2) sndbuf_remalloc() re-checks CHN_F_MMAP after its kmalloc-window relock and discards the new allocations instead of freeing the old (possibly mapped) buffers.
| test | stock sound.ko | patched sound.ko (md5 a517bbeβ¦) |
|---|---|---|
| PoC 3000 rounds | HIT β€73; read+write CONFIRMED | no window in 3000 rounds, EXIT=1 |
| legit mmap(off=0, 128K) of dsp | works | works (probe, run.fix.log) |
| SETFRAGMENT shrink/grow | works | works |
Patched module built from /usr/src/sys/dev/sound/sound with the exact fix.diff in this pack; stock restored afterwards (md5 e869fcdeβ¦).
Honest notes
- The alias proof is allocator-dependent (kmem-slab pages return to the VM pool; which spray channel reclaims the block varies). The race itself (HIT) is highly reliable β every session hit within 73 rounds, usually round 0β2.
- Early PoC iterations learned: write() before the race poisons the channel with CHN_F_RUNNING (blocks mmap with EINVAL β dsp.c:2341, CHN_F_MMAP_INVALID); the vchan parent limit inflates deep shrinks to β₯256B (sufficient to trigger the realloc branch from a 128K state, insufficient from 8K); 8K working sets never cross the allocsize>>5 threshold, so no free occurs at that size.
- Side observation (not this finding):
kldunload soundpanics in sndstat_sysuninit (sbuf) β panic.sndstat-unload.txt; root-only.
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
fixedfix.diff (channel.c recheck of CHN_F_MMAP before sndbuf_remalloc + buffer.c post-relock abort inside sndbuf_remalloc) built into sound.ko: identical PoC caught the window within 73 rounds on stock and never in 3000 rounds on the patched module; legit dsp mmap and fragment resizing preserved
run.fix.log (patched: RESULT no window caught in 3000 attempts, EXIT=1; feature sanity OK); patched module md5 a517bbe17032d9ba88046ab759e578ca vs stock e869fcdebe0137ee26a9de85429c0baf; fix.diff in this pack
Confirmed kernel references
Detail
Exploit chain
open /dev/dsp (fresh vchan clone, 0666) -> SETFRAGMENT 2x64K (bufsoft=128K) -> race mmap(128K) vs shrinking SETFRAGMENT 2x16B: the shrink validates CHN_F_MMAP at chn_resizebuf entry, drops the lock (SETBLOCKSIZE + remalloc kmalloc windows), the mapper takes the lock mid-window and maps the OLD buffer, the resize completes and kfrees that buffer -> stale RW mapping over freed heap -> spray fresh dsp channels grown to 128K to reclaim the block -> read/write the reclaiming victim's live kernel buffer through the stale alias (proven). Escalation to uid=0 would require reclaiming a privileged 128K-class kernel object (not completed).
Evidence (decisive lines)
run.3.log (alias proof: HIT round 0, READ-after-free alias, WRITE-after-free 0xDF in victim live buffer, CONFIRMED, EXIT=0); run.log (stock baseline round 2 HIT + mutation proof 31/32 pages); run.fix.log (patched sound.ko: no window in 3000 rounds + legit mmap preserved); VERDICT.md for the full matrix and line refs
PoC changes
Seed had no runnable PoC. Learned/adjusted during bring-up: fd must be O_RDWR for PROT_READ|PROT_WRITE mmap (EACCES otherwise); no write()/SETTRIGGER before the race (CHN_F_RUNNING poisons CHN_F_MMAP_INVALID -> mmap EINVAL); shrink target must be < allocsize>>5 (256 works from a 128K buffer; the vchan parent limit floors deep shrinks at 256B; 8K working sets never free); reclaim via fresh-channel 128K grow (not shrink/regrow - pool channels with allocsize==128K take the NOCHANGE branch); pre-fault the stale mapping before spraying.
Verified recommended fix
Re-check CHN_F_MMAP after every relock in chn_resizebuf (before sndbuf_remalloc) and abort sndbuf_remalloc's swap if the channel got mapped during its kmalloc window.
Verdict
Unprivileged local user (0666 /dev/dsp) obtains a persistent RW page mapping over freed M_DEVBUF kernel heap: dsp_mmap_single() can only acquire the channel lock while chn_resizebuf() is inside one of its three lock-drop windows, and exactly then it maps the old bufsoft buffer that the resize kfree()s microseconds later. Reproduced live: shrink-ioctl-returns-0 + 128K mapping held + live buffer 256B in every session (round 0-73); controlled write into the live kernel buffer that reclaimed the freed block demonstrated via the alias proof (0xDF marker), and post-free kernel rewriting of 31/32 stale pages demonstrated via the mutation proof. No panic is needed for the corruption (silent). uid=0 chain not completed this run - the remaining step is privileged-target grooming within the 128K kmem-slab class.
No comments yet.