radeon_gart_unbind GPU-page index t desyncs from CPU-page index p when a page slot is NULL, leaving stale DMA mappings in the GART table
- File:
sys/dev/drm/radeon/radeon_gart.c - Lines: 254β265 (unbind loop)
- Severity: Low
- CVSS 3.1:
CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U:C:L/I:L/A:L - CWE: CWE-665 Improper Initialization, CWE-787 Out-of-bounds Write (stale write to wrong table index)
- Confidence: likely
- Status: new
Summary
In radeon_gart_unbind the inner loop that advances the GPU-page-table index
t (radeon_gart.c:257) is nested inside the if (rdev->gart.pages[p])
guard, but the outer for-loop always increments the CPU-page index p
(line 254).
When an unbind range contains a NULL (already-unbound) page followed by a
bound page, t falls behind p and the dummy-page PTE writes for the bound
page land at the wrong pages_entry[] index and the wrong VRAM table offset
via radeon_gart_set_page, leaving the bound page's actual GART entries
pointing at their original physical address.
radeon_gart_bind (line 302-313) does not have this bug because it
unconditionally increments t.
Root cause
radeon_gart_unbind (radeon_gart.c:254-265):
for (i = 0; i < pages; i++, p++) { /* p ALWAYS advances */
if (rdev->gart.pages[p]) { /* guard */
rdev->gart.pages[p] = NULL;
for (j = 0; j < (PAGE_SIZE / RADEON_GPU_PAGE_SIZE); j++, t++) { /* t ONLY advances inside guard */
rdev->gart.pages_entry[t] = rdev->dummy_page.entry;
if (rdev->gart.ptr)
radeon_gart_set_page(rdev, t, rdev->dummy_page.entry);
}
}
}
When pages[p] is NULL (slot already unbound), the inner j-loop that
increments t is skipped entirely. But p++ in the outer for-header still
fires. After one skipped slot, t == p - 1 (on x86-64 where
PAGE_SIZE/RADEON_GPU_PAGE_SIZE == 1, confirmed at
sys/cpu/x86_64/include/param.h:77-78 PAGE_SHIFT=12 β PAGE_SIZE=4096 ==
RADEON_GPU_PAGE_SIZE radeon.h:643).
The next non-NULL page's dummy writes go to pages_entry[p-1] (the skipped
page's entry, already dummy) while pages_entry[p] (the page actually being
unbound) is never overwritten β it retains its real DMA-address PTE.
On architectures with PAGE_SIZE > 4096 (ratio > 1) the skew is amplified
by the ratio.
Contrast radeon_gart_bind (radeon_gart.c:302-313): the j-loop and its
t++ are outside any NULL guard, so t and p always stay
synchronized.
Threat model
The stale GART entry means the GPU's IOMMU-equivalent translation for that aperture offset still points to the physical page that was supposed to be unbound. If that physical page was returned to the allocator and reused (by another BO, the page cache, or kernel slab), the GPU can DMA to/from freed or reallocated memory β a DMA-based use-after-free / cross-object corruption.
Primary reachable trigger: radeon_gart_fini (radeon_gart.c:374-379)
calling radeon_gart_unbind(rdev, 0, rdev->gart.num_cpu_pages) to unbind the
entire aperture at once. This produces a mixed NULL/non-NULL range when
any BOs remain bound at teardown (ring-buffer BOs, BOs evicted from VRAM by
radeon_bo_evict_vram in radeon_device_fini:1563 before asic->fini β
xxx_gart_fini calls radeon_gart_fini).
This path requires root privilege (module unload or shutdown). The normal
unprivileged path (radeon_ttm_backend_unbind at radeon_ttm.c:669-673)
always passes a single BO's uniform fully-bound range, so it does not
currently trigger the desync.
During teardown the GPU rings are being stopped (rdev->shutdown=true at
radeon_device_fini:1561), limiting concurrent DMA exploitation of the stale
entry, but the window between the incorrect unbind and GART hardware disable
(xxx_gart_disable, called AFTER radeon_gart_fini in e.g.
rs600_gart_fini:627-629) is non-zero.
Proof of concept
Not directly reachable from an unprivileged DRM_IOCTL_RADEON_CS path.
Requires privileged teardown with active bindings. Reproduction of the
code-level bug (proving the desync writes to the wrong index):
- Build a kernel with a radeon GPU present, load the driver.
- Create several GTT BOs (via
DRM_IOCTL_RADEON_GEM_CREATEwithRADEON_GEM_DOMAIN_GTT) to populate scattered GART slots, then destroy some to createNULLholes between bound pages. - Trigger driver unload (
kldunload radeondrmas root) βradeon_device_fini β asic fini β xxx_gart_fini β radeon_gart_fini β radeon_gart_unbind(0, num_cpu_pages). - Instrument
radeon_gart_unbind(or add aWARN_ON(t != p * (PAGE_SIZE/RADEON_GPU_PAGE_SIZE))after the outer loop) to observet < expectedafter aNULLslot.
The observable artifact: after fini, some pages_entry[t] entries still
contain a real RADEON_GART_PAGE_VALID PTE instead of
dummy_page.entry (0 on pre-init, or the dummy encoding post-init).
A simpler proof: add the invariant check
WARN_ON(t != (offset / RADEON_GPU_PAGE_SIZE) + (i+1) * (PAGE_SIZE / RADEON_GPU_PAGE_SIZE))
inside the outer loop and run any workload that produces a partially-bound
aperture at unload β the WARN fires, proving the desync.
The orchestrator can place a minimal kernel-module patch (the WARN_ON) plus
a loader.conf tunable in findings/poc/DF-NNNN/ to demonstrate on a QEMU
guest with a radeon GPU.
Recommended fix
Move the t increment outside the if-guard so t always advances by
PAGE_SIZE/RADEON_GPU_PAGE_SIZE per outer iteration, mirroring
radeon_gart_bind.
This ensures pages_entry[] and the VRAM table are written at the correct
index for every page in the range, regardless of whether the slot was
previously bound.
--- a/sys/dev/drm/radeon/radeon_gart.c
+++ b/sys/dev/drm/radeon/radeon_gart.c
@@ -251,17 +251,18 @@ void radeon_gart_unbind(struct radeon_device *rdev, unsigned offset,
WARN(1, "trying to unbind memory from uninitialized GART !\n");
return;
}
t = offset / RADEON_GPU_PAGE_SIZE;
p = t / (PAGE_SIZE / RADEON_GPU_PAGE_SIZE);
- for (i = 0; i < pages; i++, p++) {
+ for (i = 0; i < pages; i++, p++, t += (PAGE_SIZE / RADEON_GPU_PAGE_SIZE)) {
if (rdev->gart.pages[p]) {
rdev->gart.pages[p] = NULL;
- for (j = 0; j < (PAGE_SIZE / RADEON_GPU_PAGE_SIZE); j++, t++) {
+ for (j = 0; j < (PAGE_SIZE / RADEON_GPU_PAGE_SIZE); j++) {
rdev->gart.pages_entry[t + j] = rdev->dummy_page.entry;
if (rdev->gart.ptr) {
- radeon_gart_set_page(rdev, t,
+ radeon_gart_set_page(rdev, t + j,
rdev->dummy_page.entry);
}
}
}
}
This makes the outer loop increment both p and t unconditionally (matching
radeon_gart_bind's structure at radeon_gart.c:302-313), and the inner loop
indexes relative to the now-correct base t via t + j.
No behavioral change for the common case (all pages bound); only fixes the
stale-entry case when a NULL slot precedes a bound slot.
References
sys/dev/drm/radeon/radeon_gart.c:254-265βunbindloop with t-inside-guardsys/dev/drm/radeon/radeon_gart.c:302-313βbindloop with t-outside-guard (correct)sys/dev/drm/radeon/radeon_gart.c:374-379βradeon_gart_finifull-aperture unbind triggersys/cpu/x86_64/include/param.h:77-78βPAGE_SIZE=4096 == RADEON_GPU_PAGE_SIZEon x86-64sys/dev/drm/radeon/radeon.h:643βRADEON_GPU_PAGE_SIZEdefinition
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2014 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| VERDICT.md | verdict | Source verification narrative | 1.2 KB | β raw |
| fix.diff | suggested-fix | Fix: Always advance t by PAGE_SIZE/GPU_PAGE_SIZE in outer loop, regardless of pages[p | 696 B | view raw |
| build.sh | build-script | Build/validation instructions | 366 B | view raw |
| run.sh | run-script | Run instructions (HW-gated, source-only) | 184 B | view raw |
| env.txt | environment | Guest environment | 404 B | view raw |
DF-2014 - Source Verification
Verdict: REPRODUCED (source-only confirmation)
Finding: sys/dev/drm/radeon/radeon_gart.c:254-265
Mechanism: radeon_gart_unbind outer loop always increments CPU-page index p. Inner GPU-page index t only advances inside if(pages[p]) guard. NULL page β t desyncs from p β stale DMA mappings left behind.
Hardware dependency: Requires radeon GPU with GART.
Fix: Always advance t by PAGE_SIZE/GPU_PAGE_SIZE in outer loop, regardless of pages[p] NULL.
Verification method
Source-only confirmation. The cited code path was traced line-by-line in the audited sys/ tree. The bug exists exactly as described. This is a HW-gated driver finding β the vulnerable code path requires specific hardware (GPU, controller, PHY, TPM, etc.) not present in the QEMU audit guest. Runtime reproduction on this guest is not possible without the hardware.
Fix validation
fix.diff authored and applied to guest source. All 40 fixes in this batch
compile cleanly in a single combined kernel build: make -j6 nativekernel
KERNCONF=X86_64_GENERIC β rc=0, zero -Werror violations.
Kernel: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026
Fix verification
not_testablenot_testable: HW-gated. fix.diff applies + compiles in batch build (rc=0 -Werror). Source trace confirms fix closes the path.
Batch build: 40 fix.diffs applied, make nativekernel β rc=0 -Werror. Bug at sys/dev/drm/radeon/radeon_gart.c:254-265 source-confirmed.
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- r
- a
- d
- e
- o
- n
- /
- r
- a
- d
- e
- o
- n
- _
- g
- a
- r
- t
- .
- c
- :
- 2
- 5
- 4
- -
- 2
- 6
- 5
Detail
Exploit chain
none
Evidence (decisive lines)
Source trace sys/dev/drm/radeon/radeon_gart.c:254-265. HW-gated (no HW in QEMU). Fix compiles in batch build rc=0.
PoC changes
Evidence pack: VERDICT.md, fix.diff, manifest.json. Fix: t desyncs from p β stale DMA mappings. Always advance t.
Verified recommended fix
See fix.diff. t desyncs from p β stale DMA mappings. Always advance t.
Verdict
REPRODUCED (source-only). sys/dev/drm/radeon/radeon_gart.c:254-265: t desyncs from p β stale DMA mappings. Always advance t.
No comments yet.