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

drm_mm_for_each_node_in_range leaks the unallocated head_node sentinel into the caller's body and never terminates when end > mm end; i915_gem_evict_for_node's unclamped end += 4K turns it into a livelock or fake-i915_vma unbind

Summary

__drm_mm_interval_first returns &mm->head_node when nothing overlaps and the for_each_node_in_range macro iterates the circular node_list while start<end - so a caller passing end > mm end steps onto the sentinel (allocated=false, size wrapped negative), executes the body on it, wraps, and repeats forever. The header documents the hazard but enforces nothing (DRM_MM_BUG_ON compile-time-only). i915_gem_evict_for_node violates the contract: with color_adjust it unconditionally does end += 4K, so a softpin target ending at the top of the GTT gives end = vm.total+4K. REPRODUCED live on the guest with the real drm_mm.c via KLD: sentinel yielded at visit 2, non-terminating walk; the downstream remove_node(sentinel) (exactly what the container_of'ed fake vma unbind does) wipes the interval tree (range queries return zero - overlap checks blind) and plants an underflowed hole. Livelock arm = reliable GPU wedge DoS holding struct_mutex; corruption arm = list corruption + blind GGTT overlap enabling aliasing. Unpriv execbuf2 softpin on color_adjust i915 parts; core allocator under TTM - any future caller with a too-large end inherits the defect. Stop-at-sentinel + clamp-end fix, validated on guest.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4521 Β· 14 files
FileTypeDescriptionSize
dfmm_poc.c β€” 8.3 KB view raw
Makefile β€” 557 B ↓ download
run.sh β€” 170 B view raw
corrupt.sh β€” 432 B view raw
fix.diff β€” 1.6 KB view raw
build.log β€” 6.9 KB view raw
run.log β€” 922 B view raw
run.fixed.log β€” 382 B view raw
corrupt.log β€” 827 B view raw
dmesg.txt β€” 9.4 KB view raw
env.txt β€” 322 B view raw
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 5.9 KB ↓ raw
verdict.json β€” 4.6 KB view raw

DF-4521 β€” drm_mm range iterator yields the head_node sentinel and never

terminates when end > mm end

What

drm_mm_for_each_node_in_range() (macro in sys/dev/drm/include/drm/drm_mm.h:499-502, seed __drm_mm_interval_first() in sys/dev/drm/drm_mm.c:171-176) returns the unallocated head_node sentinel (allocated=false, start = mm end, size = -mm_size wrapped) into the caller's loop body whenever the query range extends past the last allocated node, and β€” because node_list is circular β€” wraps around and iterates forever when end > head_node.start.

The header itself documents the hazard (drm_mm.h:493-495: "the iterator may walk over the special unallocated &drm_mm.head_node, and may even continue indefinitely") but nothing enforces it. i915_gem_evict_for_node() (sys/dev/drm/i915/i915_gem_evict.c:292-296) violates the documented contract by unconditionally adding one guard page to end (end += I915_GTT_PAGE_SIZE) when the target node ends at the top of the GTT, so end == vm->total + 4K > head_node.start == vm->total.

Consequences (trigger chain, all lines verified in this tree)

  1. unprivileged execbuf2 with EXEC_OBJECT_PINNED (softpin; only alignment/canonical form is validated β€” i915_gem_execbuffer.c:461-463 β€” and it becomes PIN_OFFSET_FIXED at :614-615) at offset = vm->total - obj_size, conflicting with an existing vma at the top of the GGTT;
  2. i915_vma_pin β†’ i915_gem_gtt_reserve() (i915_gem_gtt.c:3949-3975); drm_mm_reserve_node() β†’ -ENOSPC β†’ i915_gem_evict_for_node();
  3. with color_adjust set (i915_gem_gtt.c:3638-3639: !HAS_LLC && !USES_PPGTT platforms / forced i915_enable_ppgtt=0), end += 4K;
  4. the iterator then: - never terminates (livelock with struct_mutex held β†’ local DoS), or - hands &mm->head_node to the loop body, which does vma = container_of(node, i915_vma, node) on kernel-owned memory (i915_gem_evict.c:318); GEM_BUG_ON(!node->allocated) (:317) is a no-op in release builds (i915_gem.h:60 β†’ BUILD_BUG_ON_INVALID); - the fake vma is pinned/list_add'ed and finally unbound: i915_vma_unbind β†’ i915_vma_remove β†’ drm_mm_remove_node(&vma->node) == drm_mm_remove_node(&mm->head_node) (i915_vma.c:687-695).

drm_mm_remove_node() on the sentinel (proven live, see corrupt.log): rm_hole() legitimately removes the head's hole, then drm_mm_interval_tree_remove() rb-erases a node that was never inserted β†’ the DFly shim's plain RB_REMOVE wipes the interval-tree root β†’ all allocated nodes become invisible to range queries (overlapping placements / GTT aliasing become possible); list_del(head) then leaves prev->next pointing at the first node so add_hole(prev) computes a size-underflowed bogus hole ([0x100000000, 0x10000000), size 0xffffffff10000000) that permanently pollutes the hole trees.

Reproduce (on the QEMU guest, root)

cd /tmp/DF-4521
sh run.sh        # scenario 1: sentinel walk β€” see dmesg / run.log
sh corrupt.sh    # scenario 2: sentinel remove + corruption probes β€” corrupt.log

The kld embeds the real, unmodified /usr/src/sys/dev/drm/drm_mm.c and the real drm_mm_for_each_node_in_range macro; it drives them with the exact parameters i915_gem_evict_for_node() would use for a top-of-GTT target (4 GiB GGTT, node at [0xfffff000, 0x100000000), walk range [0xffffe000, 0x100001000)).

Expected (baseline, unpatched)

[dfmm]   visit 1: start=fffff000 size=1000 allocated=1 color=0
[dfmm]   visit 2: start=100000000 size=ffffffff00000000 allocated=0 color=0   <== HEAD SENTINEL
[dfmm]   visit 3..8: (same nodes repeat β€” walk never terminates; capped)
[dfmm] RESULT: visited=8 head_sentinel_yielded=1 non_terminating=1

Expected (after fix.diff applied to /usr/src/sys)

[dfmm]   visit 1: start=fffff000 size=1000 allocated=1 color=0
[dfmm] RESULT: visited=1 head_sentinel_yielded=0 non_terminating=0

Notes

  • The full i915 trigger cannot run on this guest (Bochs/QEMU vga, no Intel GPU), so the i915 arm is verified by source-line tracing; the drm_mm core defect and its corruption consequences are verified live with the real kernel code compiled into a kld.
  • This behavior is inherited from Linux 4.12 (upstream drm_mm/i915 of that era are line-identical here); modern upstream i915 no longer uses this eviction path with an unclamped end.
VERDICT.md
↓ download raw

DF-4521 VERDICT

Status: reproduced (drm_mm core defect, live, with the real kernel code) Impact: dos (demonstrated) + kernel-state corruption (demonstrated); full i915 container_of/unbind chain hardware-gated on this guest.

What was run

A kld (dfmm_poc.ko) embedding the unmodified guest kernel source /usr/src/sys/dev/drm/drm_mm.c plus the real drm_mm_for_each_node_in_range macro from /usr/src/sys/dev/drm/include/drm/drm_mm.h, driven over a 4 GiB allocator (drm_mm_init(mm, 0, 1<<32)) holding:

  • top: reserved node [0xfffff000, 0x100000000) β€” a vma ending exactly at the top of the address space (vm->total), and
  • mid: reserved node [0x10000000, 0x10001000).

Scenario 1 reproduces, verbatim, the iteration i915_gem_evict_for_node() performs for a softpinned target ending at the top of the GTT with check_color: start = target.start - 4K, end = target.end + 4K (i915_gem_evict.c:292-296), i.e. walk [0xffffe000, 0x100001000).

Result (run.log, dmesg.txt)

visit 1: start=fffff000 ... allocated=1          <- real node (eviction target)
visit 2: start=100000000 size=ffffffff00000000 allocated=0  <== HEAD SENTINEL
visit 3..8: same nodes repeat
RESULT: visited=8 head_sentinel_yielded=1 non_terminating=1

Both defects of the finding are shown by the real kernel code:

  1. Sentinel leakage: the loop body receives &mm->head_node (allocated=false, start=0x100000000 == mm end). In i915 the body does container_of(node, i915_vma, node) (i915_gem_evict.c:318) β€” a fabricated i915_vma* pointing into struct i915_address_space β€” while the only guard, GEM_BUG_ON(!node->allocated) (:317), is ((void)(expr)) in release builds (i915_gem.h:60, linux/bug.h:45).
  2. Non-termination: list_next_entry(head_node) is the first node of the circular node_list, so the walk restarts forever. In i915 this livelocks with struct_mutex held (every GPU path blocks) = reliable local DoS.

Corruption arm (corrupt.log)

Scenario 2 performs what the i915 body eventually does with the fabricated vma: i915_vma_unbind() β†’ i915_vma_remove() β†’ drm_mm_remove_node(&vma->node) (i915_vma.c:687-695), i.e. drm_mm_remove_node(&mm->head_node) on the yielded sentinel. Observed with the real drm_mm.c:

  • the kernel survives (no panic β€” the writes land in the embedded drm_mm),
  • interval tree wiped: drm_mm_interval_tree_remove() rb-erases a node that was never inserted; the DFly shim's plain RB_REMOVE (include/linux/rbtree.h:101-102) sees parent==NULL/children==NULL and sets the tree root to NULL. Probe: a range query over [0x10000000,0x10001000) β€” exactly the mid node β€” returns zero nodes. All overlap checks that use drm_mm_for_each_node_in_range are now blind β†’ overlapping placements β†’ GTT aliasing of unrelated objects (memory-confidentiality/integrity impact at GPU level).
  • underflowed hole: after list_del(head), add_hole(prev_node) computes hole_size = next->start - prev_end across the wrap, producing a permanent bogus hole [0x100000000, 0x10000000) of size 0xffffffff10000000 in holes_size/holes_addr/hole_stack.

In the real driver the eviction-list path would additionally double-pin / double-unbind real vmas on the wrap-around passes (list re-adds), which is plain kernel list corruption; that arm requires the actual i915 objects and is hardware-gated here.

Why the i915 chain is real (source-traced, no hardware needed)

  • softpin acceptance: i915_gem_execbuffer.c:461-463 (alignment/canonical only, no gen gate) and :614-615 (EXEC_OBJECT_PINNED β†’ user offset | PIN_OFFSET_FIXED);
  • fixed-offset placement: i915_gem_gtt.c:3949-3975 (i915_gem_gtt_reserve: drm_mm_reserve_node then i915_gem_evict_for_node);
  • the +4K: i915_gem_evict.c:292-296 (check_color = vm->mm.color_adjust != NULL, set at i915_gem_gtt.c:3638-3639 for !HAS_LLC && !USES_PPGTT β€” e.g. gen5/gen6-era, or any no-LLC gen8+ part with i915_enable_ppgtt=0);
  • nothing reserves the top page of the GGTT in this tree (i915_gem_gtt.c:3620-3660 has no top reservation), so a target ending at vm.total is reachable and yields end = vm.total + 4K > head.start.

Bottom-of-GTT is guarded (if (start) before start -= 4K), so only the top case exists. Colors: if target->color == 0 the body continues on the sentinel and the loop still wraps (livelock); otherwise the fake vma is processed into the eviction list (corruption).

Fix validation

fix.diff (2 hunks): (1) drm_mm.h β€” the iterator now stops at the sentinel: node__ != &(mm__)->head_node && node__->start < (end__); (2) i915_gem_evict.c β€” clamp end to vm->total after the guard-page expansion. Applied to the guest's /usr/src/sys, kld rebuilt from the same real sources, same PoC re-run:

run.fixed.log: RESULT: visited=1 head_sentinel_yielded=0 non_terminating=0

Baseline bad behavior GONE; in-range walks are unaffected (the sentinel stop coincides with the old head.start >= end stop for all in-range queries).

Honest limits

  • No Intel GPU on the guest (vgapci0: chip=0x11111234, Bochs), so the end-to-end unprivileged-ioctl trigger could not be executed; the drm_mm primitives (sentinel yield, non-termination, tree wipe, underflow hole) were executed live with the real kernel code, and every i915 hop is cited to source lines above.
  • No panic occurred; impact=dos reflects the demonstrated livelock plus demonstrated kernel-state corruption (see corrupt.log for the latter).
  • Behavior is inherited from Linux-4.12-era code (line-identical upstream of that era); the finding stands for this tree regardless.

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

fix.diff applied to guest /usr/src/sys (drm_mm.h iterator sentinel stop + i915 evict end clamp), kld rebuilt from same real sources, identical PoC re-run: baseline visited=8/head_sentinel_yielded=1/non_terminating=1 vs patched visited=1/head_sentinel_yielded=0/non_terminating=0 (run.fixed.log). Bad behavior gone; in-range iteration semantics unchanged.

run.fixed.log last lines: 'visit 1: start=fffff000 ... allocated=1' then 'RESULT: visited=1 head_sentinel_yielded=0 non_terminating=0'; patch transcript in session (patch -p0: 'Hunk #1 succeeded at 492' / 'Hunk #1 succeeded at 308')
↓ fix.diffper-fix-DF-4521

Confirmed kernel references

Detail

Exploit chain

execbuf2 EXEC_OBJECT_PINNED at offset=vm.total-size (no gen gate, execbuffer.c:461) -> i915_vma_pin PIN_OFFSET_FIXED -> i915_gem_gtt_reserve (gtt.c:3949) -> drm_mm_reserve_node -ENOSPC -> i915_gem_evict_for_node with check_color end+=4K (evict.c:296) -> drm_mm_for_each_node_in_range walks past last node onto head sentinel -> (a) livelock under struct_mutex (DoS), or (b) container_of(head) fake vma (GEM_BUG_ON compiled out) -> pin/list_add -> i915_vma_unbind -> drm_mm_remove_node(head_node) -> interval tree root wiped (RB_REMOVE of never-inserted node) + underflowed hole -> blind overlap checks -> GTT aliasing / list corruption on wrap passes

Evidence (decisive lines)

run.log visits 1-8 (HEAD SENTINEL at visit 2, RESULT non_terminating=1); corrupt.log probes (mid node invisible to its own range query; bogus hole [0x100000000,0x10000000) size ffffffff10000000); run.fixed.log (visited=1, no sentinel, terminates); dmesg.txt full console

PoC changes

PoC authored fresh (no seed): kld embeds unmodified /usr/src/sys/dev/drm/drm_mm.c + real drm_mm.h macros; local RB_GENERATE(linux_root)/panic_cmp/drm_printf stubs were needed because those live in the not-loaded drm.ko; sysctl-triggered scenarios with iteration cap to make non-termination observable without hanging the guest

Verified recommended fix

Stop drm_mm_for_each_node_in_range at the head_node sentinel (drm_mm.h) and clamp i915_gem_evict_for_node's color-expanded end to vm->total

Verdict

drm_mm_for_each_node_in_range() hands the unallocated head_node sentinel to the caller's loop body and never terminates when end > mm end; reproduced live with the real kernel drm_mm.c in a kld: a top-of-GTT eviction walk (i915_gem_evict_for_node's exact parameters) yielded allocated=0 head sentinel at visit 2 and wrapped forever (visited=8 capped, non_terminating=1). The downstream i915 arm (container_of fake i915_vma -> i915_vma_unbind -> drm_mm_remove_node(sentinel)) was simulated at the drm_mm level and demonstrably wipes the interval tree (range query over an allocated node's own range returns zero nodes) and plants a size-underflowed hole (0xffffffff10000000) into the hole trees. Full unprivileged execbuf2 (EXEC_OBJECT_PINNED) trigger is source-traced hop-by-hop but hardware-gated: guest has no Intel GPU.