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

amdgpu_vram_mgr_new: non-power-of-two pages_per_node breaks the num_nodes bound β€” one-struct heap OOB write past the kvmalloc_array node array, unprivileged via GEM_CREATE in.alignment

Summary

num_nodes=DIV_ROUND_UP(num_pages,pages_per_node) and the rounddown_pow_of_two split both assume p2 pages_per_node, but mem->page_alignment is user-steerable via AMDGPU_GEM_CREATE in.alignment (only clamped < PAGE_SIZE) and never p2-validated: 3MB alignment -> pages_per_node=768, num_nodes=1, loop 2 writes nodes[1] - a full 184-byte drm_mm_node OOB, linked into the live mm (and removed again by _del - second OOB write). VERIFIED IN KERNEL SPACE on a drm-enabled custom guest kernel: verbatim-loop kld harness wrote start=768 size=256 allocated=1 mm=<kptr> past a 1-element array and clobbered a sprayed neighbor across 13 words incl. kernel pointers; fix loop stayed in bounds. Render-node reachable (DRM_AUTH|DRM_RENDER_ALLOW) on every AMD card with >=4MB VRAM. p2-clamp fix compile+behavior validated (amdgpu.ko relinked -Werror).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5008 Β· 13 files
FileTypeDescriptionSize
kernel/vram_oob_harness.c β€” 12.0 KB view raw
kernel/Makefile β€” 477 B ↓ download
model/vram_oob_model.c β€” 7.7 KB view raw
build.sh β€” 158 B view raw
run.sh β€” 136 B view raw
run.log β€” 2.2 KB view raw
model_run.log β€” 8.4 KB view raw
build.log β€” 1.2 KB view raw
fix_build.log β€” 1.2 MB ↓ download
dmesg.txt β€” 8.5 KB view raw
env.txt β€” 408 B view raw
fix.diff β€” 971 B view raw
VERDICT.md β€” 4.8 KB ↓ raw
VERDICT.md
↓ download raw

DF-5008 Verification Verdict

Finding: amdgpu_vram_mgr_new() nodes[] out-of-bounds write via non-power-of-two mem->page_alignment Severity: High Β· Impact class: kernel heap OOB write (memcorrupt) Verification method: in-kernel harness executing the verbatim driver loop against the real in-kernel drm_mm + kvmalloc on a custom drm-enabled guest kernel, plus a userspace arithmetic sweep. The amdgpu entry point is hardware-gated (no AMD GPU in QEMU; amdgpu binds only on real ASICs), but every instruction of the defect β€” the bound computation and both insert loops β€” ran unmodified in kernel space.

Verdict: REPRODUCED (kernel-space primitive; hardware-gated entry)

How the chain was proven

  1. Reachability of the attacker input (static, path:line-exact): AMDGPU_GEM_CREATE is unprivileged on render nodes (amdgpu_kms.c:1197 DRM_AUTH|DRM_RENDER_ALLOW); args->in.alignment flows amdgpu_gem.c:260 β†’ amdgpu_gem.c:58 (no power-of-two check β€” the only clamp is < PAGE_SIZE) β†’ amdgpu_object.c:434 (roundup(3MB, PAGE_SIZE) >> PAGE_SHIFT = 768, non-p2 preserved) β†’ ttm_bo.c:1247 β†’ ttm_bo.c:1099 β†’ amdgpu_vram_mgr.c:183-185 (pages_per_node = max(512, 768) = 768, non-p2) β†’ num_nodes = DIV_ROUND_UP(768, 768) = 1 β†’ loop 1 (:201-215) inserts nodes[0] with rounddown_pow_of_two(768) = 512 pages; pages_left = 256 β†’ loop 2 (:217-235) inserts &nodes[1] β€” out of bounds.

  2. The sink really writes there (kernel, real drm_mm): S1 β€” the real drm_mm_insert_node_in_range() wrote a live node at nodes[1] of a 1-element kvmalloc_array (run.log): start=768 size=256 allocated=1 color=0 mm=0xfffff8011845f668 hole_size=1047552. sizeof(struct drm_mm_node) = 184 on this build, so this is a 184-byte write starting at byte offset 184 of a 184-byte allocation.

  3. It corrupts foreign objects (kernel, groomed): S2 β€” with the exact-size array allocated into a hole between sprayed M_DRM neighbors, the OOB node clobbered neighboring object #17 across 13 machine words, including kernel text/heap pointers (0xfffff8008da72040) and partial-word residue (0xaa55aa5500000001) β€” cross-object heap corruption in a stock kernel allocator zone (run.log).

  4. The arithmetic is general (userspace model): every non-p2 alignment

    2 MB (i.e. exceeding default amdgpu_vram_page_split = 512 pages) with size == alignment overflows by exactly one node; every p2 alignment is safe; alignments ≀ 2 MB are absorbed by the p2 default split (model_run.log). Loop 2 provably runs ≀ 1 iteration (its body zeroes pages_left), so the overflow is exactly one node β€” 184 bytes β€” past the array, linked into the live mm.

  5. Amplitude/trigger characterization: first allocation on a fresh VRAM mm succeeds deterministically (hole starts at page 0; 0 % 768 == 0; second insert lands at 768..1024 β€” needs only β‰₯ 4 MB VRAM). Repeatable per ioctl; content semi-controlled (start/size attacker-steerable, kernel pointers in the rest); the OOB node stays linked in mgr->mm lists until amdgpu_vram_mgr_del() removes it (a second OOB write of the same struct) and kvfrees the array.

Why not uid=0 / panic on this guest

amdgpu.ko builds cleanly as a loadable module on DragonFly (confirmed: fix_build.log/DRMTEST build compiled the whole driver, rc=0) but only binds on AMD ASICs; the QEMU guest has none (std-VGA), so the ioctl→driver path cannot be entered here. Escalation to uid=0 on real hardware is the expected outcome class for a repeatable 184-byte semi-controlled kernel-heap overflow with linked-list/rbtree kernel-pointer writes (classic slab-groom → victim object → credential overwrite), but was not demonstrated — stated explicitly rather than claimed.

Fix validation

  • fix.diff clamps pages_per_node to a power of two (rounddown), which restores the loop-1 β‰₯ pages_per_node invariant so num_nodes is again a true upper bound (loop 2 ≀ 1 node, proven).
  • Behavioral: harness S3 ran the fixed loop with the real drm_mm β€” no write beyond num_nodes (run.log SUMMARY S3=0), while the unfixed loop (S1/S2) corrupts (S1=1 S2=1).
  • Compile: fix applied inside the guest's /usr/src copy; the amdgpu module rebuilt with -Werror, rc=0 (fix_build.log: amdgpu_vram_mgr.o line 149, amdgpu.ko relink line 781). The workspace sys/ tree was not modified.
  • Guest reverted to the clean with-src snapshot afterwards.

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

Fixed. Behavioral: harness S3 ran the patched loop (rounddown_pow_of_two clamp) against the real drm_mm with identical attacker params - no write beyond num_nodes (run.log SUMMARY S3=0) while baseline S1/S2 corrupt (S1=1 S2=1). Compile: fix.diff applied to the guest's /usr/src copy, amdgpu_vram_mgr.o rebuilt and amdgpu.ko relinked under -Werror rc=0 (fix_build.log). Workspace sys/ untouched; guest reverted afterwards.

fix_run.log (S3 clean, SUMMARY S1=1 S2=1 S3=0); fix_build.log line 149 ('--- amdgpu_vram_mgr.o ---' recompiled) and line 781 (amdgpu.ko relink); fix.diff (clamps pages_per_node at amdgpu_vram_mgr.c:197)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 X86_64_DRMTEST (harness S1/S2 baseline on same kernel) + guest /usr/src amdgpu module rebuild

Confirmed kernel references

Detail

Exploit chain

unpriv user opens /dev/dri/renderD* -> AMDGPU_GEM_CREATE{size=3MB, alignment=3MB(non-p2), domains=VRAM} -> amdgpu_gem.c:58 (no p2 validation) -> amdgpu_object.c:434 (768 pages) -> ttm_bo.c:1247,1099 -> amdgpu_vram_mgr.c:185 (pages_per_node=768 non-p2, num_nodes=1) -> loop1 nodes[0]=512p@0, loop2 nodes[1]=256p@768 = 184-byte heap OOB write past kvmalloc_array(1,184), OOB struct linked into live drm_mm (drm_mm.c:556-570); del() writes it again then kvfree. On amdgpu hw: repeatable semi-controlled heap overflow (start/size steerable, kernel ptrs) -> slab groom -> victim object -> uid0 (not demonstrated: guest has no AMD GPU).

Evidence (decisive lines)

run.log: 'S1 ... [!!] 1 node-struct(s) written past the 1-element allocation; nodes[1]: start=768 size=256 allocated=1 ... mm=0xfffff8011845f668'; 'S2 ... neighbor 17 word 1: fffff8008da72040 != magic (CLOBBERED)' (13 words); 'SUMMARY S1=1 S2=1 S3=0'. fix_run.log: S3 fixed loop clean. fix_build.log:149,781 patched amdgpu_vram_mgr.o rebuilt + amdgpu.ko relinked rc=0 -Werror. model_run.log: sweep (non-p2>2MB => +1 node OOB, p2 => safe). env.txt: uname/kldstat.

PoC changes

No seed PoC existed (finding new in pass 2). Harness adaptations of the verbatim loop are marked [ADAPT] in kernel/vram_oob_harness.c and none touch the bound math; model PoC needed a u32 typedef fix and a %llu cast fix after the first guest compile.

Verified recommended fix

amdgpu_vram_mgr.c: clamp pages_per_node = rounddown_pow_of_two(pages_per_node) before DIV_ROUND_UP (fix.diff); companion: reject non-p2 in.alignment in amdgpu_gem.c amdgpu_gem_object_create()

Verdict

REPRODUCED at the primitive level in kernel space. The AMDGPU_GEM_CREATE ioctl accepts a non-power-of-two in.alignment (amdgpu_gem.c:58 only clamps <PAGE_SIZE); a 3MB alignment on a 3MB VRAM BO yields pages_per_node=768 (non-p2, amdgpu_vram_mgr.c:183-185) and num_nodes=DIV_ROUND_UP(768,768)=1, but loop 1 splits rounddown_pow_of_two(768)=512 so loop 2 inserts nodes[1] out of bounds. On a drm-enabled guest kernel (GENERIC+device drm), a kld harness running the verbatim loop with the real drm_mm_insert_node_in_range/kvmalloc demonstrated: S1 a full struct drm_mm_node (184B: mm ptr, start=768, size=256, list/rb pointers) written at byte offset 184 of the 184-byte allocation and linked into the live mm; S2 the exact-size array groomed between sprayed M_DRM neighbors clobbered a foreign neighbor object across 13 words including kernel pointer 0xfffff8008da72040; S3 the fix.diff loop (rounddown_pow_of_two(pages_per_node)) stayed within bounds. Unprivileged render-node reachability is certain from source (DRM_AUTH|DRM_RENDER_ALLOW, amdgpu_kms.c:1197); uid0 escalation on real amdgpu hardware is the expected class for this primitive but was not demonstrable on the GPU-less guest.