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

drm_syncobj: lockless drm_syncobj_fence_get() races drm_syncobj_replace_fence()'s outside-the-lock dma_fence_put() of the old fence β€” rcu_read_lock() is only preempt_disable() on DragonFly and the last put kfree()s synchronously, so a cross-CPU waiter kref_get()s freed memory and then dispatches through the freed fence's ops: unprivileged kernel heap UAF (read+write) via render-node syncobj ioctls on i915/amdgpu

Summary

syncobj->fence is read locklessly via dma_fence_get_rcu_safe (bare load + unconditional kref_get) while replace_fence swaps under the lock and drops the old fence's reference AFTER releasing it. A SIGNAL-created stub fence holds exactly one ref and dma_fence_release kfrees synchronously; DF's rcu_read_lock provides no grace period, so a cross-CPU waiter that loaded the pointer before the swap atomically increments freed memory then dispatches through the freed fence's ops (indirect call) and writes its cb_list. All syncobj ioctls are DRM_UNLOCKED|RENDER_ALLOW - unpriv via render node on i915/amdgpu; SIGNAL vs WAIT(0) loop at ioctl speed on SMP. Same no-op-RCU family as DF-4329/1881/4506. kref_get_unless_zero + kfree_rcu (delay-line) fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4510 Β· 10 files
FileTypeDescriptionSize
README.md β€” 2.3 KB ↓ raw
VERDICT.md β€” 6.5 KB ↓ raw
syncobj_race.c β€” 5.5 KB view raw
build.sh β€” 124 B view raw
run.sh β€” 101 B view raw
build.log β€” 28 B view raw
run.log β€” 932 B view raw
env.txt β€” 861 B view raw
fix.diff β€” 1.9 KB view raw
verdict.json β€” 5.4 KB view raw

DF-4510 β€” drm_syncobj lockless fence lifetime: drm_syncobj_fence_get Γ— drm_syncobj_replace_fence β†’ synchronous kfree β‡’ dma_fence UAF

  • File: sys/dev/drm/drm_syncobj.c:170-216 (+ include/drm/drm_syncobj.h:120-130 reader)
  • Class: kernel heap use-after-free (read+write), unprivileged via render node
  • Reachability: i915 (i915_drv.c:3283-3285) / amdgpu (amdgpu_drv.c:1302-1306) set DRIVER_SYNCOBJ; the syncobj ioctls are DRM_UNLOCKED|DRM_RENDER_ALLOW (drm_ioctl.c:685-698). Needs β‰₯2 CPUs for the interleave.

The bug in one paragraph

drm_syncobj_fence_get() loads syncobj->fence and takes a reference with a bare kref_get (dma-fence.h:189-199), under an rcu_read_lock() that on this port is only preempt_disable() (rcupdate.h:46-56) β€” no grace period. drm_syncobj_replace_fence() swaps the pointer under syncobj->lock, then drops the old fence's reference after releasing the lock (drm_syncobj.c:195). A syncobj whose fence was installed by SYNCOBJ_SIGNAL holds the only reference to that stub fence, so the put frees it immediately (linux_fence.c:43-51 β†’ kfree). A waiter on another CPU that loaded the pointer just before the swap executes kref_get on freed memory and then uses the freed fence (dma_fence_is_signaled dispatches fence->ops; dma_fence_add_callback writes fence->cb_list). Groomable heap UAF; full chain in VERDICT.md.

Reproduce (i915/amdgpu hardware, β‰₯2 CPUs, unprivileged)

cc -O2 -pthread -o syncobj_race syncobj_race.c
./syncobj_race 30      # thread A: SYNCOBJ_SIGNAL loop; thread B: SYNCOBJ_WAIT(0) loop

Expected: kernel panic / slab or refcount corruption splat during the run, or silent corruption detectable in dmesg.

On the audit guest

run.log: no /dev/dri/* nodes exist (the guest's only display device is QEMU virtio-gpu chip=0x11111234, unsupported by this i915/radeon/amdgpu port). The trigger compiles cleanly (build.log) but the race cannot be started β€” verdict inconclusive (device absent), not a false positive.

Fix

fix.diff β€” both halves: kref_get_unless_zero + retry in dma_fence_get_rcu/_rcu_safe, and kfree_rcu(fence, rcu) in dma_fence_release/dma_fence_free (the port's __kfree_rcu delay-line provides a β‰₯1 s grace period).

VERDICT.md
↓ download raw

DF-4510 β€” drm_syncobj: lockless syncobj->fence reference race β†’ immediate kfree β‡’ dma_fence UAF (unpriv via render node on i915/amdgpu)

Status: inconclusive on the audit guest β€” the guest has no DRM device (no /dev/dri; its only display PCI device is QEMU virtio-gpu chip=0x11111234, for which this DRM port has no driver β€” only i915, radeon and amdgpu exist, and only i915/amdgpu set DRIVER_SYNCOBJ). The race itself is proven from the code below; every element of it was verified line-by-line in this tree.

The race (line-accurate)

Reader (waiter) β€” drm_syncobj_wait_ioctl β†’ drm_syncobj_array_wait_timeout:

drm_syncobj.c:715:  entries[i].fence = drm_syncobj_fence_get(syncobjs[i]);
/* include/drm/drm_syncobj.h:120-130 */
rcu_read_lock();                                   /* == preempt_disable()  */
fence = dma_fence_get_rcu_safe(&syncobj->fence);
rcu_read_unlock();

/* include/linux/dma-fence.h:189-199 β€” NO lock, NO retry, NO unless-zero: */
fence = *dfp;               /* (R1) load pointer                    */
if (fence)
        kref_get(&fence->refcount);   /* (R2) unconditional atomic inc */
return fence;

Writer (signaller/resetter/driver CS) β€” drm_syncobj_signal_ioctl / drm_syncobj_reset_ioctl / drm_syncobj_replace_fence (also drm_syncobj_free, and every driver export user):

/* drm_syncobj.c:170-196 */
if (fence) dma_fence_get(fence);
lockmgr(&syncobj->lock, LK_EXCLUSIVE);
old_fence = syncobj->fence;            /* plain deref              */
rcu_assign_pointer(syncobj->fence, fence);  /* mfence + store      */
... run cb_list callbacks under the lock ...
lockmgr(&syncobj->lock, LK_RELEASE);
dma_fence_put(old_fence);              /* (W) OUTSIDE the lock      */

For a syncobj last SIGNALed (the only thing an unprivileged user needs to arrange), old_fence is a drm_syncobj_stub_fence (drm_syncobj.c:199-216) whose single reference is the one the syncobj holds. So (W) drives refcount 1β†’0:

/* linux_fence.c:43-51 */
dma_fence_release(): fence->ops->release ? : kfree(fence);
/* drm_syncobj_stub_fence_ops (drm_syncobj.c:69-72) has NO ->release
   => kfree() fires SYNCHRONOUSLY, no grace period of any kind. */

On Linux this is safe because rcu_read_lock() is a real RCU critical section and fence frees go through kfree_rcu; a reader inside the section cannot have the object pulled from under it, and dma_fence_get_rcu_safe retries. On DragonFly both halves are stubs:

sys/dev/drm/include/linux/rcupdate.h:46-56:  rcu_read_lock()  == preempt_disable()
sys/dev/drm/include/linux/rcupdate.h:94:     synchronize_rcu() == <empty>
sys/dev/drm/include/linux/dma-fence.h:97-103: dma_fence_get_rcu() == unconditional kref_get

Interleave: CPU0 executes (R1) and loads old_fence; CPU1 executes (W), kfree'ing it; CPU0 resumes at (R2) β†’ atomic_inc on freed memory, then returns the dangling pointer, which the waiter immediately dereferences (dma_fence_is_signaled reads fence->flags and dispatches fence->ops at dma-fence.h:115-127, and dma_fence_add_callback writes cb->node into the freed object's cb_list at linux_fence.c:310-312).

No lock is shared between reader and writer, preempt_disable only prevents preemption of CPU0 — it does not stop CPU1 — so this is a genuine cross-CPU UAF. The window (load→inc, a few instructions) is small but the attack loop is free: SIGNAL allocates+frees a stub fence per call (kmalloc churn at ioctl speed) while the waiter polls WAIT(0) at ioctl speed; run indefinitely on a 2+ core machine.

Same-site readers: drm_syncobj_find_fence (drm_syncobj.c:242), the wait path (:715), drm_syncobj_fence_get_or_add_callback (:118); and all driver-facing exports of drm_syncobj_replace_fence drop the old fence the same way (amdgpu CS out-fences, i915 execbuf out-fences).

Exploitation sketch (why this is memcorrupt, not a theoretical splat)

  1. The freed object is struct drm_syncobj_stub_fence (dma_fence + spinlock, one kmalloc bucket). Groom that bucket with attacker-controlled data (spray via the usual unprivileged kmalloc consumers).
  2. The waiter's kref_get is a controlled atomic_inc at offset 0 of the reused slot (refcount corruption of whatever victim object was sprayed in).
  3. If the slot still contains groomed fake-fence bytes, dma_fence_is_signaled reads fake flags/ops β†’ fence->ops->signaled(fence) is an indirect call through a forged ops pointer; dma_fence_add_callback writes a linked-list node through fake fence->cb_list β€” write-what-where-list primitives. On this audit target (no SMAP/SMEP/ KASLR by policy) that is a direct code-execution path.
  4. Even the benign-looking outcome (refcount bump on a reused victim object) is a classic refcount-overflow→UAF seed.

Family (same no-op-RCU class, distinct files/objects β€” NOT duplicates): DF-4329 (i915_request), DF-1881 (linux_reservation), DF-4506 (linux_idr grow), DF-4362 (fd/sync_file shims β€” that one is the fd leak, unrelated).

Why inconclusive on this guest (not a false positive)

open("/dev/dri/renderD128") -> No such file or directory
open("/dev/dri/card0")      -> No such file or directory
pciconf -l: vgapci0@pci0:0:2.0 chip=0x11111234 (QEMU virtio-gpu) β€” no driver
  in this DRM port (port contains i915/radeon/amdgpu only); radeon lacks
  DRIVER_SYNCOBJ anyway.

Every syncobj ioctl is gated on drm_core_check_feature(dev, DRIVER_SYNCOBJ) (drm_syncobj.c:583 etc.) and no DRM device exists, so the racing threads cannot even be started. The C trigger (syncobj_race.c) builds cleanly on the guest (see build.log) and would run unmodified on any i915/amdgpu machine with 2+ cores; run.log records the device-absent diagnosis.

Two halves must both land (see fix.diff): 1. dma_fence_get_rcu/dma_fence_get_rcu_safe: use kref_get_unless_zero() + retry loop (upstream semantics) so a reader can never resurrect a fence whose release already fired. 2. Deferred free: dma_fence_release's default path and dma_fence_free must use kfree_rcu(fence, rcu) β€” the port's __kfree_rcu (linux_rcu.c:162-184) already implements a β‰₯1 s delay-line, which is a sufficient grace period for the tiny loadβ†’inc window.

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 (kref_get_unless_zero + retry in dma_fence_get_rcu/_rcu_safe, deferred kfree_rcu(fence, rcu) in dma_fence_release/dma_fence_free β€” the port's __kfree_rcu delay-line gives >=1s grace, ample for the few-instruction window) is git-apply clean against sys/; baseline/patched comparison impossible on the guest because no DRM device exists to run the trigger against a patched kernel.

git apply --check findings/poc/DF-4510/fix.diff -> OK; kref_get_unless_zero verified present in sys/dev/drm/include/linux/kref.h:85; struct dma_fence.rcu member verified in sys/dev/drm/include/linux/dma-fence.h:45; __kfree_rcu delay-line verified in sys/dev/drm/linux_rcu.c:80-110,162-184
↓ fix.diffper-fix-DF-4510

Confirmed kernel references

Detail

Exploit chain

unpriv render-node fd -> SYNCOBJ_CREATE(SIGNALED) once -> thread A: SYNCOBJ_SIGNAL loop (each call frees the previous stub fence via replace_fence's unlocked put) while thread B: SYNCOBJ_WAIT(0) loop (fence_get load+inc) -> B interleaves load/kfree/inc -> kref_get on freed kmalloc slot + use of freed dma_fence -> groom the freed bucket with controlled data -> fake dma_fence ops pointer -> dma_fence_is_signaled/fence_cb list write primitives -> code exec on this unhardened target

Evidence (decisive lines)

VERDICT.md (line-accurate race proof + exploitation sketch); syncobj_race.c + run.log (builds clean; device-absent diagnosis on guest); fix.diff (git-apply clean; two halves: kref_get_unless_zero+retry and kfree_rcu deferred fence free)

PoC changes

authored fresh: self-contained uapi structs + ioctl numbers taken from sys/dev/drm/include/uapi/drm/drm.h (0xBF/0xC3/0xC5), two racing pthreads, device-absent diagnosis path

Verified recommended fix

dma_fence_get_rcu(_safe): kref_get_unless_zero + retry loop, and free fences via kfree_rcu (dma_fence_release default path and dma_fence_free) so the port's RCU delay-line covers lockless readers

Verdict

Race-certain by construction, untestable on this guest. drm_syncobj_fence_get() (drm_syncobj.h:120-130) loads syncobj->fence and takes a reference via dma_fence_get_rcu_safe() (dma-fence.h:189-199) which on this port is a bare load + unconditional kref_get, under rcu_read_lock() that is only preempt_disable() (rcupdate.h:46-56). drm_syncobj_replace_fence() (drm_syncobj.c:170-196) swaps the pointer under syncobj->lock and then dma_fence_put()s the old fence OUTSIDE the lock (:195); for a syncobj holding only a SIGNAL-created stub fence that put is the last reference and frees synchronously via dma_fence_release->kfree (linux_fence.c:43-51; stub ops have no ->release). A concurrent waiter on another CPU that loaded the pointer before the swap executes kref_get on freed memory (UAF write) and then uses the freed fence β€” dma_fence_is_signaled dispatches fence->ops and dma_fence_add_callback writes its cb_list β€” a heap UAF (read+write) type-confusable through slab grooming; all syncobj ioctls are DRM_UNLOCKED|DRM_RENDER_ALLOW and i915/amdgpu set DRIVER_SYNCOBJ, so this is unprivileged on real desktop hardware. Could not be exercised: the guest has no DRM device at all (only QEMU virtio-gpu hardware, unsupported by this port; no /dev/dri β€” run.log), so the racing ioctls cannot be issued; trigger program builds clean on the guest (build.log) and runs unmodified on any i915/amdgpu box with 2+ cores. Narrow load->inc window makes practical exploitation 'likely' rather than 'certain', but the window is attacker-repeatable indefinitely at ioctl speed. Same no-op-RCU family as DF-4329/DF-1881/DF-4506, distinct file and object; unrelated to the known DF-1666 fd leak on this file.