CVE-2026-74946
Overview
Background
- `WebGLBuffer`
- Gecko’s representation of a WebGL buffer object holding vertex or index (element array) data on behalf of a web page.
- `mIndexRanges`
- a per-buffer cache mapping a byte range of index data to the maximum index value it contains, so repeated draw calls can skip rescanning the buffer for bounds validation.
- `InvalidateCacheRange`
- a method that drops cached
mIndexRangesentries overlapping a region of the buffer that has just been modified, keeping the cached max-index values consistent with buffer contents. - `IndexByteSizeByType`
- a helper returning the size in bytes of a single index for a given element type (e.g. 1, 2, or 4 bytes).
Root Cause Analysis
When buffer contents are modified, WebGLBuffer::InvalidateCacheRange walks every cached entry in mIndexRanges and invalidates those that overlap the modified [updateBegin, updateEnd) byte region. The overlap test computed the cached entry’s start as rangeBegin = range.byteOffset * indexByteSize, but range.byteOffset is already a byte offset, so multiplying it by indexByteSize inflated the start to a far larger, incorrect position. The invariant that a cached range’s [rangeBegin, rangeEnd) must describe the actual bytes it covers was therefore violated, and the guard if (rangeBegin >= updateEnd || rangeEnd <= updateBegin) continue; could conclude a genuinely overlapping update did not overlap and skip invalidation.
The fix sets rangeBegin = range.byteOffset directly, so the computed span matches the real byte extent and overlapping updates once again invalidate the stale cache entry. Because the cached maximum index is used to accept draw calls without rescanning, an un-invalidated stale entry lets the buffer be validated against out-of-date index bounds.
range.byteOffset was multiplied by indexByteSize as though it were an index count, inflating the range’s start and defeating the overlap check; the fix uses the byte offset as-is so cache invalidation correctly covers modified index data.Attack Path
- Prime the cache
The page issues a draw call from an element-array buffer so Gecko records an
mIndexRangesentry capturing the range’s maximum index value. - Rewrite the indices
Script updates the buffer’s index bytes (e.g. via
bufferSubData) to contain larger, out-of-bounds index values covering the same range. - Miss the invalidation
The inflated
rangeBeginmakesInvalidateCacheRangetreat the modified region as non-overlapping, so the stale max-index entry is not dropped. - Reuse stale bounds A subsequent draw call is validated against the cached (now too-small) maximum index and is accepted despite referencing indices past the vertex data.
- Out-of-bounds access The GPU fetches vertex attributes for out-of-range indices, reading memory outside the intended vertex buffer.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
fordom/canvas/WebGLBuffer.cpp |
modified |
Files Changed
dom/canvas/WebGLBuffer.cpp
Audit Directions
- Unit confusion between offsets and countsAudit code that mixes byte offsets and element indices; verify a value already in bytes is never re-multiplied by an element size before comparison or arithmetic.
- Cache invalidation overlap testsReview every range-overlap guard used to invalidate caches and confirm both endpoints are computed in the same units as the update region they are compared against.
- Validation shortcuts using cached boundsExamine places where cached maxima or bounds let later operations skip re-validation, ensuring the cache is provably invalidated on every mutation of the underlying data.
Patch
diff --git a/dom/canvas/WebGLBuffer.cpp b/dom/canvas/WebGLBuffer.cpp
index 4ac02bd414d..357b9873269 100644
--- a/dom/canvas/WebGLBuffer.cpp
+++ b/dom/canvas/WebGLBuffer.cpp
@@ -264,7 +264,7 @@ void WebGLBuffer::InvalidateCacheRange(uint64_t byteOffset,
for (const auto& cur : mIndexRanges) {
const auto& range = cur.first;
const auto& indexByteSize = IndexByteSizeByType(range.type);
- const auto rangeBegin = range.byteOffset * indexByteSize;
+ const auto rangeBegin = range.byteOffset;
const auto rangeEnd =
rangeBegin + uint64_t(range.indexCount) * indexByteSize;
if (rangeBegin >= updateEnd || rangeEnd <= updateBegin) continue;