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

ttm_eu_reserve_buffers() duplicate-BO (-EALREADY/dups) handling is dead code on DragonFly: the DFly ww_mutex never returns -EALREADY, so duplicate BOs from an unprivileged CS list are silently recursively double-locked and ww ticket accounting desyncs

Summary

The dups branch codes against the Linux -EALREADY contract but the DF ww_mutex uses LK_CANRECURSE silent recursion - a duplicate GEM handle in a user CS list double-locks and ctx->acquired desyncs (first unlock NULLs ww->ctx; second skips). RUNTIME-PROVEN on stock guest (second_lock=0 vs Linux -37, acquired=1 after balanced unlocks, 3 runs); the reserve_shared-failure path via a user-controlled duplicate collapses into the DF-2136 permanent-lock wedge. __wwlock -EALREADY fix validated end-to-end on a rebuilt+reloaded drm.ko (second_lock=-37, RESULT=FIXED).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5138 Β· 16 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 7.3 KB ↓ raw
df5138_wwproof.c β€” 4.4 KB view raw
Makefile β€” 327 B ↓ download
build.sh β€” 125 B view raw
run.sh β€” 148 B view raw
build.log β€” 5.8 KB view raw
run.log β€” 519 B view raw
run.2.log β€” 533 B view raw
fix_run.log β€” 501 B view raw
fix_build.log β€” 124.9 KB view raw
fix.diff β€” 1.1 KB view raw
unload_hang.txt β€” 1007 B view raw
env.txt β€” 467 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 6.3 KB view raw

DF-5138 β€” ttm_eu_reserve_buffers dups handling is dead code on DragonFly

Files: sys/dev/drm/ttm/ttm_execbuf_util.c:119-124 (consumer of the broken contract) / root cause in sys/dev/drm/linux_wwmutex.c (DFly's ww_mutex).

What: ttm_eu_reserve_buffers() relies on __ttm_bo_reserve() returning -EALREADY when a BO's reservation is already held by the same ticket, so a duplicate BO (same GEM handle listed twice in a user CS submission β€” radeon relocs chunk and amdgpu BO list both pass duplicates through without dedupe) gets moved to the dups list and is locked exactly once. DragonFly's ww_mutex NEVER returns -EALREADY: ww_mutex_init() uses LK_CANRECURSE (linux_wwmutex.c:62) and lockmgr's same-thread recursive acquire silently succeeds (kern_lock.c:125-143), so __wwlock returns 0 and re-bumps ctx->acquired. The dups branch is dead code; the duplicate is silently double-locked, and ww_mutex_unlock() (linux_wwmutex.c:188-202) decrements ctx->acquired only once per resv (the first unlock NULLs ww->ctx, the second skips the decrement) β€” the ticket accounting desyncs. A second consumer already codes against the Linux contract: modeset_lock() (drm_modeset_lock.c:270-276) explicitly maps -EALREADY to "we already hold the lock".

Impact: success path is lock-count balanced (no corruption). Failure paths desync: in the uh-oh/retry path the wedged accounting makes later locks on the same ticket use the NOWAIT+tsleep route, and when reservation_object_reserve_shared() fails on a duplicate's second recursive episode, the reverse-backoff releases only the first episode β€” the BO's reservation lock stays permanently held (DF-2136-class wedge, reachable through a user-controlled duplicate list entry). Realistic ceiling: local DoS on GPU-equipped systems. Low (hardware-gated, conditional trigger), certain for the mechanism.

Reproduce (runtime, on the DFly guest β€” mechanism level)

The KLD df5138_wwproof.c performs the exact sequence ttm_eu_reserve_buffers() performs for a duplicate BO (two ww_mutex_lock() on the same resv with the same ticket), then the two unlocks ttm_eu_fence_buffer_objects() would do:

kldload drm                 # ww_mutex implementation lives in drm.ko
sh build.sh                 # make SYSDIR=/usr/src/sys
sh run.sh                   # dmesg | grep DF5138

Stock kernel (run.log): first_lock=0 second_lock=0 <- Linux contract: -37 (-EALREADY) ticket->acquired after unlocks=1 <- correct value: 0 (desync) RESULT=REPRODUCED

Fix validation

fix.diff adds the missing -EALREADY short-circuit to __wwlock(). Applied inside the guest /usr/src, drm.ko rebuilt (fix_build.log), loaded, proof re-run (fix_run.log): first_lock=0 second_lock=-37 ticket->acquired after unlocks=0 RESULT=FIXED

See unload_hang.txt for the pre-existing stock kldunload drm hang hit during validation (environment, unrelated).

Files

  • df5138_wwproof.c, Makefile, build.sh, run.sh
  • build.log (module build), run.log + run.2.log (stock runs), fix_run.log (patched run), fix_build.log (patched drm.ko build)
  • fix.diff, env.txt, unload_hang.txt, VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-5138 β€” VERDICT

status: reproduced (mechanism, runtime-proven on the audit guest) reproduced: 1 (mechanism) Β· impact: none on this guest (ceiling: local DoS on GPU hardware)

The finding

ttm_eu_reserve_buffers() (sys/dev/drm/ttm/ttm_execbuf_util.c:93-178) contains a duplicate-BO separation mechanism that is dead code on DragonFly:

ret = __ttm_bo_reserve(bo, intr, (ticket == NULL), ticket);   // :113
...
} else if (ret == -EALREADY && dups) {                        // :119
        struct ttm_validate_buffer *safe = entry;
        entry = list_prev_entry(entry, head);
        list_del(&safe->head);
        list_add(&safe->head, dups);
        continue;
}

It depends on the Linux ww_mutex contract: locking a ww_mutex already held by the SAME acquire context returns -EALREADY. DragonFly's ww_mutex (sys/dev/drm/linux_wwmutex.c, DFly-original) never does:

  • ww_mutex_init() initializes the lockmgr with LK_CANRECURSE (linux_wwmutex.c:62);
  • lockmgr's same-thread exclusive relock silently succeeds (sys/kern/kern_lock.c:125-143: "If the caller already holds the lock exclusively then we silently obtain another count");
  • so __wwlock() returns 0 and executes ww->ctx = ctx; ww->stamp = ctx->stamp; ++ctx->acquired; a second time (linux_wwmutex.c:117-120).
  • grep confirms: no -EALREADY producer exists anywhere in linux_wwmutex.c / ww_mutex.h.

Consequences, traced and (for the core mechanism) runtime-proven:

  1. Duplicate BOs are recursively double-locked. Both consumers can deliver duplicates from unprivileged userspace: the radeon CS relocs chunk looks up each handle independently (radeon_cs.c:103-104 β€” same handle twice β†’ two entries), and amdgpu_bo_list_create() does not dedupe either (amdgpu_bo_list.c: lookup per info[i].bo_handle). On Linux the second entry goes to dups; on DFly both stay in list and the resv is locked twice.
  2. Ticket accounting desync. ww_mutex_unlock() (linux_wwmutex.c:188-202) decrements ctx->acquired and NULLs ww->ctx only on the FIRST unlock of a resv; the second unlock of the recursive hold finds ww->ctx == NULL and skips the decrement β†’ ctx->acquired over-counts. Measured: acquired=1 after balanced lock/unlock (run.log). Effect: __wwlock() later takes the LK_NOWAIT+tsleep route (linux_wwmutex.c:110-114) where a plain blocking lock was correct β€” behavioral skew only (liveness analyzed sound).
  3. Failure-path wedge (conditional). If the uh-oh path fires between the two occurrences (e.g. -EDEADLK), the reverse-backoff unlocks the first episode, the loop re-acquires, and lock counts re-balance (traced); but if reservation_object_reserve_shared() fails (krealloc -ENOMEM, linux_reservation.c:93-94) on the SECOND occurrence, the reverse-backoff releases the first episode and the second recursive episode is never unlocked (ttm_eu_backoff_reservation_reverse is exclusive of the current entry β€” list.h:187-189) β†’ the BO's reservation lock is permanently held. This is the DF-2136 leak made reachable via a user-controlled duplicate list entry (on Linux the dup would have been moved to dups and never double-locked). Every later CS touching that BO blocks in ww_mutex_lock_slow (killable when intr, unkillable for the intr=false callers, e.g. amdgpu_gem.c:177).
  4. Fence side is refcount-safe (traced, killed as a corruption candidate): reservation_object_add_shared_fence / add_excl_fence take their own refs (linux_reservation.c:109/151/244); a shared fence with the same context replaces in place (linux_reservation.c:120-127). Double fence installation on a duplicated resv is semantically odd but refcount-balanced, and the lockmgr count balances (two LK_RELEASE for two acquisitions).

A second consumer already expects the contract: modeset_lock() (drm_modeset_lock.c:270-276) maps -EALREADY to "we already hold the lock.. this is fine" β€” dead code on DFly today for the same reason.

Runtime proof (guest, stock kernel)

df5138_wwproof.ko performs the exact ttm_eu_reserve_buffers sequence for a duplicate BO (two ww_mutex_lock(&ww, &ctx), then the unlocks ttm_eu_fence_buffer_objects() would perform β€” one per entry still in the list). Stock run (run.log, run.2.log β€” 3 identical runs total):

DF5138-PROOF: first_lock=0 second_lock=0 (EALREADY would be -37)
DF5138-PROOF: ticket->acquired after both locks = 2 (...)
DF5138-PROOF: after unlocks ticket->acquired = 1 (correct value: 0; 1 proves the desync)
DF5138-PROOF: resv still locked? 0 (...)
DF5138-PROOF: RESULT=REPRODUCED (no -EALREADY; silent recursive double-lock; ticket accounting desync)

ww_mutex_is_locked()==0 also confirms the success path is lock-balanced (the bug is NOT a straight memory-corruption primitive β€” the dangerous manifestations are the failure-path wedges in 2/3 above, hardware-gated behind a GPU CS ioctl this guest lacks).

Fix validation (performed in the same run)

fix.diff restores the contract in __wwlock():

if (ctx != NULL && ww->ctx == ctx)
        return (-EALREADY);

Safety: ctx is private to the calling thread; observing ww->ctx == ctx implies this thread holds the lock (own store visible to own load; no other thread can store our ctx pointer). Applied inside the guest /usr/src, drm.ko rebuilt (fix_build.log, 105 compile units, clean -Werror), installed, guest rebooted (see unload_hang.txt β€” stock kldunload drm hang forced a reboot; environment issue, unrelated), proof re-run (fix_run.log):

DF5138-PROOF: first_lock=0 second_lock=-37 (EALREADY would be -37)
DF5138-PROOF: ticket->acquired after both locks = 1 (...)
DF5138-PROOF: after unlocks ticket->acquired = 0 (correct value: 0; ...)
DF5138-PROOF: resv still locked? 0 (...)
DF5138-PROOF: RESULT=FIXED (-EALREADY returned; dup separated; accounting correct)

With the fix, ttm_eu_reserve_buffers' dups branch is live, the duplicate is separated, and both the modeset -EALREADY handler and the ttm dups handling behave as upstream intends. Baseline REPRODUCED / patched FIXED β€” bad behavior gone.

Why severity is Low

  • The mechanism is certain and runtime-proven, but the memory-safety-adjacent consequences (3) need (a) radeon/amdgpu GPU hardware (absent on the audit guest), (b) a duplicate handle in a CS list, and (c) an allocation failure at the right instant β€” narrow trigger, local DoS ceiling, overlapping DF-2136's leak family for the wedge symptom.
  • No information disclosure, no corruption on the success path.

Exploit chain

(conditional DoS only: CS ioctl with duplicate BO handle + reserve_shared -EINTR/-ENOMEM timing β†’ permanently-held resv lock β†’ all later users of that BO block; no escalation path β€” the desync never produces attacker-controlled memory writes.)

Environment note

kldunload drm of the STOCK module hangs on this guest (D-state, holds the kld lock; unload_hang.txt) β€” pre-existing, unrelated to the finding/fix. Guest left clean via vm.sh reset with-src.

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 applied to guest /usr/src/sys/dev/drm/linux_wwmutex.c, drm.ko rebuilt (fix_build.log: 105 compile units, clean -Werror build, 20s), installed to /boot/kernel, guest rebooted, proof module re-run: second_lock=-37 (-EALREADY as on Linux), ticket->acquired=0 after unlocks, RESULT=FIXED (fix_run.log). Baseline (stock drm.ko) had second_lock=0 / acquired=1 / RESULT=REPRODUCED. git apply --check clean against the unmodified tree.

fix_run.log ('DF5138-PROOF: RESULT=FIXED (-EALREADY returned; dup separated; accounting correct)'); fix_build.log (full patched drm.ko build); run.log (baseline RESULT=REPRODUCED); unload_hang.txt (stock kldunload drm environment hang documented, unrelated to fix)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 x86_64 (same kernel; single-module drm.ko rebuild)

Confirmed kernel references

Detail

Exploit chain

conditional local DoS on GPU-equipped systems: CS ioctl listing the same GEM handle twice -> ttm_eu_reserve_buffers recursively double-locks the resv -> if reserve_shared fails on the second episode (memory pressure), reverse-backoff releases only the first -> BO reservation lock held forever -> every later CS/validation touching that BO blocks (unkillable for intr=false callers). No memory-write primitive, no escalation path.

Evidence (decisive lines)

run.log + run.2.log (stock: 'first_lock=0 second_lock=0 (EALREADY would be -37)', 'after unlocks ticket->acquired = 1 (correct value: 0...)', RESULT=REPRODUCED); fix_run.log (patched drm.ko: 'first_lock=0 second_lock=-37', 'ticket->acquired = 0', RESULT=FIXED); fix_build.log (105-unit drm.ko rebuild with fix.diff, clean); build.log (proof module build); VERDICT.md source trace: ttm_execbuf_util.c:113/119-124, linux_wwmutex.c:62/109-120/188-202, kern_lock.c:125-143, list.h:187-189, linux_reservation.c:93-94, drm_modeset_lock.c:270-276 (second consumer already handling -EALREADY = dead code today), radeon_cs.c:103-104 + amdgpu_bo_list.c (no dedupe of duplicate handles)

PoC changes

authored from scratch (no seed): KLD performing ttm_eu's exact duplicate-BO lock/unlock sequence against the live drm.ko ww_mutex implementation; module made fix-aware (second unlock only when second lock returned 0, mirroring the dups separation) so the same binary validates baseline and patched kernels; build needed the drm compat KCFLAGS include set (sys/dev/drm/drm/Makefile:121-126) and sys/conf.h for DEV_MODULE

Verified recommended fix

In __wwlock() (sys/dev/drm/linux_wwmutex.c) return -EALREADY when the lock is already held by the calling acquire context: 'if (ctx != NULL && ww->ctx == ctx) return (-EALREADY);' β€” restoring the Linux ww_mutex contract that ttm_execbuf_util.c:119-124 and drm_modeset_lock.c:270-276 already code against

Verdict

Mechanism runtime-reproduced on the audit guest with a KLD that performs ttm_eu_reserve_buffers' exact duplicate-BO sequence: ww_mutex_lock twice with the same ticket returned 0 the second time (Linux contract: -EALREADY=-37; no -EALREADY producer exists in sys/dev/drm/linux_wwmutex.c because ww_mutex_init sets LK_CANRECURSE at linux_wwmutex.c:62 and kern_lock.c:125-143 silently recurses same-thread exclusive relocks), making the dups branch at ttm_execbuf_util.c:119-124 dead code; after the two matching unlocks ticket->acquired stayed 1 instead of 0 (ww_mutex_unlock at linux_wwmutex.c:188-202 decrements only the first unlock of a recursive hold). Success path is lock-count balanced (ww_mutex_is_locked==0 after) β€” NOT a direct corruption primitive. Traced ceilings: (a) duplicate BO + reservation_object_reserve_shared failure (krealloc -ENOMEM, linux_reservation.c:93-94) leaves the second recursive episode permanently held (DF-2136-class wedge reachable through a user-controlled duplicate CS entry; later CS users of the BO block forever, unkillable for intr=false callers like amdgpu_gem.c:177); (b) acquired over-count forces the NOWAIT+tsleep route on later same-ticket locks. Duplicate handles are deliverable by unprivileged userspace on both consumers (radeon_cs.c:103-104 per-entry lookup; amdgpu_bo_list.c no dedupe). Full chain needs GPU hardware the audit guest lacks (0 AMD/ATI PCI devices), hence impact=none here and severity Low. fix.diff (restore -EALREADY in __wwlock) applied in guest /usr/src, drm.ko rebuilt clean (-Werror) and reloaded: proof printed second_lock=-37, acquired=0, RESULT=FIXED β€” bad behavior gone.