Unclamped phase offset in the envy24ht DMA emulators: second record channel START reads up to ~32 KB past the shared record DMA buffer into the user's /dev/dsp stream (kernel heap disclosure); play-direction twin carries an attacker-controlled 32-bit write ~130 KB past the play DMA buffer but is latent behind the 1-play-channel registration (DF-3996 twin)
Summary
Every envy24ht emldma function computes dst/src = x/2 + ch->offset and performs the FIRST loop access before the %= wrap; offset comes from the second-same-direction-channel START branch, identical formula to DF-3996. The driver registers 1 play + >=3 rec channels, so the REACHABLE path is record: second rec channel START computes offset up to 4088 (attacker-chosen hwptr via GETIPTR + blocksize via SETFRAGMENT), user-controlled freeptr makes the first EMLDMARD read up to 32,672 bytes PAST the 32,768-byte rbuf into the user's record stream; the data-side pair access also writes one element past ch->data. Play-direction twin (controlled writes +130KB past pbuf) present but dormant as shipped (needs 2 play channels). Harness-proven on guest (soft+hard guard-page modes, deterministic); fix-validated 0/0/no-fault. Live trigger hardware-gated (no ICE1724 in guest).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3998 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.8 KB | β raw | |
| VERDICT.md | β | 6.5 KB | β raw | |
| emldmaht_harness.c | β | 16.1 KB | view raw | |
| emldmaht_harness_fixed.c | β | 16.4 KB | view raw | |
| build.sh | β | 111 B | view raw | |
| run.sh | β | 132 B | view raw | |
| build.log | β | 738 B | view raw | |
| run.log | β | 999 B | view raw | |
| run.2.log | β | 999 B | view raw | |
| run.hard.log | β | 213 B | view raw | |
| fix_run.log | β | 1.5 KB | view raw | |
| env.txt | β | 218 B | view raw | |
| fix.diff | β | 1.9 KB | view raw | |
| verdict.json | β | 5.1 KB | view raw |
DF-3998 β Unclamped phase offset in the envy24ht DMA emulators (DF-3996 twin)
File: sys/dev/sound/pci/envy24ht.c (pass 2)
Class: kernel memory disclosure (record direction, unprivileged) + latent
kernel heap OOB write (play direction) + 1-element OOB write past ch->data.
Reproduce
scp -F dfbsd-qemu/config emldmaht_harness.c dfbsd-maxx:/tmp/ vm.sh run_user 'cc -O2 -Wall -o /tmp/emldmaht_harness /tmp/emldmaht_harness.c && /tmp/emldmaht_harness' vm.sh run_user '/tmp/emldmaht_harness hard' # guard-page proof # fixed variant: vm.sh run_user 'cc -O2 -Wall -o /tmp/emldmaht_fixed /tmp/emldmaht_harness_fixed.c && /tmp/emldmaht_fixed && /tmp/emldmaht_fixed hard'
Expected (baseline)
[C] 2nd rec ch (num=2, slot=-6) START: ptr=4087 blk=8 -> offset=4088 (ssize=2048)
[C] user stream received 12 samples from BEFORE rbuf, 1 from PAST rbuf end
first leaked: data[17] = 0xbe06 (low half of kernel-heap ...)
data-side OOB write: data16[8192] = 0xfee9 (one element past ch->data)
[D] play twin p16sl (LATENT, 2nd play ch): offset=4064 -> 2 sample32 entries
written past pbuf end, first at +130024 bytes (value 0xb9b90000)
HARD: SIGSEGV at first OOB read past rbuf end (offset=4088) - arithmetic proven
Fixed variant: all counts 0, no fault (fix_run.log).
Files
emldmaht_harness.cβ verbatim driver code + trigger-offset formulaemldmaht_harness_fixed.cβ same code with fix.diff applied (+DF-3999 slot clamp)build.log/run.log/run.2.log/run.hard.log/fix_run.log/env.txtfix.diffβ git-apply-able fix againstsys/dev/sound/pci/envy24ht.c
Live /dev/dsp trigger is hardware-gated: the QEMU guest has no ICE1724
(1412:1724) device (env.txt: envy24-pci-devs=0). The harness runs the exact
kernel constants and verbatim code paths; the arithmetic is deterministic.
DF-3998 β VERDICT
Finding: envy24ht(4) carries the identical DF-3996 emldma defect: every
DMA-emulator function forms its ring index as dst = src/2 + ch->offset
(play) / src = dst/2 + ch->offset (rec) and performs the FIRST loop access
BEFORE the %= dsize/%= ssize wrap, so whenever src/2 + offset >= dsize
(rec: >= ssize) the access lands past the shared DMA buffer. The phase
offset comes from envy24htchan_trigger's second-same-direction-channel
START branch (envy24ht.c:1720-1726), same formula as envy24.c:1810.
Status: reproduced (verbatim-code harness on the guest; live /dev/dsp trigger hardware-gated β no ICE1724 PCI device in QEMU). Confidence: certain. Impact class on real hardware: kernel heap disclosure (record direction) β the record direction is directly reachable unprivileged; the play-direction OOB write is present in the code but gated behind a second PLAY channel, which the driver does not register (see below), so on the driver as shipped it is latent.
Root cause (path:line)
envy24htchan_triggerPCMTRIG_START else-branch,sys/dev/sound/pci/envy24ht.c:1721-1723:ch->offset = ((ptr / ch->blk + 1) * ch->blk % (ch->size / 4)) * 4 / ch->unit;executed whensc->run[slot] != 1aftersc->run[slot]++(1714) β i.e. for every same-direction channel after the first.envy24ht_r16slenvy24ht.c:1459src = dst / 2 + ch->offset;then 1465-1466 readdmabuf[src * 2 + slot{,+1}]BEFOREsrc %= ssize(1470).envy24ht_r32sl1429 / 1435-1436 / 1440 β same.envy24ht_p32sl1324 / 1330-1331 / 1333,envy24ht_p16sl1357 / 1366-1367 / 1375,envy24ht_p8u1399 / 1405-1406 / 1408 β play twins.- Buffers:
sc->rbuf= ENVY24HT_REC_BUFUNIT(8)*4096 = 32768 B = 8192 sample32_t entries,sc->pbuf= 131072 B = 32768 entries (envy24ht.c:2160-2161, allocated 2171-2186 via bus_dmamem_alloc).
Reachability analysis (what is and is NOT reachable as shipped)
- The driver registers 1 play + (2+adcn) rec channels
(envy24ht.c:2494-2505; adcn = ((scfg & 0x0c)>>2)+1, standard boards
scfg=0x0b β adcn=3 β 5 rec channels). The offset branch requires
sc->run[slot] != 1β for PLAY that needs 2 running play channels; only 1 exists, andchn_start()refuses to re-START a running channel (sys/dev/sound/pcm/channel.c:697-701CHN_STARTED(c)guard) β the play-direction OOB write (p8u/p16sl/p32sl) is unreachable on the driver as currently registered, i.e. LATENT β it goes live the moment the commented-out 5-play-channel registration (envy24ht.c:2493,2498) is restored, which the code comments show was the design. - The record direction has 3-6 channels: the SECOND rec channel's
PCMTRIG_START runs the offset branch unconditionally. r16sl with
S16_LE gives ssize = ch->size/8 = 2048 while the offset formula scales
with ch->size/4 β attacker-chosen ptr (observable via SNDCTL_DSP_GETIPTR
on the running first rec channel) + user blocksize (SNDCTL_DSP_SETFRAGMENT)
yields offset up to ~4088, and freeptr (how many bytes the user has
consumed) sets dst/2 β
src = dst/2 + offsetreaches 8183 vs ssize 2048 β first access readsrbuf[16360..16361], 32672 bytes PAST the 32 KB record DMA buffer, into the user's record stream (read()on /dev/dsp). One OOB read pair per EMLDMARD/START; leaks the low 16 bits (S16_LE) or the full 32 bits (S32_LE) of each adjacent kernel word. - Data-side edge: with freeptr at maximum,
data[dst+1]writes one element (2-4 bytes) past the kmalloc'dch->data(M_ENVY24HT, ch->size bytes) β observed in the harness asdata16[8192]write.
Reproduction
- Guest: DragonFly 6.5-DEVELOPMENT (env.txt), no 1412:1724 PCI device
(
envy24-pci-devs=0) β live trigger impossible on this VM. emldmaht_harness.cruns the five emldma functions, the trigger offset formula and gethwptr bounds VERBATIM against canary arenas sized exactly like pbuf/rbuf. Results (run.log, deterministic across run.2.log):- [C] second rec channel (num=2), ptr=4087, blk=8 (user blocksize 16),
freeptr max β offset 4088 β 1 sample pair read from +32672 B past
rbuf end into the user stream (0xFEE9 = low half of the kernel-heap
marker 0xDEADFEE9); 12 additional before-rbuf samples from the periodic
src %= ssizewrap landing on src<3 with slot β6 (that part is the DF-3999 slot defect, amplified by the offset); data-side 1-element write past ch->data with the leaked kernel value. - [D] play twin: offset 4064 β first write at +130024 B past the 128 KB pbuf with the user's PCM payload 0xB9B90000 (S16_LE) β the write primitive, latent as described above.
- hard mode (run.hard.log): rbuf sized exactly against a PROT_NONE guard page β SIGSEGV on the FIRST OOB read β the arithmetic is exactly what the kernel would run against its bus_dmamem allocation.
- Fixed variant (fix.diff semantics applied): 0 leaked samples, 0 OOB writes, no fault (fix_run.log).
Exploit chain (real hardware)
open /dev/dsp0.r0 (first rec channel), start recording β open .r1,
SETFMT S16_LE/S32_LE, SETFRAGMENT for the chosen blk, consume bytes to set
freeptr, observe r0 hwptr via SNDCTL_DSP_GETIPTR, trigger r1 START β
ch->offset computed (up to 4088/2047 by format) β each EMLDMARD leaks a
32-bit kernel word from up to ~32 KB past the 32 KB record DMA buffer into
the user's record stream; retrigger/re-open varies the offset β repeatable
kernel heap disclosure from an unprivileged user (0666 /dev/dsp clones).
The complementary play-direction primitive (attacker-controlled 32-bit
writes up to ~130 KB past the 128 KB play DMA buffer with S32_LE) is
currently dormant β it activates for any build/patch that restores the
designed 5-play-channel registration, at which point this becomes the
DF-3996 privesc-grade chain verbatim. Fix both now.
Fix
fix.diff β wrap the phase-adjusted index before the first access in all
five emldma functions (play: dst = (src/2 + ch->offset) % (ch->size/4|8);
rec: src = (dst/2 + ch->offset) % (ch->size/8)), plus even-alignment of
the data-side index so the (src, src+1)/(dst, dst+1) pair can never
straddle the end of ch->data. Validated at harness level: baseline
leaks + hard-mode fault vs 0/0/no-fault after the fix.
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 semantics applied to the verbatim harness functions: baseline 1 far-OOB read pair (+32672B) + 12 before-rbuf reads + data-side 1-element write + hard-mode guard fault; patched run reports 0 leaks, 0 OOB writes, no fault (fix_run.log). No kernel build performed (no envy24ht hardware to attach the driver on the guest); the diff is pure arithmetic wrapping, compile-trivial.
findings/poc/DF-3998/fix_run.log; findings/poc/DF-3998/emldmaht_harness_fixed.c; findings/poc/DF-3998/fix.diff
Confirmed kernel references
- sys/dev/sound/pci/envy24ht.c:1721
- sys/dev/sound/pci/envy24ht.c:1714
- sys/dev/sound/pci/envy24ht.c:1324
- sys/dev/sound/pci/envy24ht.c:1330
- sys/dev/sound/pci/envy24ht.c:1357
- sys/dev/sound/pci/envy24ht.c:1366
- sys/dev/sound/pci/envy24ht.c:1399
- sys/dev/sound/pci/envy24ht.c:1405
- sys/dev/sound/pci/envy24ht.c:1429
- sys/dev/sound/pci/envy24ht.c:1435
- sys/dev/sound/pci/envy24ht.c:1459
- sys/dev/sound/pci/envy24ht.c:1465
- sys/dev/sound/pci/envy24ht.c:2160
- sys/dev/sound/pci/envy24ht.c:2494
Detail
Exploit chain
open /dev/dsp0.r0 (rec, 0666 clone) and start it -> open .r1, SETFMT S16_LE/S32_LE, SETFRAGMENT (blocksize 16 -> blk=8 dwords), read() enough to place freeptr, observe r0 hwptr via SNDCTL_DSP_GETIPTR -> trigger r1 START -> ch->offset up to 4088 (S16) while ssize=2048 -> every EMLDMARD leaks a 32-bit kernel word from up to ~32KB past the 32KB bus_dmamem rbuf into the user's record stream; retrigger/reopen varies the offset -> repeatable unprivileged kernel heap disclosure. Live chain not executed: guest lacks the PCI device (hardware-gated). The complementary controlled-write primitive (play direction) is dormant on the shipped registration.
Evidence (decisive lines)
run.log ([C] offset=4088 -> 1 sample pair from +32672B past rbuf end, value 0xfee9 = low half of marker 0xDEADFEE9; data-side data16[8192] write; [D] play twin +130024B write 0xb9b90000), run.2.log (deterministic), run.hard.log (SIGSEGV at first OOB read), fix_run.log (patched: all zeros, no fault), VERDICT.md full trace
PoC changes
Pass-2 reviewer authored the harness from scratch on the DF-3996 template: five emldma functions + trigger offset formula + gethwptr bounds verbatim from envy24ht.c with envy24ht constants (PLAY_CHNUM 8, REC_CHNUM 2, buffers 131072/32768 B); fixed-variant regenerated twice (arena aliasing bug in harness v1 scaffolding fixed by separating pbuf/rbuf canary arenas; even-alignment clamps added to kill the data-side pair straddle).
Verified recommended fix
Wrap the phase-adjusted index before the first loop access in all five emldma functions and even-align the data-side pair index - see fix.diff
Verdict
Reproduced via verbatim-code harness on the guest (no ICE1724/Envy24HT PCI device in QEMU, so live /dev/dsp trigger is hardware-gated): with the exact kernel constants, the second RECORD channel's PCMTRIG_START offset (envy24ht.c:1721-1723, up to 4088 for S16_LE with attacker-chosen hw-ptr/blocksize) plus user-controlled freeptr makes the first r16sl loop iteration read sample32_t entries up to 32672 bytes PAST the 32768-byte record DMA buffer into the user's record stream (kernel heap disclosure; full 32-bit words with S32_LE, low 16 bits with S16_LE), plus a 1-element (2-4 byte) OOB write past the kmalloc'd ch->data when freeptr is maximal. The play-direction twin (p8u/p16sl/p32sl writes attacker-controlled PCM up to 130024 bytes past the 131072-byte play DMA buffer) is present in the code but gated behind a second PLAY channel, which the driver does not register (envy24ht.c:2494-2505 registers exactly 1 play channel and chn_start refuses double-START) - latent, activates if the commented-out 5-play-channel registration is restored. Arithmetic deterministic; guard-page hard mode faults at the first OOB read. Fix validated at harness level: wrapping the phase-adjusted index before the first access (+ even alignment of the data-side pair index) eliminates all leaks, the data-side write and the fault.
No comments yet.