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

radeon_sa_bo_next_hole leaves dangling borrowed fence pointers across inner-loop iterations β†’ UAF write, UAF read, and double kfree in radeon_sa_bo_new (missing upstream fix 0381ac3ca2e7)

Summary

The port has the 2016 fence-ref loop but LACKS the 2022 fences[i]=NULL per-scan init: next_hole borrows an uncounted fence pointer, the same thread frees it under the same lock (flist fences hold exactly one ref - the common case), and a later scan with no fresh borrow ref's/reads/double-kfrees the stale pointer - kernel heap corruption on the standard slab-groom path from sustained unpriv DRI IB traffic. Line-faithful port harness: full chain 100/100 (UAF-REF, UAF-WAIT, DOUBLE-FREE), the exact upstream one-liner kills it 100/100; kernel path inconclusive (no radeon GPU on guest). Port-upstream-0381ac3ca2e7 fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4927 Β· 13 files
FileTypeDescriptionSize
radeon_sa_uaf.c β€” 16.9 KB view raw
build.sh β€” 118 B view raw
run.sh β€” 90 B view raw
build.log β€” 468 B view raw
run.log β€” 824 B view raw
run.2.log β€” 560 B view raw
run.3.log β€” 46 B view raw
env.txt β€” 276 B view raw
fix.diff β€” 1.2 KB view raw
VERDICT.md β€” 6.8 KB ↓ raw
README.md β€” 4.5 KB ↓ raw
manifest.json β€” 1.8 KB view raw
verdict.json β€” 5.8 KB view raw

DF-4927 β€” radeon_sa fence-borrow UAF / double-free (pass-2 finding)

File: sys/dev/drm/radeon/radeon_sa.c Class: kernel heap use-after-free + double-free (memcorrupt), HW-gated (radeon GPU required at kernel level) Upstream fix: Linux 0381ac3ca2e7 "drm/radeon: Initialize fences array entries in radeon_sa_bo_next_hole" (2022; amdgpu twin 6a15f3ff19a8). DragonFly carries the pre-2022 code: the 2016 fence-ref fix (f6ff4f67cdf8) is present (the ref loop at radeon_sa.c:352-353) but the 2022 fences[i] = NULL initialization in radeon_sa_bo_next_hole is missing.

What the bug is

radeon_sa_bo_next_hole() borrows a non-counted pointer into the caller's fences[] array (radeon_sa.c:278, only when the flist head fence is not yet signaled). The array is only reset once per outer loop iteration (radeon_sa.c:335-338); nothing clears fences[i] between inner loop iterations. If the borrowed fence becomes signaled before a later next_hole() call, the same thread frees it β€” via radeon_sa_bo_try_free() (sa.c:341β†’166β†’150) or next_hole() itself (sa.c:306) calling radeon_sa_bo_remove_locked() β†’ radeon_fence_unref() β†’ dma_fence_put() β†’ refcount 0 β†’ kfree() (sys/dev/drm/linux_fence.c:43-51; fences parked on an flist normally hold exactly one reference β€” see radeon_ib_free(), sys/dev/drm/radeon/radeon_ib.c:95-98). If ring i then produces no fresh borrow (its flist is now empty, or its new head is signaled with tries[i] > 2), the stale pointer survives until the inner loop exits, and:

  • radeon_sa.c:352-353 radeon_fence_ref(fences[i]) β†’ UAF write (kref_get on freed slab memory β€” corrupts allocator freelist metadata if the chunk was not yet reused),
  • radeon_sa.c:356 radeon_fence_wait_any() reads fences[i]->seq β†’ UAF read,
  • radeon_sa.c:357-358 radeon_fence_unref(&fences[i]) drops the bogus reference β†’ refcount hits 0 again β†’ dma_fence_release β†’ kfree() a second time β†’ double free.

Trigger: any local user with DRI access on a radeon system driving enough IB traffic that the shared ring_tmp_bo sub-allocator blocks (radeon_sa_bo_new wait loop) with multi-ring in-flight work β€” i.e. GPU workload + timing, no privileges beyond the DRI device. The window is "fence signals between two inner-loop iterations", which recurs millions of times under sustained load.

What can be verified on this audit guest

The QEMU guest has no radeon GPU, so the kernel code path cannot be exercised end-to-end (status inconclusive for the kernel path). What is verified, deterministically, on the guest:

  1. radeon_sa_uaf.c β€” a line-faithful userspace port of the five allocator functions (sa.c:142-374; only list plumbing, locks and the fence object are substituted, see file header). A "GPU simulator" flips the target fence to signaled on its 3rd radeon_fence_signaled() check β€” i.e. after it was borrowed by one next_hole() call and before the next iteration inspects it, exactly the window the upstream commit message describes. The model reproduces the full event chain: UAF-REF (352-353) β†’ UAF-WAIT (356) β†’ DOUBLE-FREE (357-358). 100/100 runs, exit 42.
  2. Fix validation on the same port (-DFIXED adds exactly the upstream one-liner fences[i] = NULL;): identical fence-death timeline, zero dangling dereferences, 100/100 runs, exit 0.
  3. Source divergence vs. Linux v6.1 radeon_sa.c (fetched during the audit) and the upstream fix commit 0381ac3ca2e7.patch (quoted in VERDICT.md).

Reproduce

sh build.sh          # cc -O2 -> rsa_base, cc -O2 -DFIXED -> rsa_fixed
sh run.sh            # expects rsa_base exit 42 (UAF), rsa_fixed exit 0

Decisive output (baseline):

    [fence F1 refcount 0 -> kfree]      <- same-thread free inside the locked loop
  *** UAF-REF    fence F1 ... at radeon_sa_bo_new:352 fence_ref
  *** UAF-WAIT   fence F1 ... at radeon_sa_bo_new:356 fence_wait_any
  *** DOUBLE-FREE fence F1 ... at radeon_sa_bo_new:358 fence_unref
VERDICT: UAF/double-free REPRODUCED on the ported allocator logic  (exit 42)

Files

  • radeon_sa_uaf.c β€” the PoC (port + fence model + GPU simulator)
  • build.sh / run.sh
  • build.log / run.log (baseline) / run.2.log (fixed) / run.3.log (100Γ— stress both ways) β€” full untrimmed guest output
  • env.txt β€” guest uname, compiler, user
  • fix.diff β€” the kernel fix (upstream port), validated on the port model
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-4927 VERDICT β€” radeon_sa dangling-fence UAF/double-free

Bottom line

The kernel-level bug is certain from source + upstream history; it could not be exercised on the audit guest because the guest has no radeon GPU. The allocator logic was instead extracted verbatim into a userspace port and run on the guest as uid=1001: the dangling-fence sequence (borrow β†’ same-thread kfree β†’ ref-after-free β†’ wait-read-after-free β†’ double-free) reproduces deterministically (100/100), and adding exactly the missing upstream one-liner eliminates all three events (100/100). Kernel-path status is therefore inconclusive (HW absent), with confidence: certain on the code-level claim itself.

Root cause (path:line)

  • sys/dev/drm/radeon/radeon_sa.c:278 β€” fences[i] = sa_bo->fence; borrows an uncounted pointer (only taken when the fence is not yet signaled).
  • sys/dev/drm/radeon/radeon_sa.c:270-272 β€” ring scan continues on an empty flist without clearing fences[i]; the array is only reset at the top of the outer loop (:335-338). Upstream Linux v6.1 clears it at the top of every scan (the fix added by commit 0381ac3ca2e7).
  • Fence death on the same thread, still under wq.lock: radeon_sa_bo_try_free (:341) β†’ radeon_sa_bo_remove_locked (:166) β†’ radeon_fence_unref (:150) β†’ dma_fence_put β†’ kref 0 β†’ dma_fence_release β†’ kfree (sys/dev/drm/linux_fence.c:43-51, radeon .release is NULL β€” radeon_fence.c:1120); or next_hole's own removal at :306. Fences on an flist normally have refcount exactly 1 because radeon_ib_free drops the IB's reference immediately after radeon_sa_bo_free takes the sa_bo's (sys/dev/drm/radeon/radeon_ib.c:95-98).
  • Consumption of the dangling pointer: radeon_sa.c:352-353 (ref β€” UAF write), :356 (wait reads ->seq β€” UAF read), :357-358 (unref β†’ refcount 0 again β†’ second kfree β€” double free).

Fence signaling is asynchronous to wq.lock (GPU IRQ advances fence_drv[ring].last_seq, radeon_fence.c:340-353/432-447), so a fence borrowed as "not signaled" in scan k can be removed as "signaled" in scan k+1 β€” this is precisely the scenario in the upstream commit message (quoted in full below).

Upstream evidence

Linux 0381ac3ca2e7 β€” "drm/radeon: Initialize fences array entries in radeon_sa_bo_next_hole" (Xiaohui Zhang, 2022-06-07):

The entries were only initialized once in radeon_sa_bo_new. If a fence wasn't signalled yet in the first radeon_sa_bo_next_hole call, but then got signalled before a later radeon_sa_bo_next_hole call, it could destroy the fence but leave its pointer in the array, resulting in use-after-free in radeon_sa_bo_new.

The patch adds fences[i] = NULL; at the top of the per-ring scan and removes the now-redundant array reset in radeon_sa_bo_new. DragonFly's copy has neither change; it does have the earlier 2016 fix (f6ff4f67cdf8, "hold reference to fences in radeon_sa_bo_new") β€” the ref loop at radeon_sa.c:352-353 β€” which is what makes the 2022 bug reach a radeon_fence_ref() call on the dangling pointer at all.

Verification performed (guest, unprivileged user maxx uid=1001)

  1. build.sh: builds rsa_base (DF logic as ported) and rsa_fixed (adds exactly fences[i] = NULL;) with the guest's base gcc 8.3 β€” build.log.
  2. run.sh / run.log: baseline prints the kfree of F1 inside the locked loop and then UAF-REF, UAF-WAIT, DOUBLE-FREE at sa.c:352/356/358; exit 42. run.2.log: fixed build β€” same fence-death timeline, zero events, exit 0. run.3.log: 100Γ— repeat each β€” 100/100 vs 100/100.
  3. PoC port fidelity notes (poc_changes): locks dropped (bug is single-threaded), linux list.h replaced by an equivalent minimal implementation, dma_fence_get/put modeled by a refcount with an explicit dead flag (NULL-guard semantics of dma_fence_get preserved β€” that guard was initially missing from the port and caused a spurious NULL-deref crash that was fixed during bring-up; see attempts).

Exploit chain (kernel, on radeon hardware β€” analysis, not executed)

  1. Unprivileged user with /dev/dri render access submits continuous command streams on β‰₯2 rings (graphics + compute/copy) sized to exhaust the shared IB sub-allocation pool, entering the radeon_sa_bo_new blocking loop.
  2. A fence borrowed by next_hole() completes on the GPU between two inner-loop scans; the same thread frees the radeon_fence (kmalloc'd M_DRM, ~96 bytes β€” radeon_fence_emit, radeon_fence.c:131) via try_free/next_hole, leaving the pointer in fences[].
  3. radeon_fence_ref() at sa.c:353 does kref_get on the freed chunk: +1 to the first word of free slab memory. If the chunk sits on the per-CPU freelist this corrupts freelist metadata; if it was reallocated (fence churn makes reuse likely) it corrupts an arbitrary victim's first field.
  4. radeon_fence_unref() at :358 drives the refcount back to 0 β†’ kfree() of the already-free chunk β†’ freelist loop/corruption β†’ cross-cache-style attacks on M_DRM allocations; with heap grooming (fence-sized M_DRM objects are attacker-churnable from DRI) this is a plausible path to controlled overlap β†’ code execution in kernel context (uid=0). On this no-SMAP/SMEP/KASLR guest model the standard slab-groom β†’ victim-object β†’ function-pointer pivot chain applies; none of it could be exercised without the GPU.

Fix

fix.diff ports the upstream fix verbatim (both hunks of 0381ac3ca2e7, lockmgr context unchanged). Validated on the port model: baseline reproduced=1, patched reproduced=0. A kernel-level single-fix rebuild was not performed: without a radeon device the changed code path cannot be entered on the guest (driver binds only to radeon hardware), so a kernel-build test would demonstrate nothing beyond compilation β€” the port validation is the meaningful one and is recorded as such (fix_status: fixed, scoped in fix_verdict).

Status classification rationale

  • Kernel path: inconclusive (guest lacks the hardware; not not_reproduced because it was never runnable, not skipped because we substituted the strongest available verification).
  • Logic-level: reproduced, deterministic, 100/100.
  • Impact field none = no kernel-level impact was observed on this guest; the assessed impact on radeon-equipped systems is High (kernel heap UAF/double-free driven by unprivileged DRI clients).

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

Validated at logic level: the -DFIXED build differs from baseline by EXACTLY the upstream one-liner (fences[i] = NULL in next_hole) and produces zero dangling-deref events where baseline produces the full UAF-ref/UAF-wait/double-free chain (100/100 runs each way). Kernel-level single-fix rebuild not performed: without a radeon GPU the modified code path cannot be entered on the audit guest, so a kernel build would only prove compilation; fix.diff applies to sys/dev/drm/radeon/radeon_sa.c cleanly at zero offset (verified with patch --dry-run).

run.log (baseline: 3 events, exit 42) vs run.2.log (fixed: 0 events, exit 0); run.3.log stress 100x; fix.diff hunks at radeon_sa.c:267 and :332.
↓ fix.diffper-fix-DF-4927

Confirmed kernel references

Detail

Exploit chain

Kernel (analysis, requires radeon HW): unprivileged DRI client exhausts the shared ring_tmp_bo IB pool with multi-ring workloads -> radeon_sa_bo_new blocking loop borrows a pending fence into fences[i] (sa.c:278) -> GPU signals it between two inner-loop scans -> same thread frees it via try_free/next_hole (sa.c:166/306 -> :150) -> radeon_fence_ref() kref_get's freed M_DRM slab memory at :353 (freelist-metadata corruption or victim-object field corruption if reused) -> radeon_fence_unref() drives refcount to 0 again at :358 -> second kfree of the same chunk (double free) -> slab-groom to victim object / function pointer -> code exec as uid 0. Not executable on this guest (no GPU). PoC-executed portion: deterministic borrow->kfree->UAF-ref->UAF-wait->double-free sequence on the ported allocator logic, plus fix validation on the same port.

Evidence (decisive lines)

run.log: '[fence F1 refcount 0 -> kfree]' followed by '*** UAF-REF ... at radeon_sa_bo_new:352 fence_ref', '*** UAF-WAIT ... :356 fence_wait_any', '*** DOUBLE-FREE ... :358 fence_unref', exit=42. run.2.log (fixed): same kfree timeline, zero events, exit=0. run.3.log: 100/100 vs 100/100. VERDICT.md: full path:line trace + upstream commit quotes. fix.diff: exact port of 0381ac3ca2e7 (applies clean, zero offset).

PoC changes

Original finding sketch did not exist (pass-2 finding discovered in this run); PoC authored from scratch as a line-faithful port of sa.c:142-374 with: minimal list.h equivalent, locks dropped (bug is single-threaded), dma_fence_get/put modeled by refcount+dead-flag. Two fidelity fixes during bring-up: (1) main initially kept an extra reference on the target fence so it never died - fixed to mirror radeon_ib_free's handover (refcount 1 on flist); (2) the ported radeon_fence_ref lacked dma_fence_get's NULL guard and crashed on NULL fences[] entries - restored the guard. GPU signaling modeled as flip-on-3rd-check, placing the signal exactly between the borrow scan and the next scan.

Verified recommended fix

Port upstream Linux 0381ac3ca2e7: add fences[i] = NULL; at the top of the per-ring scan in radeon_sa_bo_next_hole() and drop the redundant fences[] reset in radeon_sa_bo_new().

Verdict

Kernel path could not be exercised: the QEMU/KVM audit guest has no radeon GPU, so radeon_sa.c is unreachable there (status inconclusive rather than not_reproduced - it was never runnable). The bug itself is certain from source and upstream history: DragonFly carries pre-2022 radeon_sa code - it HAS the 2016 fence-ref fix (ref loop at radeon_sa.c:352-353, upstream f6ff4f67cdf8) but LACKS the 2022 fences[i]=NULL initialization in radeon_sa_bo_next_hole (upstream 0381ac3ca2e7, whose commit message describes this exact UAF). A fence borrowed uncounted at sa.c:278 is freed by the SAME thread inside the same locked loop (try_free/next_hole removal once the GPU signals it; flist fences hold refcount 1 - radeon_ib.c:95-98), and the dangling fences[] entry then reaches radeon_fence_ref (UAF write, :352-353), radeon_fence_wait_any (UAF read, :356) and radeon_fence_unref (double kfree via dma_fence_release, :357-358, linux_fence.c:43-51). A line-faithful userspace port of sa.c:142-374 run on the guest as uid=1001 reproduces the full event chain deterministically (100/100, exit 42), and adding exactly the upstream one-liner eliminates all events (100/100, exit 0) - see run.log/run.2.log/run.3.log.