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)
PoC verification
Evidence pack
findings/poc/DF-4521 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
- unprivileged execbuf2 with
EXEC_OBJECT_PINNED(softpin; only alignment/canonical form is validated β i915_gem_execbuffer.c:461-463 β and it becomesPIN_OFFSET_FIXEDat :614-615) atoffset = vm->total - obj_size, conflicting with an existing vma at the top of the GGTT; i915_vma_pinβi915_gem_gtt_reserve()(i915_gem_gtt.c:3949-3975);drm_mm_reserve_node()β-ENOSPCβi915_gem_evict_for_node();- with
color_adjustset (i915_gem_gtt.c:3638-3639:!HAS_LLC && !USES_PPGTTplatforms / forcedi915_enable_ppgtt=0),end += 4K; - the iterator then:
- never terminates (livelock with
struct_mutexheld β local DoS), or - hands&mm->head_nodeto the loop body, which doesvma = 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.
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), andmid: 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:
- Sentinel leakage: the loop body receives
&mm->head_node(allocated=false,start=0x100000000 == mm end). In i915 the body doescontainer_of(node, i915_vma, node)(i915_gem_evict.c:318) β a fabricatedi915_vma*pointing intostruct 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). - Non-termination:
list_next_entry(head_node)is the first node of the circularnode_list, so the walk restarts forever. In i915 this livelocks withstruct_mutexheld (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 plainRB_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 themidnode β returns zero nodes. All overlap checks that usedrm_mm_for_each_node_in_rangeare 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)computeshole_size = next->start - prev_endacross the wrap, producing a permanent bogus hole[0x100000000, 0x10000000)of size0xffffffff10000000inholes_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_nodetheni915_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 withi915_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.totalis reachable and yieldsend = 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=dosreflects 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
fixedfix.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')
Confirmed kernel references
- sys/dev/drm/drm_mm.c:171-176
- sys/dev/drm/include/drm/drm_mm.h:483-502
- sys/dev/drm/i915/i915_gem_evict.c:292-336
- sys/dev/drm/i915/i915_gem_gtt.c:3949-3975
- sys/dev/drm/i915/i915_gem_gtt.c:3638-3639
- sys/dev/drm/i915/i915_gem_execbuffer.c:461-463
- sys/dev/drm/i915/i915_gem_execbuffer.c:614-615
- sys/dev/drm/i915/i915_vma.c:687-695
- sys/dev/drm/i915/i915_gem.h:60
- sys/dev/drm/include/linux/bug.h:45
- sys/dev/drm/include/linux/rbtree.h:101-102
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.
No comments yet.