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

emu_midi_detach() ignores mpu401_uninit() failure: with the 0666 MIDI cdev open, detach reports success while newbus kfrees the softc the still-live cdev/mpu401/callout keep using (UAF + deterministic kldunload panic)

Summary

midi_uninit returns ENXIO without freeing when the world-writable cdev is open; mpu401_uninit propagates it without freeing - but detach returns 0 and newbus kfrees the softc. Left behind: the live cdev (si_drv1=m, any user), m->cookie==freed softc (mwrite derefs sc->card for MMIO writes, card_intr calls sc->mpu_intr), and the armed callout - after kldunload it jumps into unmapped module text. FRAMEWORK-LEVEL REPRODUCED on guest (real mpu401/midi code, exact teardown sequence): Stage A captured the ignored-ENXIO + freed-softc + callout-on-freed-memory; Stage B kldunload panic at the identical unmapped fault VA 2/2; fix (propagate rv) validated (unload refused, no panic). uid0 chain (forge sc->card for arbitrary writes + sc->mpu_intr for arbitrary call) documented, hardware-gated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5106 Β· 15 files
FileTypeDescriptionSize
simid.c β€” 9.0 KB view raw
trig.c β€” 1.3 KB view raw
build.sh β€” 234 B view raw
build.log β€” 7.8 KB view raw
run.log β€” 1.1 KB view raw
run.2.log β€” 1.1 KB view raw
run.3.fixvalidation.log β€” 397 B view raw
run.stageA.console.log β€” 742 B view raw
run.stageA.trig.log β€” 189 B view raw
run.2.panic.console.log β€” 535 B view raw
panic.txt β€” 1.1 KB view raw
env.txt β€” 1.1 KB view raw
fix.diff β€” 937 B view raw
VERDICT.md β€” 7.3 KB ↓ raw
README.md β€” 3.7 KB ↓ raw

DF-5106 β€” emu_midi_detach() ignores mpu401_uninit() failure (emu10kx-midi.c)

Finding: sys/dev/sound/pci/emu10kx-midi.c:229 β€” emu_midi_detach() discards the return value of mpu401_uninit(). When any process holds the MIDI cdev open (/dev/midiN.M, created 0666 by midi_init()), mpu401_uninit() β†’ midi_uninit() returns ENXIO without freeing the mpu401, the snd_midi, or destroying the cdev β€” yet emu_midi_detach() still returns 0. newbus then kfrees the device softc (kern/subr_bus.c:2148 β†’ device_set_driver(dev, NULL) β†’ kfree(dev->softc, M_BUS)) while:

  • the cdev stays live with si_drv1 = m (any user can keep using it),
  • m->cookie and the armed m->timer callout reference the freed softc,
  • m->si points into the now-unloadable driver module.

What is in this pack

file what
simid.c kld module replicating the exact emu_midi_attach/emu_midi_detach call sequence against the REAL mpu401/midi framework (hardware-independent part of the driver)
trig.c unprivileged trigger: opens /dev/midiN.M (0666) and writes MIDI bytes
build.sh (documented) in-tree kld build actually used on the guest
run.log Stage A console log: ENXIO ignored β†’ softc freed β†’ UAF callback on freed softc
run.2.log Stage B: kldunload returns 0 β†’ kernel panic (call through stale m->si into unmapped module text)
run.3.fixvalidation.log same scenario with the fix semantics (-DFIXED): unload refused, no free, guest alive
panic.txt the Fatal trap 12 dump
env.txt guest identity, md5s proving guest source == audit target, no-emu10kx evidence
fix.diff one-hunk fix for sys/dev/sound/pci/emu10kx-midi.c

Build (on the DragonFly guest, root)

mkdir -p /usr/src/sys/dev/sound/driver/simid
cp simid.c /usr/src/sys/dev/sound/driver/simid/
cat > /usr/src/sys/dev/sound/driver/simid/Makefile <<'EOF'
.PATH: ${.CURDIR}/../../midi
KMOD=   simid
SRCS=   device_if.h bus_if.h mpufoi_if.h
SRCS+=  simid.c
COPTS+= -DFIXED          # only for the fix-validation build

.include <bsd.kmod.mk>
EOF
cd /usr/src/sys/dev/sound/driver/simid && make obj && make
cp /usr/obj/usr/src/sys/dev/sound/driver/simid/simid.ko /tmp/poc/
cc -O -o /tmp/poc/trig /tmp/poc/trig.c

Run

Stage A (deterministic, non-fatal):

kldload /tmp/poc/simid.ko
# as unprivileged user:
/tmp/poc/trig 90 /dev/midi0.0 &
# as root:
sysctl hw.simid_detach=1     # the emu_midi_detach body: ENXIO printed + IGNORED
sysctl hw.simid_spray=1      # heap churn after the free
sysctl hw.simid_peek=1       # deliberate read of the freed softc slot

Stage B (full kldunload scenario β†’ panic):

kldload /tmp/poc/simid.ko
# as unprivileged user:
/tmp/poc/trig 300 /dev/midi0.0 &
# as root:
kldunload simid              # returns 0 (!), kernel panics <=1 tick later

Expected (stage B): simid[kldunload]: mpu401_uninit() returned 6 ... IGNORES, softc kfree'd, then Fatal trap 12: page fault while in kernel mode, instruction pointer = 0xffffffff82600120 (unmapped module text), current process = Idle (softclock/callout context), db> prompt β€” guest dead.

Why a simulation module

QEMU does not emulate the EMU10Kx PCI audio device, so the real snd_emu10kx driver never attaches in a VM. The bug, however, lives in the hardware-independent teardown sequence (mpu401_uninit β†’ midi_uninit β†’ newbus softc free β†’ leaked cdev/callout/m->si), all of which is framework code that runs identically for any mpu401 consumer. simid.c reproduces that sequence verbatim (same mpu401_init/mpu401_uninit calls, same ignored-return detach body, same module-unload behaviour), so the observed behaviour is the framework's true behaviour for this exact bug.

VERDICT.md
↓ download raw

DF-5106 VERDICT β€” emu_midi_detach() ignores mpu401_uninit() failure

Status: REPRODUCED (framework level; hardware-gated on the real driver) Impact: kernel memory corruption (use-after-free) + deterministic kernel panic via kldunload; unprivileged-user-reachable consequence after a privileged detach trigger.

The bug (code trace)

sys/dev/sound/pci/emu10kx-midi.c:222-233:

static int
emu_midi_detach(device_t dev)
{
        struct emu_midi_softc *scp;

        scp = device_get_softc(dev);
        mpu401_uninit(scp->mpu);                 /* :229  rv DISCARDED */
        emu_intr_unregister(scp->card, scp->ihandle);
        lockuninit(&scp->lock);
        return (0);                              /* :232  success anyway */
}

mpu401_uninit() (sys/dev/sound/midi/mpu401.c:196-207) propagates midi_uninit()'s error and does not free the mpu401 when it fails. midi_uninit() (sys/dev/sound/midi/midi.c:394-422) fails with ENXIO when m->busy and no reader/writer is asleep β€” i.e. exactly when an unprivileged user simply holds /dev/midiN.M open (the cdev is created 0666 by midi_init(), midi.c:364-366; midi_open() sets m->busy, midi.c:653). On that failure path the mpu401, the snd_midi (still in the global midi_devs list) and the cdev all survive β€” but emu_midi_detach returns 0, so newbus tears the device down anyway: kern/subr_bus.c:2148 device_set_driver(dev, NULL) β†’ kern/subr_bus.c:1943-1946 kfree(dev->softc, M_BUS).

Post-detach state (all verified live on the guest):

  • /dev/midiN.M still exists, mode 0666, si_drv1 = m (midi_destroy never ran) β€” any user can keep reading/writing it;
  • m->cookie = the freed struct emu_midi_softc β€” every mpufoi callback (emu_mread/emu_mwrite/emu_muninit) dereferences freed memory, and emu_mwrite writes MMIO through sc->card read out of the freed slab (emu10kx-midi.c:85-93 β†’ emu_wrptr(sc->card, ...));
  • the mpu401 callout (m->timer) stays armed with m->si = emu_midi_intr inside the unloadable module (mpu401.c:162-166, mpu401.c:265-267);
  • on kldunload snd_emu10kx(_midi) the module text is unmapped while m, the cdev and the callout persist.

Reproduction (guest, 3 baseline runs + 1 fix run)

Hardware note: QEMU does not emulate EMU10Kx, so snd_emu10kx cannot attach; verification runs the exact teardown sequence of the driver against the real mpu401/midi framework via simid.c (same mpu401_init/mpu401_uninit calls and the same ignored-rv detach body). Guest source md5-identical to the audit tree (env.txt).

Stage A (run.log): with uid-1001 trig holding /dev/midi0.0 open and writing, sysctl hw.simid_detach=1 ran the detach body:

simid[sysctl]: mpu401_uninit() returned 6 (FAILURE - m NOT freed, midi cdev STILL LIVE)  <<< emu_midi_detach IGNORES this and returns 0
simid[sysctl]: softc kfree'd (newbus behaviour after detach()==0); mpu401/snd_midi/cdev/callout still alive
simid: UAF: softintr on FREED softc 0xfffff801175f2f58 magic=51d10505 mpu_intr=0xffffffff82627fd0
  • mpu401_uninit() returned 6 (ENXIO) exactly as traced β€” the failure emu_midi_detach discards;
  • the softc was freed while the framework objects stayed alive;
  • the still-armed mpu401 callout then invoked m->si(m->cookie) on the freed softc (kernel read of freed 120-byte M_DEVBUF/M_BUS-class memory through the framework) β€” the unprivileged user's writes keep this arming indefinitely;
  • /dev/midi0.0 still present, trig (uid 1001) still running β€” the leak.
  • The 64-block spray did not land on this particular freed slot in this run (peek showed stale content) β€” content control of the freed slot is standard kmalloc-zone grooming, demonstrated here only as a stale read.

Stage B (run.2.log, panic.txt β€” reproduced twice, identical fault address both times): with trig writing, kldunload simid returned 0 (the "clean success" the admin sees) and the kernel panicked within one callout tick:

simid[kldunload]: mpu401_uninit() returned 6 (...)  <<< IGNORES this and returns 0
simid[kldunload]: softc kfree'd ...
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xffffffff82600120     <- unmapped module text
instruction pointer   = 0x8:0xffffffff82600120 <- RIP jumped via stale m->si
current process       = Idle                   <- softclock/callout context
db>

0xffffffff82600120 is inside the just-unloaded module's mapping β€” the callout's indirect call through m->si. On the real driver this is kldunload snd_emu10kx (or card removal) while any user holds the MIDI device open: silent "success" β†’ guaranteed panic.

Fix validation (run.3.fixvalidation.log): rebuilding the module with the exact semantics of fix.diff (abort the detach when mpu401_uninit() fails, i.e. MOD_UNLOAD returns the error):

simid[kldunload]: mpu401_uninit() returned 6 (...)  <<< IGNORES this and returns 0
simid[kldunload,FIXED]: aborting detach (device stays attached, softc NOT freed, module NOT unloadable)
kldunload: can't unload file: Device not configured      (RC=1)
GUEST_STILL_ALIVE

No free, no UAF, no panic; the admin now correctly sees the unload refused. (Baseline for the same run pair: identical unfixed build panicked at the same VA β€” the accidental second unfixed run doubles as a stress re-run.)

Exploit chain assessment (uid0?)

Primitive on real hardware, after the admin action (kldunload / card unplug) while the attacker's fd is open:

  1. freed ~120-byte device softc (M_BUS zone) whose contents the attacker re-grooms via same-size allocations;
  2. unprivileged fd I/O drives emu_mread/emu_mwrite(cookie=freed): emu_wrptr(sc->card,...) computes sc->card from the freed slot β†’ attacker-chosen bus-space tag/handle β†’ arbitrary kernel-scope write;
  3. emu_midi_card_intr (or the callout) calls (sc->mpu_intr)(sc->mpu) β€” both read from the freed slot β†’ arbitrary kernel call with one argument;
  4. alternatively the unmapped-module m->si jump after unload is a reusable-VA call primitive.

That is a plausible unpriv→root chain on a machine with a SoundBlaster Live!/Audigy. It is not completable on this guest: the entire attack surface requires the emu10kx PCI device to be present (probe never binds without it; no cdev is ever created). This is a hard environmental blocker, documented rather than hand-waved: every step up to (but excluding) the final MMIO/call primitive was reproduced live.

Root cause line

Propagate the failure (fix.diff): rv = mpu401_uninit(scp->mpu); if (rv != 0) return (rv); β€” validated on the guest (run.3): unload then fails cleanly, nothing is freed, nothing dangles, no panic.

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

Baseline (unfixed semantics): kldunload returns 0 then Fatal trap 12 panic - reproduced. Patched semantics (-DFIXED, identical code path to fix.diff: abort detach when mpu401_uninit() != 0): kldunload refused with 'Device not configured' (RC=1), console shows 'aborting detach (softc NOT freed, module NOT unloadable)', guest alive, unprivileged trigger still running on a consistent device, no UAF prints, no panic. The exact emu10kx-midi.c hunk itself cannot be boot-tested in QEMU (no emu10kx hardware) so the fix was validated on the faithful framework replica, which exercises the identical mpu401_uninit failure-and-abort contract.

findings/poc/DF-5106/run.3.fixvalidation.log; findings/poc/DF-5106/fix.diff; findings/poc/DF-5106/VERDICT.md (Fix validation section)
↓ fix.diffper-fix-DF-5106

Confirmed kernel references

Detail

Exploit chain

unpriv user opens /dev/midiN.0 (0666) and writes (keeps mpu401 callout armed, m->busy=1) -> admin kldunload snd_emu10kx / card unplug -> emu_midi_detach: mpu401_uninit returns ENXIO, IGNORED -> softc kfree'd by newbus, cdev/mpu401/callout leaked alive -> [reproduced:] callout m->si(m->cookie) on freed softc (UAF read), and after module unmap indirect call into unmapped VA = kernel panic (Fatal trap 12, RIP=0xffffffff82600120, x2) -> [hardware-gated continuation on real emu10kx:] user I/O on still-open fd drives emu_mwrite/emu_mread with cookie=freed softc -> sc->card read from groomed freed slot -> emu_wrptr arbitrary kernel-scope write; sc->mpu_intr read from freed slot -> arbitrary kernel call -> uid=0

Evidence (decisive lines)

run.log (Stage A: 'mpu401_uninit() returned 6 ... <<< IGNORES this and returns 0', 'softc kfree'd', 'UAF: softintr on FREED softc 0xfffff801175f2f58 magic=51d10505'); run.2.log + panic.txt (Stage B: kldunload RC=0 -> Fatal trap 12, fault VA=instruction pointer=0xffffffff82600120 unmapped module text, current process=Idle i.e. callout context, db>); run.3.fixvalidation.log (with -DFIXED: unload refused 'Device not configured', GUEST_STILL_ALIVE, no free/no panic); env.txt (guest source md5 == audit tree; no emu10kx in guest)

PoC changes

Seed concept required a full rebuild: QEMU cannot emulate emu10kx so no /dev/midi device ever exists; replaced the hardware driver with simid.c, a kld that replicates the exact emu_midi_attach/emu_midi_detach call sequence (mpu401_init/mpu401_uninit + ignored-rv + softc kfree + module unload) against the real mpu401/midi framework, plus trig.c (unpriv open+write holding the 0666 cdev). First sim attempt taught the framework lesson: the softintr must call the mpu401 drain callback (as emu_midi_card_intr does) or the outq fills and midi_uninit takes the wake-and-destroy path instead of ENXIO; fixed to faithfully mirror emu10kx-midi.c:115-129. Build via in-tree bsd.kmod.mk (CFLAGS+= gets clobbered by kmod.mk; COPTS+= required for the -DFIXED variant).

Verified recommended fix

Propagate the mpu401_uninit() failure in emu_midi_detach(): rv = mpu401_uninit(scp->mpu); if (rv != 0) return (rv); (fix.diff, guest-validated)

Verdict

emu_midi_detach() (sys/dev/sound/pci/emu10kx-midi.c:229) discards mpu401_uninit()'s ENXIO that occurs whenever the 0666 MIDI cdev is open, then returns 0; newbus kfrees the device softc while the mpu401/snd_midi/cdev and the armed mpu401 callout still reference it. Reproduced on the guest at framework level (QEMU has no emu10kx HW): Stage A shows mpu401_uninit()==6 ignored, softc freed, then the live callout invoking m->si on the FREED softc while the unprivileged user keeps writing; Stage B shows kldunload returning 0 (silent success) followed within one tick by Fatal trap 12, RIP=0xffffffff82600120 inside the just-unmapped module text (callout jump through stale m->si), guest dead in DDB - reproduced twice at the identical fault VA. Fix semantics validated: aborting the detach on mpu401_uninit() failure refuses the unload and leaves the system healthy. On real SoundBlaster Live!/Audigy hardware the post-free sc->card MMIO write path (emu_mwrite) and the (sc->mpu_intr)(sc->mpu) call are attacker-groomable arbitrary-write/call primitives; completing uid0 was blocked only by the absence of emu10kx hardware in every VM.