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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3998 Β· 14 files
FileTypeDescriptionSize
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 formula
  • emldmaht_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.txt
  • fix.diff β€” git-apply-able fix against sys/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.

VERDICT.md
↓ download raw

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_trigger PCMTRIG_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 when sc->run[slot] != 1 after sc->run[slot]++ (1714) β€” i.e. for every same-direction channel after the first.
  • envy24ht_r16sl envy24ht.c:1459 src = dst / 2 + ch->offset; then 1465-1466 read dmabuf[src * 2 + slot{,+1}] BEFORE src %= ssize (1470).
  • envy24ht_r32sl 1429 / 1435-1436 / 1440 β€” same.
  • envy24ht_p32sl 1324 / 1330-1331 / 1333, envy24ht_p16sl 1357 / 1366-1367 / 1375, envy24ht_p8u 1399 / 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, and chn_start() refuses to re-START a running channel (sys/dev/sound/pcm/channel.c:697-701 CHN_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 + offset reaches 8183 vs ssize 2048 β†’ first access reads rbuf[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'd ch->data (M_ENVY24HT, ch->size bytes) β€” observed in the harness as data16[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.c runs 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 %= ssize wrap 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-3998

Confirmed kernel references

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.