CVE-2026-9882
Overview
Background
- `gl::IndexRange`
- ANGLE helper storing the span of element indices touched by a draw; historically held
mStartandmCountasuint32_tand derivedend()asmStart + mCount - 1. - Primitive restart
- a GL feature that reserves
UINT_MAXas a restart marker; when it is disabled,UINT_MAXbecomes a legal element index that a draw may actually dereference. - `maxElementIndex`
- the reported cap (
GL_MAX_ELEMENT_INDEX) on the largest usable element index, feeding validation of index-buffer contents. - `gl::ComputeVertexBindingElementCount`
- computes how many vertices a binding must supply from a draw’s index range, used by buffer-size/bounds validation.
Root Cause Analysis
gl::IndexRange computed mCount = end - start + 1 in 32-bit arithmetic, so when primitive restart is disabled and the maximum index end = UINT_MAX is valid, end - start + 1 wraps to 0 (for start = 0) instead of the true count UINT_MAX + 1. Downstream, gl::ComputeVertexBindingElementCount returned that wrapped drawCount unchecked and vertexCount() was a uint32_t, so buffer-size and range validation saw a tiny required vertex count rather than the enormous real one. Consequently bounds checks that should reject an under-sized vertex buffer passed, while the actual draw fetched a vertex range up to UINT_MAX + 1, producing an out-of-bounds vertex fetch. A second defect compounded this: ValidateDrawElementsCommon cast IndexRange::end to a signed int32, further corrupting the comparison.
The fix widens mCount to uint64_t, stores mEnd explicitly so end() no longer re-derives via 32-bit math, returns 64-bit counts, CheckedNumeric-casts drawCount, and caps maxElementIndex at UINT_MAX - 1 so a valid draw’s element count can never reach the 32-bit overflow boundary.
UINT_MAX as a valid element index means the count (end - start + 1) needs one more bit than the index itself, so any 32-bit count computation silently wraps at exactly the largest legal draw and turns a bounds check into a green light.Attack Path
- Disable primitive restart
Issue GL calls so
UINT_MAXis treated as a real index rather than a restart marker. - Craft index data
Supply an index buffer whose maximum element index is
UINT_MAX, yieldingend = UINT_MAX. - Overflow the count
gl::IndexRangecomputesmCount = end - start + 1in 32-bit, wrapping to0/tiny value. - Pass validation
gl::ComputeVertexBindingElementCountand buffer-range checks see the wrapped small count and accept an under-sized vertex buffer. - Trigger OOB fetch
The GPU/backend draw reads the true
UINT_MAX + 1-wide vertex range, reading out of bounds beyond the bound buffer.
Impact Assessment
UINT_MAX + 1 vertices. This yields out-of-bounds reads (and potential downstream memory corruption or information disclosure) within the GPU process from renderer-reachable content, all without any memory-safety error being surfaced to validation.Changed Functions
| Function | Change | Notes |
|---|---|---|
IndexRangesrc/common/mathutil.h |
modified |
Files Changed
src/common/mathutil.hsrc/libANGLE/Context.cppsrc/libANGLE/VertexAttribute.cppsrc/libANGLE/VertexAttribute.hsrc/libANGLE/renderer/d3d/d3d9/Renderer9.cppsrc/libANGLE/renderer/renderer_utils.cppsrc/libANGLE/validationES.hsrc/tests/gl_tests/WebGLCompatibilityTest.cpp
Audit Directions
- Count vs. index widthWherever an inclusive
end - start + 1(ormax - min + 1) count is computed, verify the count type has one more bit of range than the index type, since a maximal valid index overflows a same-width count. - Truncating casts in validationFlag casts of
IndexRange::end/counts to signed or narrower integer types (int32,uint32_t) inside draw/bounds validation; these silently corrupt comparisons at boundary values. - Feature-gated valid rangesAudit code that assumes sentinel values (like
UINT_MAXprimitive-restart) are never real data; when the gating feature is disabled the full range becomes reachable and arithmetic must tolerate the extreme.
Patch
From ff1b91d5f69e8253a5f8d7075a1253b287ebe9e2 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Mon, 27 Apr 2026 11:33:19 -0400
Subject: [PATCH] Fix overflows in IndexRange storage.
IndexRange stores mStart and mCount (instead of mEnd) as uint32_t.
mCount will overflow when the end index is UINT_MAX, this can happen
when primitive restart is disabled making UINT_MAX a valid index.
Also fix an invalid cast of IndexRange::end to a signed 32-bit integer
in ValidateDrawElementsCommon.
The test for this behaviour, WebGLCompatibilityTest.LargeIndexRange, had
a bug and did not call glVertexAttribPointer causing validation to fail
earlier due to buffer being bound to the attribute.
Also universally limit the max element index to UINT_MAX - 1 to protect
against incorrect math assuming draw count can fit in a 32-bit integer.
Fixed: chromium:504175501
Fixed: chromium:505056913
Fixed: chromium:506375217
Change-Id: I20ebd619e65801833862846a70d31138b2e576b5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7797469
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/common/mathutil.h b/src/common/mathutil.h
index 4f5b5c5..5cb2d3e 100644
--- a/src/common/mathutil.h
+++ b/src/common/mathutil.h
@@ -872,9 +872,10 @@
{};
IndexRange(Undefined) {}
IndexRange() = default;
- IndexRange(uint32_t start_, uint32_t end_) : mStart(start_), mCount(end_ - start_ + 1)
+ IndexRange(uint32_t start, uint32_t end)
+ : mStart(start), mEnd(end), mCount(static_cast<uint64_t>(end - start) + 1)
{
- ASSERT(start_ <= end_);
+ ASSERT(start <= end);
}
bool isEmpty() const { return mCount == 0; }
uint32_t start() const
@@ -885,15 +886,18 @@
uint32_t end() const
{
ASSERT(!isEmpty());
- return mStart + mCount - 1;
+ return mEnd;
}
// Number of vertices in the range.
- uint32_t vertexCount() const { return mCount; }
+ uint64_t vertexCount() const { return mCount; }
private:
uint32_t mStart{0};
- uint32_t mCount{0};
+ uint32_t mEnd{0};
+
+ // Since the range is inclusive, mCount == 0 indicates an empty range
+ uint64_t mCount{0};
};
inline bool operator==(const IndexRange &a, const IndexRange &b)
diff --git a/src/libANGLE/Context.cpp b/src/libANGLE/Context.cpp
index 5db0dda..20a1ea4 100644
--- a/src/libANGLE/Context.cpp
+++ b/src/libANGLE/Context.cpp
@@ -4429,6 +4429,11 @@
ANGLE_LIMIT_CAP(caps->maxDualSourceDrawBuffers, IMPLEMENTATION_MAX_DUAL_SOURCE_DRAW_BUFFERS);
+ // Disallow using UINT_MAX as an index. This would allow for a draw count of UINT_MAX + 1,
+ // overflowing a 32-bit integer.
+ constexpr GLint64 kMaxElementIndex = std::numeric_limits<GLuint>::max() - 1;
+ ANGLE_LIMIT_CAP(caps->maxElementIndex, kMaxElementIndex);
+
// WebGL compatibility
extensions->webglCompatibilityANGLE = mWebGLContext;
for (const auto &extensionInfo : GetExtensionInfoMap())
diff --git a/src/libANGLE/VertexAttribute.cpp b/src/libANGLE/VertexAttribute.cpp
index bd1b692..d88956c 100644
--- a/src/libANGLE/VertexAttribute.cpp
+++ b/src/libANGLE/VertexAttribute.cpp
@@ -139,7 +139,7 @@
return attrib.relativeOffset + binding.getOffset();
}
-size_t ComputeVertexBindingElementCount(GLuint divisor, size_t drawCount, size_t instanceCount)
+size_t ComputeVertexBindingElementCount(GLuint divisor, uint64_t drawCount, size_t instanceCount)
{
// For instanced rendering, we draw "instanceDrawCount" sets of "vertexDrawCount" vertices.
//
@@ -154,7 +154,9 @@
return (instanceCount + divisor - 1u) / divisor;
}
- return drawCount;
+ // Ensure that drawCount can always fit into a size_t. This should also be validated by
+ // maxElementIndex.
+ return angle::CheckedNumeric<size_t>(drawCount).ValueOrDie();
}
} // namespace gl
diff --git a/src/libANGLE/VertexAttribute.h b/src/libANGLE/VertexAttribute.h
index 40f2bb2..1b31b9b 100644
--- a/src/libANGLE/VertexAttribute.h
+++ b/src/libANGLE/VertexAttribute.h
@@ -100,7 +100,7 @@
// Warning: you should ensure binding really matches attrib.bindingIndex before using this function.
GLintptr ComputeVertexAttributeOffset(const VertexAttribute &attrib, const VertexBinding &binding);
-size_t ComputeVertexBindingElementCount(GLuint divisor, size_t drawCount, size_t instanceCount);
+size_t ComputeVertexBindingElementCount(GLuint divisor, uint64_t drawCount, size_t instanceCount);
struct VertexAttribCurrentValueData
{
diff --git a/src/libANGLE/renderer/d3d/d3d9/Renderer9.cpp b/src/libANGLE/renderer/d3d/d3d9/Renderer9.cpp
index 773ac17..cabd5ad 100644
--- a/src/libANGLE/renderer/d3d/d3d9/Renderer9.cpp
+++ b/src/libANGLE/renderer/d3d/d3d9/Renderer9.cpp
@@ -1508,7 +1508,7 @@
context, type, count, indices, context->getState().isPrimitiveRestartEnabled(),
&indexRange));
- size_t vertexCount = indexRange.vertexCount();
+ uint64_t vertexCount = indexRange.vertexCount();
ANGLE_TRY(applyVertexBuffer(context, mode, static_cast<GLsizei>(indexRange.start()),
static_cast<GLsizei>(vertexCount), instances, &indexInfo));
diff --git a/src/libANGLE/renderer/renderer_utils.cpp b/src/libANGLE/renderer/renderer_utils.cpp
index 096732d..cd0e52d 100644
--- a/src/libANGLE/renderer/renderer_utils.cpp
+++ b/src/libANGLE/renderer/renderer_utils.cpp
@@ -1613,7 +1613,15 @@
context->getState().isPrimitiveRestartEnabled(), &indexRange));
ANGLE_TRY(ComputeStartVertex(context->getImplementation(), indexRange, baseVertex,
startVertexOut));
- *vertexCountOut = indexRange.vertexCount();
+
+ // Protect against requiring 64-bits to store a draw count. Most math is done in size_t and
+ // not safe on 32-bit systems. This would require a UINT_MAX index when primitive restart is
+ // disabled.
+ uint64_t vertexCount = indexRange.vertexCount();
+ ANGLE_CHECK_GL_MATH(context->getImplementation(),
+ vertexCount <= std::numeric_limits<GLuint>::max());
+
+ *vertexCountOut = static_cast<size_t>(vertexCount);
}
else
{
diff --git a/src/libANGLE/validationES.h b/src/libANGLE/validationES.h
index 155d5f5..44c6d10 100644
--- a/src/libANGLE/validationES.h
+++ b/src/libANGLE/validationES.h
@@ -1134,7 +1134,7 @@
return false;
}
- if (!ValidateDrawAttribs(context, entryPoint, static_cast<GLint>(indexRange.end())))
+ if (!ValidateDrawAttribs(context, entryPoint, static_cast<GLint64>(indexRange.end())))
{
return false;
}
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 784ce9b5..030cb82 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -1812,8 +1812,6 @@
ANGLE_GL_PROGRAM(program, kVS, essl1_shaders::fs::Red());
glUseProgram(program);
- glEnableVertexAttribArray(glGetAttribLocation(program, "a_Position"));
-
constexpr float kVertexData[] = {
1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
};
@@ -1822,12 +1820,17 @@
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(kVertexData), kVertexData, GL_STREAM_DRAW);
+ GLuint positionLocation = glGetAttribLocation(program, "a_Position");
+ glEnableVertexAttribArray(positionLocation);
+ glVertexAttribPointer(positionLocation, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
+
constexpr GLuint kMaxIntAsGLuint = static_cast<GLuint>(std::numeric_limits<GLint>::max());
+ constexpr GLuint kMaxGLuint = std::numeric_limits<GLuint>::max();
constexpr GLuint kIndexData[] = {
+ 0,
kMaxIntAsGLuint,
kMaxIntAsGLuint + 1,
- kMaxIntAsGLuint + 2,
- kMaxIntAsGLuint + 3,
+ kMaxGLuint,
};
GLBuffer indexBuffer;
Regression Test / PoC
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index 784ce9b5..030cb82 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -1812,8 +1812,6 @@
ANGLE_GL_PROGRAM(program, kVS, essl1_shaders::fs::Red());
glUseProgram(program);
- glEnableVertexAttribArray(glGetAttribLocation(program, "a_Position"));
-
constexpr float kVertexData[] = {
1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
};
@@ -1822,12 +1820,17 @@
glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(kVertexData), kVertexData, GL_STREAM_DRAW);
+ GLuint positionLocation = glGetAttribLocation(program, "a_Position");
+ glEnableVertexAttribArray(positionLocation);
+ glVertexAttribPointer(positionLocation, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
+
constexpr GLuint kMaxIntAsGLuint = static_cast<GLuint>(std::numeric_limits<GLint>::max());
+ constexpr GLuint kMaxGLuint = std::numeric_limits<GLuint>::max();
constexpr GLuint kIndexData[] = {
+ 0,
kMaxIntAsGLuint,
kMaxIntAsGLuint + 1,
- kMaxIntAsGLuint + 2,
- kMaxIntAsGLuint + 3,
+ kMaxGLuint,
};
GLBuffer indexBuffer;
@@ -1837,11 +1840,11 @@
EXPECT_GL_NO_ERROR();
// First index is representable as 32-bit int but second is not
- glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0);
+ glDrawElements(GL_POINTS, 4, GL_UNSIGNED_INT, 0);
EXPECT_GL_ERROR(GL_INVALID_OPERATION);
// Neither index is representable as 32-bit int
- glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, reinterpret_cast<void *>(sizeof(GLuint) * 2));
+ glDrawElements(GL_POINTS, 4, GL_UNSIGNED_INT, reinterpret_cast<void *>(sizeof(GLuint) * 2));
EXPECT_GL_ERROR(GL_INVALID_OPERATION);
}
Original Bug Report
Potential integer overflow in ANGLE IndexRange bypasses software robust-buffer-access validation
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: An integer overflow in ANGLE’s IndexRange constructor can potentially cause 32-bit index ranges to bypass software robust-buffer-access validation. This could enable out-of-bounds GPU memory reads on backends lacking hardware robust access support, such as Metal on macOS.
Affected files:
third_party/angle/src/common/mathutil.hthird_party/angle/src/libANGLE/validationES.hthird_party/angle/src/common/utilities.cppthird_party/angle/src/libANGLE/Context.cppthird_party/angle/src/libANGLE/renderer/metal/DisplayMtl.mm
Estimated timestamp from git blame: 2025-04-11
Root Cause
A potential integer overflow vulnerability exists in the IndexRange constructor within third_party/angle/src/common/mathutil.h. The constructor computes the number of elements in a range using 32-bit unsigned arithmetic without overflow protection:
// third_party/angle/src/common/mathutil.h
IndexRange(uint32_t start_, uint32_t end_) : mStart(start_), mCount(end_ - start_ + 1)
{
ASSERT(start_ <= end_);
}
bool isEmpty() const { return mCount == 0; }
If the constructor is invoked with a range where start_ = 0 and end_ = 0xFFFFFFFF, the ASSERT passes, but the calculation 0xFFFFFFFF - 0 + 1 overflows the uint32_t capacity and wraps around to 0. This causes isEmpty() to return true for a range that actually encompasses all possible 32-bit indices.
Validation Bypass
This poisoned IndexRange can be produced when ANGLE scans an index buffer containing both 0 and 0xFFFFFFFF to determine bounds (via gl::ComputeIndexRange).
In the draw validation path, ANGLE implements software-based robust-access checking for backends that lack hardware support (such as Metal, where robustBufferAccessBehaviorKHR is false). This check is gated on the index range not being empty:
// third_party/angle/src/libANGLE/validationES.h:1115
// No op if there are no real indices in the index data (all are primitive restart).
if (!indexRange.isEmpty())
{
// ...
if (!ValidateDrawAttribs(context, entryPoint, static_cast<GLint>(indexRange.end())))
{
return false;
}
}
Because isEmpty() incorrectly returns true due to the overflow, the entire validation block—including index clamping and attribute bounds checking in ValidateDrawAttribs—is skipped. ValidateDrawElementsCommon subsequently returns true, permitting the draw call to proceed with unvalidated indices.
Applicability and Impact
In WebGL 1, primitive restart is disabled by default. If the OES_element_index_uint extension is enabled, an attacker can provide a GL_UNSIGNED_INT element buffer containing indices like [0, K, 0xFFFFFFFF]. This triggers the overflow, bypasses validation, and results in the GPU executing a draw call with an attacker-controlled out-of-bounds index K relative to the vertex buffer.
The fetched out-of-bounds data can be processed by an attacker-controlled vertex shader and exfiltrated via gl.readPixels(). Because the GPU process is shared across origins, this enables the potential disclosure of cross-origin GPU memory, such as data from other tabs or WebGL resources.
Potential Reproduction Steps (Theoretical)
Note: These are suggested steps; we have not yet verified them with a working Proof of Concept.
- On macOS Chrome (Metal backend), create a WebGL 1 context and enable
OES_element_index_uint. - Bind a small
ARRAY_BUFFERto a vertex attribute. - Bind an
ELEMENT_ARRAY_BUFFERpopulated with aUint32Array([0, K, 0xFFFFFFFF])whereKis a very large out-of-bounds index. - Call
gl.drawElements(gl.POINTS, 3, gl.UNSIGNED_INT, 0). - Observe that ANGLE validation passes due to the
IndexRangeoverflow, submitting the draw call to the GPU with OOB indices.
Suggested Fix
Modify the IndexRange constructor in third_party/angle/src/common/mathutil.h to use 64-bit arithmetic or a safe math wrapper (like base::CheckedNumeric) when calculating mCount to prevent the overflow. If the element count exceeds the 32-bit limit, the logic should safely handle the maximum range without wrapping to 0.
Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.