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

dsp_oss_syncstart use-after-free: lksleep drops the syncgroup lock mid-walk; timeout wake resumes SLIST_FOREACH over sm/sg freed by a concurrent SYNCSTART

Summary

In the SNDCTL_DSP_SYNCSTART retry loop, a failed CHN_TRYLOCK leads to lksleep which RELEASES the syncgroup lock while asleep. A concurrent successful SYNCSTART of the same sg_id can then complete the full teardown - start every member, SLIST_REMOVE_HEAD + kfree each sm, SLIST_REMOVE + kfree the sg - inside the 5ms window. The plain timeout wake executes and the inner SLIST_FOREACH CONTINUES from the freed sm: SLIST_NEXT reads freed memory and CHN_TRYLOCK locks a channel pointer read from freed memory - kernel UAF (panic or exploitable corruption via heap re-spray). Only the EINTR path exits safely. The trylock failure is attacker-arranged by contending the member channel lock (looping/blocking write(2) on the same fd). Any unprivileged local user on a sound-equipped system races two threads' SYNCSTART against a channel-lock-contenting writer. 5ms window; PoC re-arms 100k times. Distinct from DF-1263 (error-path free without unlink). Break-and-rescan fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3880 Β· 10 files
FileTypeDescriptionSize
README.md β€” 1.6 KB ↓ raw
VERDICT.md β€” 3.4 KB ↓ raw
syncstart_race.c β€” 3.8 KB view raw
build.sh β€” 206 B view raw
run.sh β€” 136 B view raw
build.log β€” 8 B view raw
run.log β€” 130 B view raw
env.txt β€” 692 B view raw
fix.diff β€” 496 B view raw
verdict.json β€” 5.1 KB view raw

DF-3880 β€” use-after-free race in dsp_oss_syncstart() (SNDCTL_DSP_SYNCSTART)

File: sys/dev/sound/pcm/dsp.c:2992-3082 β€” pass 2 (GLM 5.3). Distinct from known DF-1263 (syncgroup error-path free without unlink).

BUILD scp syncstart_race.c dfbsd-maxx:/tmp/ vm.sh run_root 'cc -O2 -Wall -o /tmp/syncrace /tmp/syncstart_race.c -lpthread'

RUN (unprivileged, sound-equipped DragonFly box) vm.sh run_user '/tmp/syncrace; echo exit=$?'

EXPECTED This guest: open("/dev/dsp") -> ENOENT (no PCM device, env.txt); the race cannot execute here. On a sound-equipped box: two SYNCSTART threads racing on the same sg_id while a writer keeps the member channel lock contended force T1 into lksleep("pcmsg", 5ms) inside its member walk; T2 tears the group down (starts members, kfrees every sm and the sg, dsp.c:3056-3069) while T1 sleeps; T1's 5ms timeout wake resumes the SLIST_FOREACH from the freed sm β€” UAF read of SLIST_NEXT(sm) and CHN_TRYLOCK(sm->ch) on a freed object (wild lock pointer -> panic or corruption). 100k iterations in the PoC; a surviving run prints the "survived" line (race is probabilistic).

WHAT IT PROVES (source-certain) dsp.c:3042-3049: lksleep() drops snd_pcm_syncgroups_mtx while asleep, so sg/sm pointers held by the interrupted SLIST_FOREACH can be freed by a concurrent successful SYNCSTART; the timeout path (lksleep returns 0) does needlocks = 1; ret = 0; and CONTINUES the walk over freed memory. Only the EINTR/ERESTART path breaks out safely.

VERDICT.md
↓ download raw

DF-3880 VERDICT β€” dsp_oss_syncstart use-after-free after lksleep

Status: not_reproduced (kernel path β€” no PCM device on this guest); source-certain mechanism; confidence likely (race timing analysis).

Root cause (dsp.c:3001-3051)

The SYNCSTART retry loop:

    PCM_SG_LOCK();                                   /* :3001 */
    do {
        SLIST_FOREACH(sg, &snd_pcm_syncgroups, link) { ... }   /* :3008 */
        SLIST_FOREACH(sm, &sg->members, link) {               /* :3027 */
            if (CHN_TRYLOCK(sm->ch) == 0) {               /* :3028 */
                /* unlock members locked so far */
                ret = lksleep(sm, &snd_pcm_syncgroups_mtx,
                    PCATCH, "pcmsg", timo);            /* :3042 */
                if (ret == EINTR || ret == ERESTART)
                    break;                         /* safe exit */
                needlocks = 1;
                ret = 0;                             /* :3048 */
            }
        }                                                  /* :3050 */
    } while (needlocks && ret == 0);                    /* :3051 */

lksleep(ident, lock, ...) releases snd_pcm_syncgroups_mtx while the thread sleeps. During that window (5 ms β€” timo = hz*5/1000, :3029-3031) another thread's successful SYNCSTART on the same sg_id:

  • re-acquires the SG lock (free),
  • locks ALL members (T1 just released M_0..M_{k-1}; M_k's lock is briefly free between writer-loop iterations),
  • starts every member, SLIST_REMOVE_HEAD + kfrees every sm and SLIST_REMOVE + kfrees sg itself β€” dsp.c:3056-3069.

T1's plain timeout wake (lksleep returns 0, no signal) sets needlocks=1; ret=0 and the inner SLIST_FOREACH continues from the freed sm: SLIST_NEXT(sm, link) reads freed memory, then CHN_TRYLOCK(sm->ch) loads a freed-data channel pointer and takes an unbounded lockmgr path on it β€” kernel UAF (panic / exploitable corruption depending on heap reuse; kfreed M_DEVBUF chunk re-spray is an attacker activity). sg itself is also dangling during the resumed walk.

Trigger requirements

  • PCM device present (SNDCTL_DSP_SYNCGROUP/SYNCSTART go through dsp_ioctl :2056-2066 with channels from getchns β€” /dev/dsp* are 0666).
  • Force T1's trylock failure: keep the member channel's lock contended (the PoC's writer thread looping write(2) on the same channel β€” chn_write holds CHN lock between uiomove drops).
  • T2 wins the race inside the 5 ms sleep β€” microseconds of work.
  • No signal involved (signals take the safe EINTR break).

Not reproduced here

No PCM device on this guest (env.txt) β€” open("/dev/dsp") = ENOENT (run.log). The PoC is complete and would run verbatim on sound-equipped hardware; the mechanism is proven by source (lksleep lock-drop semantics + the free at :3056-3069 + the resume at :3050).

Fix

Break out of the stale walk after any sleep and let the do-while rescan from the list head (fix.diff, git-apply clean):

                needlocks = 1;
                ret = 0; /* Assumes ret == EAGAIN... */
+               /* DF-3880: lksleep() dropped the syncgroup
+                * lock; sg/sm may be freed now. Restart the
+                * scan instead of resuming the walk. */
+               break;
            }
        }

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

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff adds the post-sleep break so the walk restarts from the live list head and is git-apply-clean against the read-only sys/ tree; a patched-kernel build cannot be exercised on this guest (no PCM device, the racing ioctls cannot run), so fix validation is not_testable here

fix.diff; VERDICT.md; env.txt (hardware gate)
↓ fix.diffper-fix-DF-3880

Confirmed kernel references

Detail

Exploit chain

unpriv user on sound-equipped box -> open /dev/dsp (0666) -> thread W loops write(2) on the fd (contends the member channel CHN lock) -> SNDCTL_DSP_SYNCGROUP (create group with that channel, dsp.c:2832-2977) -> thread T1: SNDCTL_DSP_SYNCSTART -> SG lock, walks members, CHN_TRYLOCK fails on the contended channel, releases earlier members, lksleep 5ms (drops SG lock, dsp.c:3034-3049) -> thread T2: SNDCTL_DSP_SYNCSTART same id -> SG lock free -> locks all members, starts them, kfrees all sm + sg (dsp.c:3056-3069) -> T1 5ms timeout fires, lksleep returns 0, walk resumes at freed sm -> SLIST_NEXT on freed chunk + CHN_TRYLOCK(freed->ch) -> UAF read / lock on wild pointer -> panic, or controlled corruption after M_DEVBUF re-spray (kmalloc-128-class grooming between teardown and wake).

Evidence (decisive lines)

run.log (open /dev/dsp ENOENT - device gate on this guest); VERDICT.md (annotated race timeline with dsp.c line refs, lksleep lock-drop semantics, teardown window, safe-EINTR vs buggy-timeout paths); syncstart_race.c (complete two-racer + contention-writer harness); env.txt (no PCM device); fix.diff (git-apply-clean: break the stale walk after any sleep, rescan from list head)

PoC changes

Authored from scratch (no seed). Two SYNCSTART racers re-armed with a fresh SYNCGROUP per iteration (the group is consumed by a successful start), plus a writer thread for deterministic CHN lock contention; documented device gate for this guest.

Verified recommended fix

After lksleep() returns in dsp_oss_syncstart, break out of the stale SLIST_FOREACH and let the do-while rescan the syncgroup list from the head (the lock was dropped during the sleep, so sg/sm may be freed)

Verdict

Source-certain use-after-free race in dsp_oss_syncstart (sys/dev/sound/pcm/dsp.c:2992-3082), the SNDCTL_DSP_SYNCSTART handler, triggerable by any unprivileged user on machines with a PCM sound device (/dev/dsp 0666). The member-walk retry loop calls lksleep(sm, &snd_pcm_syncgroups_mtx, PCATCH, "pcmsg", 5ms) at dsp.c:3042-3043 when CHN_TRYLOCK(sm->ch) fails; lksleep DROPS the syncgroup lock while asleep, so a concurrent successful SYNCSTART of the same sg_id on another thread can complete the whole teardown - start every member, SLIST_REMOVE_HEAD + kfree() each sm and SLIST_REMOVE + kfree() the sg itself (dsp.c:3056-3069) - inside the 5ms window. The plain timeout wake (lksleep returns 0, no signal) executes 'needlocks = 1; ret = 0;' (:3047-3048) and the inner SLIST_FOREACH then CONTINUES from the freed sm: SLIST_NEXT(sm, link) reads freed M_DEVBUF memory and CHN_TRYLOCK(sm->ch) loads and locks a channel pointer read out of freed memory - kernel UAF (panic or exploitable corruption via heap re-spray). Only the EINTR/ERESTART signal path breaks out safely. The trylock failure that forces the sleep is attacker-arranged by keeping the member channel's lock contended (a looping/blocking write(2) on the same channel, as in the PoC's writer thread). Distinct from known DF-1263 (syncgroup error-path free without unlink - M_WAITOK-unreachable); this is the sleep-path lifetime bug on the START side. Kernel-path reproduction impossible on this guest: no audio PCI class, no hw.snd, no /dev/dsp (env.txt), open() returns ENOENT (run.log) - hence not_reproduced; the PoC (100k re-armed iterations with two SYNCSTART racers + a lock-contention writer) is complete and would run verbatim on sound-equipped hardware. Confidence 'likely' rather than 'certain' only because the race window is timing-dependent; the code path mechanics are certain from source.