Medium watch ✔︎ verified CVSS 8.8 chrome Integer Overflow 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in ANGLE
DescriptionInteger overflow in ANGLE
ComponentANGLE
Sub-componentInternals>GPU>ANGLE
Bug ClassInteger Overflow
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker505056913
Fix commitff1b91d5f69e (angle/angle) +39/-17
CWECWE-472
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedMufeed VH from Winfunc Research (winfunc.com)
Reported2026-04-21 · bounty $2000 (per vendor advisory)
Fix landed2026-04-29 (public git)
Disclosed2026-05-27
Patch gap28 days — fix public in source before the advisory

Background

ANGLE
the layer translating WebGL/OpenGL ES calls onto native GPU backends (here Metal); this fix lives in the angle/angle repository.
`IndexRange`
the ANGLE struct (src/common/mathutil.h) describing the inclusive [start(), end()] range of vertex indices an indexed draw references.
`ValidateDrawAttribs`
the validator that rejects a draw when the maximum referenced index exceeds the bound vertex-buffer limits.
`maxElementIndex`
the capability bounding the largest legal element index.

Root Cause Analysis

In the pre-fix indexed-draw validator, after the 64-bit element-index comparison the maximum index was narrowed to a signed 32-bit GLint before the attribute-bounds check: ValidateDrawAttribs(context, entryPoint, static_cast<GLint>(indexRange.end())) in src/libANGLE/validationES.h (quoted in the report and in the diff). When indexRange.end() exceeded INT_MAX, that cast produced a negative GLint, so the subsequent vertex-buffer-limit check was defeated and an out-of-bounds index was accepted. Separately, IndexRange stored mStart and mCount as uint32_t and computed mCount = end_ - start_ + 1 (src/common/mathutil.h:875), which overflows to 0 when end == UINT_MAX, with vertexCount() returning a 32-bit count (:892). The commit message attributes the signed-cast defect to ValidateDrawElementsCommon.

The fix changes the validator cast to static_cast<GLint64>(indexRange.end()) so the full value is range-checked; reworks IndexRange to store an explicit mEnd and a 64-bit mCount/vertexCount(); caps caps->maxElementIndex at UINT_MAX - 1 in src/libANGLE/Context.cpp; and widens draw-count handling in ComputeVertexBindingElementCount (src/libANGLE/VertexAttribute.cpp:142) and in src/libANGLE/renderer/renderer_utils.cpp (adding an ANGLE_CHECK_GL_MATH that the 64-bit count fits in GLuint before narrowing). Together these stop the signed truncation and the 32-bit count overflow.

Key insight
The validator truncated a 64-bit maximum element index to signed GLint, letting indices above INT_MAX wrap negative and slip past the vertex-buffer bounds check; the fix validates the value as GLint64 and removes the related 32-bit IndexRange count overflow.

Attack Path

  1. Create a one-vertex buffer A WebGL page binds an ARRAY_BUFFER with a single vertex and sets the attribute pointer.
  2. Upload an oversized index An UNSIGNED_INT element index of 0x80000000 (above INT_MAX) is placed in the index buffer.
  3. Issue an indexed draw drawElements is called; pre-fix the maximum index was cast to a negative GLint in ValidateDrawAttribs (src/libANGLE/validationES.h), so the bounds check passed.
  4. Out-of-bounds vertex fetch The accepted draw references vertex data far past the one-vertex buffer; the report observed acceptance on the ANGLE Metal backend. (inferred — backend fetch behavior is not in the shown source.)

Impact Assessment

The advisory records an integer overflow in ANGLE, medium severity, not in the wild. On backends where maxElementIndex can exceed INT_MAX (the reporter cites ANGLE Metal), a WebGL page could get an out-of-bounds indexed draw accepted, leading to out-of-bounds vertex fetches during GPU-process draw execution; broader memory-corruption consequences are (inferred).

Changed Functions

FunctionChangeNotes
IndexRange
src/common/mathutil.h
modified

Files Changed

  • src/common/mathutil.h
  • src/libANGLE/Context.cpp
  • src/libANGLE/VertexAttribute.cpp
  • src/libANGLE/VertexAttribute.h
  • src/libANGLE/renderer/d3d/d3d9/Renderer9.cpp
  • src/libANGLE/renderer/renderer_utils.cpp
  • src/libANGLE/validationES.h
  • src/tests/gl_tests/WebGLCompatibilityTest.cpp

Audit Directions

  • Signed narrowing before bounds checks
    Flag static_cast<GLint>/int narrowing of 64-bit index or size values that then feed a bounds comparison.
  • Inclusive-range count math
    Audit end - start + 1 counts stored in 32-bit types for overflow at the type’s maximum (e.g. UINT_MAX).
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;
Loading diff…

Regression Test / PoC

shipped with the fix
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);
 }
Loading diff…

Original Bug Report

reported by mu...@winfunc.com

WebGL indexed-draw validation truncation on ANGLE Metal allows out-of-bounds vertex fetches

Security Bug

VULNERABILITY DETAILS

ANGLE’s indexed-draw validator narrows the computed unsigned maximum element index to signed GLint before enforcing vertex-buffer bounds:

if (!ValidateDrawAttribs(context, entryPoint, static_cast<GLint>(indexRange.end())))
{
    return false;
}

That cast appears in third_party/angle/src/libANGLE/validationES.h in the source tree and is reachable after the correct 64-bit maxElementIndex comparison. On backends where maxElementIndex can exceed INT_MAX and WebGL relies on ANGLE’s manual buffer-access validation, indices above INT_MAX can wrap negative at this point and bypass the attribute-limit rejection.

The attached browser PoC creates:

  • one vec4 vertex in an ARRAY_BUFFER
  • one UNSIGNED_INT element index equal to 0x80000000
  • a normal drawElements(gl.POINTS, 1, gl.UNSIGNED_INT, 0) call

On the Metal-backed browser configuration below, the draw is accepted and returns gl.getError() = 0x0, even though the page references a vertex index far beyond the end of the one-vertex buffer and WebGL should reject the call with GL_INVALID_OPERATION.

This run did not produce a visible non-black pixel in the simple point-draw shader, but that does not weaken the validation result: the security bug here is that ANGLE accepts the out-of-bounds indexed draw at all.

VERSION

Chrome Version: Chromium 149.0.7805.0 + dev (local ASan build) Source Revision: 9b1af1c3bfa10271c6f92691e32659acea5f941c

Operating System: macOS 26.1 arm64

GPU / renderer: Apple M3 Pro / ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro, Unspecified Version)

REPRODUCTION CASE

Attachments:

  • poc.html
  • chrome_parent_metal_index_oob_asan_latest.log
  • chrome_parent_metal_index_oob_ramp16.log
  • chrome_parent_metal_index_oob.log (active-release Chrome confirmation)

Numbered repro steps:

  1. Serve the attached PoC locally:
python3 -m http.server 8001
  1. Run Chromium directly with ANGLE Metal forced:
/Volumes/BOX/winfunc/winfunc_artifacts/TARGETS/chromium-index-oob-latest/out/asan_index_oob/Chromium.app/Contents/MacOS/Chromium \
  --user-data-dir=/tmp/chrome-index-oob \
  --no-first-run \
  --disable-background-networking \
  --disable-default-apps \
  --disable-sync \
  --metrics-recording-only \
  --enable-logging=stderr \
  --ignore-gpu-blocklist \
  --use-gl=angle \
  --use-angle=metal \
  http://127.0.0.1:8001/poc.html

Observed result:

[poc] UNMASKED_RENDERER_WEBGL=ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro, Unspecified Version)
[poc] uploaded index=0x80000000 count=16 mode=ramp
[poc] gl.getError()=0x0
[poc] sum(rgb)=0 first16=0,0,0,255,...

The accepted draw is the proof. A correct WebGL implementation should reject this call before draw submission because the element index references vertex data far outside the bound attribute buffer.

This latest-build ASan browser run did not itself produce a sanitizer report under ASAN_OPTIONS=detect_leaks=0:abort_on_error=1:symbolize=1:log_path=...; no index-oob-asan-run* log files were emitted. The bug still manifests as invalid draw acceptance on the current ASan build, which is consistent with the out-of-bounds fetch occurring in backend GPU execution rather than in a CPU-side memory access path directly observable to ASan.

I also retained an active-release browser proof and the stronger 16-index variant:

[poc] uploaded index=0x80000000 count=16 mode=ramp
[poc] gl.getError()=0x0
[poc] sum(rgb)=0 ...

So on this Metal configuration the bug is not just a one-off acceptance of a single invalid index; the draw remains accepted under a denser invalid index pattern as well.

Type of crash: N/A (out-of-bounds indexed draw acceptance; no crash required for proof)

Crash State:

N/A

Client ID (if relevant): N/A

CREDIT INFORMATION

Reporter credit: Mufeed VH from Winfunc Research (winfunc.com)

View on issue tracker