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)
PoC verification
Evidence pack
findings/poc/DF-4510 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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-130reader) - 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) setDRIVER_SYNCOBJ; the syncobj ioctls areDRM_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).
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)
- 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). - The waiter's
kref_getis a controlledatomic_incat offset 0 of the reused slot (refcount corruption of whatever victim object was sprayed in). - If the slot still contains groomed fake-fence bytes,
dma_fence_is_signaledreads fakeflags/opsβfence->ops->signaled(fence)is an indirect call through a forged ops pointer;dma_fence_add_callbackwrites a linked-list node through fakefence->cb_listβ write-what-where-list primitives. On this audit target (no SMAP/SMEP/ KASLR by policy) that is a direct code-execution path. - 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.
Recommended fix
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_testablefix.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
Confirmed kernel references
- sys/dev/drm/drm_syncobj.c:170-216
- sys/dev/drm/drm_syncobj.c:195
- sys/dev/drm/drm_syncobj.c:242
- sys/dev/drm/drm_syncobj.c:715
- sys/dev/drm/include/drm/drm_syncobj.h:120-130
- sys/dev/drm/include/linux/dma-fence.h:97-110
- sys/dev/drm/include/linux/dma-fence.h:189-199
- sys/dev/drm/include/linux/rcupdate.h:46-56
- sys/dev/drm/linux_fence.c:43-51
- sys/dev/drm/drm_ioctl.c:685-698
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.
No comments yet.