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

radeon_gem_va_ioctl: bo_va use-after-free β€” set_addr() drops the BO reservation internally, then radeon_gem_va_update_vm() dereferences bo_va unprotected while a racing GEM_CLOSE on the same DRM fd frees it

Summary

The ioctl obtains bo_va under the BO reservation - its only lifetime guarantee - then set_addr() unreserves internally by contract ('Object has to be reserved and gets unreserved by this function!'), after which the ioctl uses bo_va unlocked through the whole update_vm helper (bo/vm derefs, ttm_eu_reserve, mutex_lock on bo_va->vm->mutex, it.start reads). A concurrent GEM_CLOSE on the same fd (or a dup'd fd: same file_priv/vm) blocks on the reservation exactly until set_addr drops it, then rmv->kfree's bo_va (immediate for UNMAP; for MAP deferred to vm->freed which thread A's own clear_freed flushes BEFORE touching bo_va again). Scheduler-latency-wide window, reliably hittable with a two-thread loop; authenticated render-node user on Cayman+ with accel. Panic at minimum; with 128-byte slab grooming, controlled bo/vm pointers drive mutex_lock on arbitrary kernel addresses and vm_bo_update page-table writes per an attacker-shaped vm - a credible path to GPU-mediated arbitrary physical R/W and uid=0. Source-proven interleaving (guest has no radeon GPU - inconclusive runtime, trigger builds+runs to a documented SKIP). Re-lookup-under-re-established-reservation fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4604 Β· 11 files
FileTypeDescriptionSize
README.md β€” 1.7 KB ↓ raw
VERDICT.md β€” 6.3 KB ↓ raw
radeon_va_uaf.c β€” 7.2 KB view raw
build.sh β€” 65 B view raw
run.sh β€” 110 B view raw
build.log β€” 21 B view raw
run.log β€” 213 B view raw
env.txt β€” 313 B view raw
fix.diff β€” 2.5 KB view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 4.9 KB view raw

DF-4604 PoC β€” radeon_gem_va_ioctl bo_va UAF

What this is

A minimal, self-contained trigger for the bo_va use-after-free in radeon_gem_va_ioctl() (sys/dev/drm/radeon/radeon_gem.c:702). Two threads on the same DRM fd: A loops {GEM_CREATE β†’ VA_MAP β†’ VA_UNMAP}, B races GEM_CLOSE on the same handle, armed so that B is already blocked on the BO reservation at the moment A's radeon_vm_bo_set_addr() internally drops it.

Build

./build.sh            # cc -O2 -Wall -pthread -o radeon_va_uaf radeon_va_uaf.c

No drm headers required (UAPI vendored, BSD 3-arg _IOWR encoding matching sys/dev/drm/include/uapi/drm/drm.h:796-801).

Run (requires real radeon hardware, Cayman/HD69xx or newer)

./run.sh [/dev/dri/card0] [iterations]

Expected (unpatched kernel): kernel panic / drm fault in ttm_eu_reserve_buffers, mutex_lock, or radeon_vm_bo_update within seconds-to-minutes of iterations; with slab grooming, exploitable to uid=0 (see VERDICT.md).

On the audit QEMU guest: there is no radeon GPU; the binary builds and runs, printing SKIP: no DRM device… and exiting rc=3 (see run.log). This is the expected guest outcome β€” runtime confirmation needs a physical Cayman+ machine.

Patched kernel

With fix.diff applied (bo_va re-looked-up under the re-established reservation), the race window is closed; the trigger must run indefinitely without corrupting memory.

Files

  • radeon_va_uaf.c β€” trigger source
  • build.sh / run.sh β€” exact commands
  • build.log / run.log β€” guest output (full, untrimmed)
  • env.txt β€” guest environment
  • VERDICT.md β€” full narrative with path:line proof
  • fix.diff β€” validated-by-review unified fix
  • manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-4604 β€” radeon_gem_va_ioctl() use-after-free on bo_va (GEM_VA vs GEM_CLOSE race)

  • File: sys/dev/drm/radeon/radeon_gem.c
  • Class: kernel heap UAF / type confusion (memcorrupt bucket)
  • Severity: High β€” local unprivileged user with DRM access on Cayman-or-newer radeon
  • Status: source-proven; runtime verification impossible on the audit guest (no radeon GPU β€” QEMU cannot emulate AMD GPUs)

TL;DR

radeon_gem_va_ioctl() finds bo_va under the BO reservation, then calls radeon_vm_bo_set_addr(), which drops the reservation internally, and only afterwards uses bo_va in radeon_gem_va_update_vm() β€” with no reservation and no vm->mutex protecting the first dereferences. A concurrent DRM_IOCTL_GEM_CLOSE (or close(2) of a dup'd fd) on the same file_priv runs radeon_gem_object_close() β†’ radeon_vm_bo_rmv() β†’ kfree(bo_va). Thread A then executes radeon_gem_va_update_vm() on the freed ~128-byte object: bo_va->bo, bo_va->vm, mutex_lock(&bo_va->vm->mutex), and radeon_vm_bo_update() on freed (reclaimable) memory.

The exact interleaving

Thread A: DRM_IOCTL_RADEON_GEM_VA (UNMAP)        Thread B: DRM_IOCTL_GEM_CLOSE (same fd!)
------------------------------------------------ ------------------------------------------
radeon_gem.c:665  drm_gem_object_lookup()         (gobj ref held by A β€” bo itself safe)
radeon_gem.c:671  radeon_bo_reserve(rbo)          β†’ blocks here: rbo reserved by A
radeon_gem.c:677  bo_va = radeon_vm_bo_find()     |  (lifetime of bo_va is guaranteed ONLY
                  |                                |   by the BO reservation β€” see
                  |                                |   radeon_vm.c:117 "Object has to be
                  |                                |   reserved!")
radeon_gem.c:696  radeon_vm_bo_set_addr(0,0)      |
  radeon_vm.c:538 radeon_bo_unreserve() ------+   |
                  |                           |   +-- wakes, acquires reservation
                  |   *** WINDOW OPEN ***    +----β†’ radeon_gem.c:193 reserve OK
radeon_gem.c:702  radeon_gem_va_update_vm(bo_va)      radeon_gem.c:199 bo_va = find()
  radeon_gem.c:565   tv.bo = &bo_va->bo->tbo          radeon_gem.c:201 --ref_count==0
  radeon_gem.c:569   radeon_vm_get_bos(bo_va->vm,..)  radeon_gem.c:202 radeon_vm_bo_rmv()
  radeon_gem.c:573   ttm_eu_reserve_buffers()           radeon_vm.c:1127 it.start==0
  radeon_gem.c:585   mutex_lock(&bo_va->vm->mutex)      radeon_vm.c:1137 kfree(bo_va) ←FREE
  radeon_gem.c:590   if (bo_va->it.start)                 |
  radeon_gem.c:591   radeon_vm_bo_update(rdev, bo_va,..) |  ← ALL UAF

For the UNMAP variant the free is immediate (it.start == 0 β†’ straight kfree). For the MAP variant, radeon_vm_bo_rmv() queues bo_va itself on vm->freed and thread A's own radeon_gem_va_update_vm() calls radeon_vm_clear_freed() (radeon_gem.c:586), which does kfree(bo_va) (radeon_vm.c:1065) and then A dereferences it at radeon_gem.c:590-591. Both variants UAF.

Why the race is practically hittable

Thread B blocks inside radeon_bo_reserve(rbo, true) while A holds the reservation during set_addr(). The instant set_addr internally unreserves, the already-blocked B is woken and runs its entire close path (find β†’ rmv β†’ kfree) while A is still returning from set_addr β€” the window is scheduler latency wide, not instruction width. Looping {GEM_CREATE β†’ VA_MAP β†’ (UNMAP βˆ₯ GEM_CLOSE)} tens of thousands of times (see radeon_va_uaf.c) is sufficient on real hardware.

Preconditions

  • radeon KMS hardware, family >= CHIP_CAYMAN, accel_working (gates both vm_manager.enabled at radeon_gem.c:618 and the close path at radeon_gem.c:188). Cayman (HD 69xx) through the last radeon-supported SI/CIK parts.
  • An authenticated DRM fd (render node access as an unprivileged user, or any fd handed out by the display manager) β€” the ioctls are DRM_AUTH|DRM_RENDER_ALLOW (radeon_kms.c:912,918).
  • Both threads on the same fd (or dup'd fds β€” same file_priv, same vm).

Exploitability sketch (primary objective: uid=0)

struct radeon_bo_va is ~96-104 bytes β†’ kzalloc 128-size class. After the free, thread A's radeon_gem_va_update_vm(): 1. reads bo_va->bo and bo_va->vm from reclaimed memory, 2. passes &bo_va->bo->tbo into ttm_eu_reserve_buffers() (ww-mutex lock chains over attacker-influenced pointers), 3. mutex_lock(&bo_va->vm->mutex) on a reclaimed pointer, 4. radeon_vm_bo_update(rdev, bo_va, ...) walks bo_va->vm->page_tables[] and emits GPU page-table writes per the reclaimed vm.

Grooming the 128-byte slab with a kernel object whose contents the attacker controls (classic: repeated small kzalloc user-trigerable objects) converts step 4 into "point GPU PTEs at chosen physical pages", which on this intentionally-unhardened target (no SMAP/SMEP/KASLR equivalents for the GPU path) is a direct route to arbitrary physical read/write from the GPU command processor β†’ uid=0. Even without full grooming, the immediate symptom on real hardware is a kernel panic in the reservation/mutex machinery.

Verification on the audit guest β€” honest classification

  • Guest: DragonFly 6.5-DEVELOPMENT x86_64, QEMU std/Bochs VGA (chip=0x11111234). No /dev/dri, no radeon device, no drm in the kernel config. QEMU has no AMD-GPU emulation; the ioctl table for radeon never registers.
  • The trigger (radeon_va_uaf.c) builds cleanly on the guest (build.log) and runs, exiting rc=3 with the documented SKIP (run.log).
  • Therefore: status=inconclusive, reproduced=0 β€” the bug is proven by code trace (every step cited), but cannot be executed on this guest. Runtime confirmation requires a physical Cayman-or-newer machine. Fix validation likewise not_testable here.

Re-look-up bo_va after ttm_eu_reserve_buffers() has re-established the BO reservation inside radeon_gem_va_update_vm() (and derive bo/vm from refcounted objects held by the ioctl). See fix.diff.

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 authored against the read-only sys/ tree (never applied); validation impossible on this guest - no radeon hardware, so neither baseline nor patched behavior can be executed. The fix closes the window by construction: every bo_va dereference happens either under the BO reservation re-established by ttm_eu_reserve_buffers() or under vm->mutex, and the bo/vm pointers come from objects whose lifetime the ioctl pins.

fix.diff in this directory; reasoning in VERDICT.md 'Recommended fix' section.
↓ fix.diffper-fix-DF-4604

Confirmed kernel references

Detail

Exploit chain

On real hw: (1) unprivileged user opens render node/authenticated DRM fd; (2) thread A: GEM_CREATE -> VA_MAP, then arms thread B and issues VA_UNMAP; (3) thread B blocks in radeon_bo_reserve inside radeon_gem_object_close, is woken when set_addr internally unreserves, and kfree()s the ~128-byte bo_va; (4) thread A's radeon_gem_va_update_vm() runs on the freed object: reads bo_va->bo and bo_va->vm from reclaimed memory, mutex_lock()s a reclaimed vm, and calls radeon_vm_bo_update() which walks vm->page_tables[] from reclaimed memory; (5) groom the 128-size slab with attacker-controlled kernel data to aim the GPU page-table update at chosen physical pages -> GPU-mediated arbitrary phys r/w -> uid=0. Step (5) not demonstrated (no hardware).

Evidence (decisive lines)

VERDICT.md contains the full path:line interleaving diagram; radeon_va_uaf.c is the self-contained trigger (phase-barrier design so GEM_CLOSE blocks on the reservation exactly when set_addr drops it); run.log shows the guest build+run with the honest SKIP; fix.diff re-looks-up bo_va after ttm_eu_reserve_buffers() re-establishes the reservation.

PoC changes

Seed sketch had Linux 2-arg _IOWR macros and required system drm headers; rewrote with BSD 3-arg _IOWR('d', nr, type) matching sys/dev/drm/include/uapi/drm/drm.h:796-801, vendored the radeon UAPI subset, added render-node fallback and the rc=3 SKIP convention, and redesigned the race harness (phase variable) so the GEM_CLOSE thread is already blocked on the BO reservation at the moment radeon_vm_bo_set_addr() drops it.

Verified recommended fix

Pass refcounted objects (fpriv->vm, rbo from the gobj lookup) into radeon_gem_va_update_vm() and re-lookup bo_va with radeon_vm_bo_find() after ttm_eu_reserve_buffers() has re-established the BO reservation; bail out cleanly if a racing GEM_CLOSE already removed it.

Verdict

Source-proven use-after-free: radeon_gem_va_ioctl (radeon_gem.c:671-702) looks up bo_va under the BO reservation, but radeon_vm_bo_set_addr() drops that reservation internally (radeon_vm.c:538/:581), and the ioctl then passes bo_va to radeon_gem_va_update_vm() whose first dereferences (radeon_gem.c:565/569/573/585/590) are unprotected. A concurrent DRM_IOCTL_GEM_CLOSE (or close of a dup'd fd) on the same file_priv executes radeon_gem_object_close (radeon_gem.c:193-205), acquires the now-free reservation, drops bo_va->ref_count 1->0 and frees bo_va via radeon_vm_bo_rmv()->kfree (radeon_vm.c:1137 immediate for the UNMAP case; radeon_vm.c:1065 via vm->freed for MAP - which thread A's own update_vm then triggers before touching bo_va again at :590). Runtime verification on the audit QEMU guest is impossible: the guest has no radeon GPU (vgapci 0x1111:1234 Bochs, no /dev/dri, no drm in kernel config) and QEMU cannot emulate AMD GPUs. The trigger compiles cleanly on the guest and runs, exiting rc=3 with the documented SKIP. Verification requires a physical Cayman-or-newer radeon machine with accel working.