Latent negative LRU bucket index in i915_gem_batch_pool_get walks cache_list[-1] and would write through the live engine pointer
Summary
n = fls(size>>PAGE_SHIFT)-1 is clamped only above; DF fls(0)==0 so size<PAGE_SIZE yields n=-1 -> cache_list[-1] aliases the live engine pointer - the walk chases a wild next and list_move_tail writes a kernel pointer through engine-as-list-head. Unreachable today (both call sites proven size>=PAGE_SIZE, invariant 1000 lines away); one relaxed gate turns it into a wild-pointer write. Lower-clamp fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5192 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.2 KB | β raw | |
| VERDICT.md | β | 6.2 KB | β raw | |
| fix.diff | β | 456 B | view raw | |
| verdict.json | β | 4.7 KB | view raw | |
| manifest.json | β | 1.6 KB | view raw |
DF-5192 β Latent negative bucket index in i915_gem_batch_pool_get (size < PAGE_SIZE)
File: sys/dev/drm/i915/i915_gem_batch_pool.c:95-98
Severity: Info (hardening / latent OOB β not reachable from any current caller)
Confidence: likely (hazard certain from code; current unreachability certain from
exhaustive call-site analysis; hence no runtime PoC exists)
What the hazard is
n = fls(size >> PAGE_SHIFT) - 1; /* line 95 */
if (n >= ARRAY_SIZE(pool->cache_list)) /* line 96: upper clamp ONLY */
n = ARRAY_SIZE(pool->cache_list) - 1;
list = &pool->cache_list[n]; /* line 98: n may be -1 */
DragonFly's fls() returns 0 for 0 (sys/cpu/x86_64/include/cpufunc.h:283-288),
so any size < PAGE_SIZE (including size == 0) yields n = -1, and
pool->cache_list[-1] is a struct-relative out-of-bounds list head:
struct i915_gem_batch_poolis{ struct intel_engine_cs *engine; struct list_head cache_list[4]; }(i915_gem_batch_pool.h:13-16), embedded instruct intel_engine_csat intel_ringbuffer.h:439, right afterpmu.sample[](intel_ringbuffer.h:431).cache_list[-1].prevaliases theenginepointer;cache_list[-1].nextaliases the 8 bytes preceding the pool (tail ofpmu.sample[]).- The walk
list_for_each_entry(obj, list, batch_pool_link)(line 100) then chases a wildnextpointer, andlist_move_tail(&obj->batch_pool_link, list)(line 142) writes:head->prev->next = obj-linktreats theenginepointer as alist_head*and stores a kernel heap pointer intoengine->pmu, then overwritespool->enginewith&obj->batch_pool_link.
That is a kernel memory-corruption primitive β if it were reachable.
Why it is NOT reachable today (the verification)
There are exactly two compiled call sites of i915_gem_batch_pool_get() in the
entire tree (verified by grep):
-
sys/dev/drm/i915/i915_gem_execbuffer.c:1153(__reloc_gpu_alloc): passes the literalPAGE_SIZEβfls(1)-1 = 0. Safe. -
sys/dev/drm/i915/i915_gem_execbuffer.c:1939(eb_parse): passesPAGE_ALIGN(eb->batch_len). This is gated byeb_use_cmdparser()(i915_gem_execbuffer.c:312-315):
c
return intel_engine_needs_cmd_parser(eb->engine) && eb->batch_len;
The && eb->batch_len term forces batch_len >= 1 (u32), so
PAGE_ALIGN(batch_len) >= 4096 β size >> PAGE_SHIFT >= 1 β
fls(...) >= 1 β n >= 0. Note the ordering in
i915_gem_do_execbuffer: the parser gate/call is at lines 2326-2333,
before the batch_len == 0 β whole-object expansion at 2351-2352, so
eb_parse always sees the raw user value β but the gate sees that same
raw value, so size == 0 cannot pass. The legacy v1 ioctl funnels into
the same i915_gem_do_execbuffer (v1βv2 conversion at :2503).
eb_parse() is called only from i915_gem_execbuffer.c:2329 under that gate;
i915_gem_batch_pool_get is declared in i915_gem_batch_pool.h and referenced
nowhere else. Therefore no userspace-reachable path passes size < PAGE_SIZE,
and no runtime PoC can exist for this hazard without first adding a new
(faulty) kernel caller. Verification was by exhaustive static call-site
proof, not by guest execution β a userspace "trigger" is impossible by
construction.
Why file it anyway
The invariant that protects the code lives ~1000 lines away in a different
file (eb_use_cmdparser in i915_gem_execbuffer.c:314) and is enforced by a
single && term with no local defense in the pool itself. Any future caller
(e.g. a new parser user, a backport that changes the gate, or the v1/v2
conversion being relaxed) turns this into an immediate wild-pointer
write/panic at best, struct corruption at worst. i915_gem_object_create_internal()
already asserts GEM_BUG_ON(!size) and page alignment
(i915_gem_internal.c:191-192) β the pool should defend its own index the same
way. Upstream ultimately deleted the batch pool entirely (Linux 5.x), which
corroborates the fragility of this API.
Fix
One-line lower clamp; see fix.diff (never applied to sys/ β audit is
read-only).
Reproduce
Not applicable β no reachable code path. This pack is the analysis record:
VERDICT.md documents the proof, verdict.json carries status: untested
with impact: none.
DF-5192 β VERDICT
Status: untested (analysis-only, by construction). Reproduced: no. Impact: none.
Bottom line
i915_gem_batch_pool_get() computes its LRU bucket index as
n = fls(size >> PAGE_SHIFT) - 1 (sys/dev/drm/i915/i915_gem_batch_pool.c:95)
and clamps only the upper bound (lines 96-97). DragonFly's fls(0) == 0
(sys/cpu/x86_64/include/cpufunc.h:283-288), so any caller passing
size < PAGE_SIZE β most degenerally size == 0 β produces n == -1 and
list = &pool->cache_list[-1] (line 98). Given the struct layout
({ engine; cache_list[4]; }, i915_gem_batch_pool.h:13-16, embedded in
struct intel_engine_cs at intel_ringbuffer.h:439 immediately after
pmu.sample[] at :431), cache_list[-1].prev aliases the live engine
pointer and .next aliases pmu tail memory. The subsequent list walk
(line 100) chases a wild pointer, and list_move_tail() (line 142) performs
head->prev->next = new β writing through the engine pointer as if it were
a list_head* β plus clobbers pool->engine. That is a textbook
struct-relative OOB write primitive.
It is not reachable from userspace on this tree. Exhaustive proof:
i915_gem_batch_pool_gethas exactly two call sites in the whole tree (grep over sys/): i915_gem_execbuffer.c:1153 and :1939.- Call site 1 (
__reloc_gpu_alloc) passes the compile-time constantPAGE_SIZEβ n = 0. Safe unconditionally. - Call site 2 (
eb_parse) passesPAGE_ALIGN(eb->batch_len)whereeb->batch_lenis the raw u32 user argument.eb_parseis invoked only from i915_gem_do_execbuffer (i915_gem_execbuffer.c:2326-2333) undereb_use_cmdparser(&eb), defined at :312-315 asintel_engine_needs_cmd_parser(eb->engine) && eb->batch_len. The&& eb->batch_lenconjunct requires batch_len β₯ 1 at the momenteb_parseruns (thebatch_len == 0 β whole-objectrewrite at :2351-2352 happens strictly after the parser block).PAGE_ALIGN(x β₯ 1) β₯ 4096βsize >> PAGE_SHIFT β₯ 1βfls β₯ 1βn β₯ 0. - The legacy v1 ioctl converts into the same execbuffer2 path (:2503), so it inherits the same gate.
- No other module references the symbol (it is not EXPORT_SYMBOL'd; the i915 Makefile/sys/conf/files build set contains no other user).
Hence: hazard certain, reachability zero, nothing to run on the guest. A
userspace trigger would require a new kernel caller that violates the size
invariant β at which point the primitive is a wild-pointer write through the
engine field, realistically a panic, conceivably struct corruption.
Why no guest run (and no false "skipped")
The finding is filed as Info/hardening precisely because the reachability
analysis kills exploitation today. The evidence pack therefore contains no
build.log/run.log by design; verdict.json records status: untested,
reproduced: false, impact: none, attempts: 0. Had this been reachable,
the appropriate classification would have been High (kernel OOB write from
ioctl-reachable path) and Phase V would have required a guest PoC.
Classes hunted alongside (all killed, for the record)
- LRU move vs detach/get race: every mutator of
pool->cache_list[]runs underdrm.struct_mutexβi915_gem_batch_pool_get(assert at i915_gem_batch_pool.c:89; callers hold via i915_mutex_lock_interruptible at i915_gem_execbuffer.c:2297) andi915_gem_batch_pool_finifrom bothintel_engine_cleanup_common(intel_engine_cs.c:671) and the GT park path__i915_gem_park(intel_engine_cs.c:1097 β i915_gem.c:175, whose caller asserts the same mutex at i915_gem.c:153). Serialized; no race. - fini with still-active objects β leak/UAF: DF's
__i915_gem_object_release_unless_active(i915_gem.c:5420-5429) converts the pool's ownership ref into an active reference for busy objects (dropped on retire); idle objects are put. Nothing in the retire/free path ever touchesbatch_pool_linkagain (tree-wide the field has exactly five references: the three in this file, the INIT at i915_gem.c:5099, and the declaration at i915_gem_object.h:129), and frees are RCU-deferred (i915_gem.c:5400-5418), so the detached-listINIT_LIST_HEADat i915_gem_batch_pool.c:64 leaves no later walker and no stale write. list_move_tailon a freshly created object:batch_pool_linkis initialized for every object byi915_gem_object_init(i915_gem.c:5092-5099), whichi915_gem_object_create_internalcalls (i915_gem_internal.c:202) β self-linked head β the move is a benign delete-self + add-tail. No uninitialized-list corruption.- fence clearing on the idle transition:
reservation_object_add_excl_fence(resv, NULL)(i915_gem_batch_pool.c:121) is NULL-safe in the vendored implementation (sys/dev/drm/linux_reservation.c:244-246if (fence) dma_fence_get(fence);); the resv cannot be freed under the walker because the pool owns the object and struct_mutex is held. - 4 GiB truncation into object creation: DF widened
i915_gem_object_create_internaltophys_addr_t sizewith anoverflows_type()guard (i915_gem_internal.c:186-195), and the bucket arithmetic itself saturates at n=3 long before any int truncation matters for the u32-bounded callers. - GEM_BUG_ON panic reachability (i915_gem_batch_pool.c:109/126): the signaled-then-retired invariant holds because request submission and this walk are both under struct_mutex; no interleaving found. (GEM_BUG_ON is a real BUG() here since CONFIG_DRM_I915_DEBUG_GEM=1, sys/dev/drm/kconfig.h:19 β worth remembering, but no trigger exists.)
- DF-2192 (object leak when
pin_pagesfails on the freshly created object, i915_gem_batch_pool.c:133-140): known from pass 1; not re-reported and not double-counted here.
Recommended fix
Clamp the lower bound where the upper bound is already clamped β see
fix.diff. One line, defense-in-depth, no behavior change for any current
caller.
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
not_testableFix validation not applicable: the bug does not manifest on any current caller (unreachable), so there is no baseline behavior to observe and no patched-kernel differential to measure. The one-line clamp is a pure defense-in-depth guard with zero behavior change for existing callers; verified to apply cleanly (git apply --check) against sys/dev/drm/i915/i915_gem_batch_pool.c without modifying sys/.
findings/poc/DF-5192/fix.diff (git apply --check OK against the read-only tree; never applied).
Confirmed kernel references
- sys/dev/drm/i915/i915_gem_batch_pool.c:95
- sys/dev/drm/i915/i915_gem_batch_pool.c:96
- sys/dev/drm/i915/i915_gem_batch_pool.c:98
- sys/dev/drm/i915/i915_gem_batch_pool.c:100
- sys/dev/drm/i915/i915_gem_batch_pool.c:142
- sys/dev/drm/i915/i915_gem_batch_pool.h:13
- sys/dev/drm/i915/intel_ringbuffer.h:439
- sys/dev/drm/i915/i915_gem_execbuffer.c:312
- sys/dev/drm/i915/i915_gem_execbuffer.c:1153
- sys/dev/drm/i915/i915_gem_execbuffer.c:1939
- sys/dev/drm/i915/i915_gem_execbuffer.c:2326
- sys/dev/drm/i915/i915_gem_execbuffer.c:2351
- sys/cpu/x86_64/include/cpufunc.h:283
Detail
Evidence (decisive lines)
findings/poc/DF-5192/VERDICT.md (full reachability proof and the killed alternative classes); README.md (hazard anatomy); fix.diff (one-line clamp, git apply --check verified against read-only sys/). Decisive citations: i915_gem_batch_pool.c:95-98 (unclamped -1 index), i915_gem_execbuffer.c:312-315 (the distant '&& eb->batch_len' guard that currently saves it), i915_gem_execbuffer.c:1153 (constant PAGE_SIZE caller), i915_gem_execbuffer.c:2326-2352 (gate ordering), cpufunc.h:283-288 (fls(0)==0).
PoC changes
No PoC exists and none was seeded: the path is unreachable from userspace on this tree (proof in VERDICT.md). Pack is analysis-only by design; no build.sh/run.sh, no guest artifacts.
Verified recommended fix
Clamp the bucket index lower bound: 'if (n < 0) n = 0;' beside the existing upper clamp at i915_gem_batch_pool.c:96-97 (see fix.diff).
Verdict
Latent struct-relative OOB in i915_gem_batch_pool_get: the LRU bucket index n = fls(size >> PAGE_SHIFT) - 1 (sys/dev/drm/i915/i915_gem_batch_pool.c:95) is clamped only from above (lines 96-97). DragonFly's fls(0)==0 (sys/cpu/x86_64/include/cpufunc.h:283-288) means any size < PAGE_SIZE yields n = -1, and &pool->cache_list[-1] (line 98) aliases the live engine pointer (struct layout: engine ptr then cache_list[4], i915_gem_batch_pool.h:13-16, embedded at intel_ringbuffer.h:439) β the walk at line 100 chases a wild next pointer and list_move_tail at line 142 writes through engine-as-list_head and clobbers pool->engine: a kernel memory-corruption primitive. However it is unreachable on this tree: the only two call sites are i915_gem_execbuffer.c:1153 (literal PAGE_SIZE, n=0) and i915_gem_execbuffer.c:1939 (PAGE_ALIGN(eb->batch_len) gated by eb_use_cmdparser()'s '&& eb->batch_len' at i915_gem_execbuffer.c:312-315, evaluated at the same point in i915_gem_do_execbuffer:2326-2333, before the batch_len==0 expansion at 2351-2352), so size >= PAGE_SIZE always. No userspace trigger can exist without first adding a new faulty kernel caller; verification was by exhaustive static call-site proof (no guest run applicable). Filed as Info hardening with a one-line lower clamp.
No comments yet.