# 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 sound` panics 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.
