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

midi(4) kqueue filter detach removes knote from BOTH klists β€” unguarded SLIST_REMOVE walks off the foreign list and dereferences NULL β†’ deterministic kernel panic

Summary

midi_kqfilter inserts each knote into exactly one of rkq/wkq, but the shared midi_filter_detach calls knote_remove on BOTH lists. knote_remove is a raw SLIST_REMOVE with no membership guard: for a knote not in the list it walks to NULL and dereferences curelm->sle_next - page fault. Triggered by ANY knote detach: EV_DELETE, kqueue close, or simply process exit after one EV_ADD. /dev/midiN.M is 0666, so any local user on a host with a MIDI device (SB Live!/Audigy via snd_emu10kx) panics the kernel with a 3-syscall program. tty(4) shows the correct per-list idiom. Model-verified on the guest with a line-verbatim harness (SIGSEGV at 0x0 both modes; fix clean); live trigger impossible only because QEMU has no emu10k1. Per-filter-detach fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4283 Β· 11 files
FileTypeDescriptionSize
midi_kq_model.c β€” 7.0 KB view raw
kq_trigger.c β€” 1.7 KB view raw
build.sh β€” 66 B view raw
run.sh β€” 90 B view raw
build.log β€” 202 B view raw
run.log β€” 1.3 KB view raw
env.txt β€” 373 B view raw
fix.diff β€” 1.5 KB view raw
README.md β€” 3.1 KB ↓ raw
VERDICT.md β€” 5.1 KB ↓ raw
verdict.json β€” 3.9 KB view raw

DF-4283 β€” midi(4) kqueue filter detach removes knote from BOTH klists β†’ NULL-deref panic

What

midi_kqfilter() (sys/dev/sound/midi/midi.c:901-930) inserts each knote into exactly ONE of m->rkq.ki_note (EVFILT_READ) or m->wkq.ki_note (EVFILT_WRITE), but the shared detach callback midi_filter_detach() (midi.c:932-941) removes the knote from both lists. knote_remove() (sys/kern/kern_event.c:1902-1909) is a plain SLIST_REMOVE (sys/sys/queue.h:208-219) with no membership guard: for a knote that is not in the list it walks curelm = SLIST_NEXT(curelm, ...) until curelm becomes NULL and dereferences it β€” page fault on the unmapped low page β†’ kernel panic. (Empty foreign list: immediate NULL deref; foreign list holding a different knote: walk to the end, then NULL deref β€” both modes demonstrated.)

Correct in-kernel idiom for comparison: sys/kern/tty.c:1308-1314 / 1334-1340 use one detach callback per list.

Trigger (unprivileged, when a MIDI device exists)

/dev/midiN.M is created with mode 0666 (midi.c:364-366). Any local user:

fd = open("/dev/midi0.1", O_RDWR);
kq = kqueue();
EV_SET(&kev, fd, EVFILT_READ, EV_ADD, ...); kevent(kq, &kev, 1, ...);
/* any of: EV_DELETE, close(kq), process exit  ->  filter detach -> panic */

Detach fires via knote_detach_and_drop() on EV_DELETE (sys/kern/kern_event.c:1365-1368) and on kqueue teardown (sys/kern/kern_event.c:706-708). It is enough to run kevent(2) once with EV_ADD and exit the process.

Hardware gating (why no live kernel trigger on the audit guest)

/dev/midiN.M exists only when some driver calls midi_init(). The only in-tree caller chain is snd_emu10kx β†’ emu_midi_attach β†’ mpu401_init() (sys/dev/sound/pci/emu10kx-midi.c:201 β†’ sys/dev/sound/midi/mpu401.c:185 β†’ midi_init). QEMU cannot emulate an EMU10K1/EMU10K2 PCI audio card, so the raw-MIDI node cannot appear on the audit guest. On real hardware with a SoundBlaster Live!/Audigy (driver auto-loads), the panic is deterministic.

Reproduce (model harness β€” runs on the guest)

cd findings/poc/DF-4283
./build.sh          # cc -O0 -g -Wall -o midi_kq_model midi_kq_model.c
                    # cc -O0 -g -Wall -o kq_trigger kq_trigger.c
./run.sh

Expected (see run.log): - empty → SIGSEGV at faulting address 0x0, exit 99 (kernel: panic) - peer → SIGSEGV at faulting address 0x0, exit 99 (kernel: panic) - fixed → clean removal, exit 0 (fix.diff semantics) - control→ clean, exit 0 - kq_trigger /dev/midistat → live kevent add/delete mechanics exercised on the real guest syscalls, guest stays alive (midistat has no kqfilter, ENODEV on EV_ADD — proving only the missing MIDI hardware blocks the live trigger).

The harness copies the queue macros and knote_remove() verbatim from sys/sys/queue.h:191-219 and sys/kern/kern_event.c:1902-1909 (LWKT pool token omitted β€” an SMP exclusivity primitive irrelevant to single-threaded list semantics) and midi_filter_detach() verbatim from midi.c:932-941.

Fix

fix.diff β€” split the detach callback per filter (mirrors the tty(4) idiom). midi_kq_model fixed mode models the patched behavior: clean.

VERDICT.md
↓ download raw

VERDICT β€” DF-4283

Status: reproduced (model harness on the audit guest) / kernel panic, local DoS Impact: dos β€” deterministic NULL-pointer-dereference kernel panic Confidence: certain

Root cause (source-certain)

  1. midi_kqfilter() [sys/dev/sound/midi/midi.c:901-930] selects exactly one klist per knote: EVFILT_READ β†’ &m->rkq.ki_note, EVFILT_WRITE β†’ &m->wkq.ki_note, then knote_insert(klist, kn).
  2. The shared detach callback midi_filter_detach() [midi.c:932-941] does: c knote_remove(rklist, kn); knote_remove(wklist, kn); i.e. it also removes the knote from the list it was never in.
  3. knote_remove() [sys/kern/kern_event.c:1902-1909] calls SLIST_REMOVE, which [sys/sys/queue.h:208-219] walks the list to find the element with no membership check: c struct type *curelm = SLIST_FIRST((head)); while (SLIST_NEXT(curelm, field) != (elm)) curelm = SLIST_NEXT(curelm, field); When the element is absent, curelm walks off the end (NULL) and SLIST_NEXT(curelm, ...) dereferences NULL.
  4. Detach runs whenever the knote goes away: EV_DELETE [kern_event.c:1365-1368] and kqueue teardown / process exit [kern_event.c:706-708] both call knote_detach_and_drop() β†’ f_detach(kn).

Net: an EV_ADD followed by any detach of a midi fd knote = page fault on the kernel unmapped low page = panic. tty(4) shows the correct per-list idiom [sys/kern/tty.c:1308-1314, 1334-1340].

Reachability

  • /dev/midiN.M is created mode 0666 [midi.c:364-366] β€” no privileges needed once the node exists.
  • The node exists only when a driver calls midi_init(); the sole in-tree caller chain is snd_emu10kx β†’ mpu401_init() [emu10kx-midi.c:201 β†’ mpu401.c:185 β†’ midi_init], i.e. SoundBlaster Live!/Audigy PCI audio. Verified on the audit guest: kldload sound succeeds, /dev/midistat (0666) appears, no /dev/midi* nodes are possible (no EMU PCI device under QEMU). So the live kernel trigger is hardware-gated on this guest but deterministic on any host with an emu10kx card and the standard module autoload.

Verification performed (this run)

  1. Model harness midi_kq_model (built + run on the guest, gcc 8.3, DragonFly 6.5-DEVELOPMENT): queue macros copied verbatim from queue.h:191-219, knote_remove() verbatim from kern_event.c:1902-1909 (pool-token omitted), midi_filter_detach() verbatim from midi.c:932-941. - mode empty (foreign list empty): SIGSEGV at address 0x0 β€” the immediate SLIST_NEXT(NULL) deref. exit 99. - mode peer (foreign list holds a different knote): SIGSEGV at address 0x0 β€” walk-to-end then NULL deref. exit 99. - mode fixed (fix.diff split-detach semantics): clean, exit 0. - mode control (membership holds): clean β€” proves the macro itself is sound when used correctly. See run.log lines: "[empty] SIGSEGV at faulting address 0x0", "[peer] SIGSEGV at faulting address 0x0".
  2. Live syscall control kq_trigger /dev/midistat on the guest: real kevent(2) EV_ADD/EV_DELETE + kqueue-teardown-with-attached-knote against the only live midi-family node. EV_ADD β†’ ENODEV (midistat has no d_kqfilter), no crash, guest alive β€” the trigger mechanics are real; only the absent MPU hardware (i.e. the midi fd) blocks the live panic.

Why not a live kernel panic

QEMU has no EMU10K1/EMU10K2 model; snd_emu401/ISA sound does not exist in this tree (sys/dev/sound/isa absent); no other driver calls midi_init. The kernel path is therefore unreachable on this guest β€” a QEMU limitation, not a property of the bug (any SB Live!/Audigy host panics on first kqueue use of its midi device by any local user).

Exploit-chain assessment

Primitive is a NULL-dereference read (curelm->sle_next with curelm == NULL); SLIST_REMOVE never writes through the NULL cursor before faulting (SLIST_REMOVE_AFTER(curelm,...) runs only after a successful walk). No controlled write, no info leak β€” impact ceiling is denial of service (panic), but it is instant, repeatable, and unprivileged on affected hardware.

Fix validation

fix.diff splits midi_filter_detach into per-filter callbacks (mirroring tty.c). Model mode fixed re-runs the identical scenario with patched semantics: clean removal, no foreign-list walk, exit 0 (run.log "[fixed] clean: knote removed from its owning list"). A kernel-level patched-kernel build cannot be exercised on this guest for the same hardware reason as above.

Bottom line

Real, deterministic, unprivileged-kernel-panic bug in the midi(4) kqueue path, certain at source level and demonstrated by a line-faithful harness on the audit guest; live kernel triggering requires emu10kx-class audio hardware (not emulatable in QEMU).

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

Validated at harness level: identical scenario with the split-detach semantics (fix.diff) removes the knote cleanly with no foreign-list walk (run.log '[fixed] clean', exit 0) vs baseline NULL-deref (exit 99). A patched-kernel build cannot exercise this path on this guest for the same hardware reason the baseline cannot (no emu10kx -> no /dev/midiN.M); fix is the standard tty(4)-style idiom, low regression risk.

run.log lines: '[fixed] calling fixed detach_read()...' / '[fixed] clean: knote removed from its owning list, no foreign-list walk' / exit=0; baseline: '[empty]/[peer] SIGSEGV at faulting address 0x0' exit=99
↓ fix.diffper-fix-DF-4283

Confirmed kernel references

Detail

Exploit chain

open(/dev/midiN.M) [0666] -> kqueue -> EV_ADD EVFILT_READ -> EV_DELETE/close/exit -> knote_detach_and_drop -> midi_filter_detach -> knote_remove(foreign klist) -> SLIST_REMOVE walks curelm to NULL -> SLIST_NEXT(NULL) deref -> page fault on unmapped low page -> kernel panic (NULL-deref read only; no write primitive; DoS ceiling)

Evidence (decisive lines)

run.log: '[empty] SIGSEGV at faulting address 0x0' and '[peer] SIGSEGV at faulting address 0x0' (exit 99); '[fixed] clean: knote removed from its owning list' (exit 0); kq_trigger /dev/midistat 'TRIGGER-MECHANICS-OK ... (guest still alive)'; env.txt: no /dev/midi* after kldload sound (hw gating proof)

PoC changes

harness written from scratch (no seed): queue macros + knote_remove copied verbatim from queue.h:191-219 / kern_event.c:1902-1909 (LWKT pool token omitted), midi_filter_detach verbatim from midi.c:932-941; iteration 1 missed SLIST_INSERT_HEAD/SLIST_EMPTY defines (compile fix), iteration 2 run.sh aborted on expected nonzero exit (set -e removed)

Verified recommended fix

split midi_filter_detach into per-filter callbacks that knote_remove() only from the owning klist (fix.diff)

Verdict

midi_filter_detach() removes each knote from BOTH m->rkq and m->wkq while midi_kqfilter() inserted it into exactly one; knote_remove()/SLIST_REMOVE has no membership guard, so the foreign-list removal walks off the end and dereferences NULL -> kernel panic on any knote detach (EV_DELETE, kqueue close, process exit) against a 0666 /dev/midiN.M node. Demonstrated by a line-verbatim userland harness on the audit guest (SIGSEGV at address 0x0 in both empty- and peer-foreign-list modes; fixed semantics clean; real kevent add/delete mechanics exercised live against /dev/midistat as control). Live kernel trigger impossible on this guest only because /dev/midiN.M exists solely on snd_emu10kx hardware (sole in-tree midi_init caller chain, QEMU has no emu10k1 model); on affected hosts the panic is deterministic and unprivileged.