Overview

High
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker511737097
Fix commitca1dbd0b011c (angle/angle) +148/-7
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-05-14 (public git)
Disclosed2026-06-30
Patch gap47 days — fix public in source before the advisory

Background

Primitive restart
an OpenGL ES feature where a reserved index value (e.g. 0xFF, 0xFFFF) inside the element array marks a break between primitives so one draw call can span multiple disconnected strips/fans.
Index conversion pool
ANGLE’s Metal backend converts GL_UNSIGNED_BYTE indices into GL_UNSIGNED_SHORT and stores the result in a shared buffer, so the converted data begins at a non-zero sub-allocation offset within that pool.
`DrawCommandRange`
a {indexCount, offset} pair describing one sub-draw; VertexArrayMtl::getDrawIndices splits a restart-enabled draw into a list of these ranges.
`getRestartIndices`
BufferMtl::getRestartIndices returns restart-boundary positions as absolute element indices relative to the start of the original GL element buffer, not relative to any pool sub-allocation.

Root Cause Analysis

The vulnerable path is VertexArrayMtl::getDrawIndices, which splits a primitive-restart draw into DrawCommandRange sub-draws by walking the restart boundaries from BufferMtl::getRestartIndices. Those boundaries are absolute element indices measured from the start of the original GL buffer, but the code computed currentIndexOffset (and its baseOffset snapshot) as offset / indexTypeBytes, where offset is the byte position of the converted data inside the shared GL_UNSIGNED_SHORT pool. When a non-zero pool sub-allocation offset was present, this made currentIndexOffset far larger than the restart positions it was compared against, so the invariant that the walking offset and the restart ranges share a common origin was violated. The resulting mis-alignment produced incorrect indexCount/offset splits whose byte ranges could reach outside the intended index data, yielding an out-of-bounds read.

The fix separates the two frames of reference: it threads the original GL offset (or client pointer) in as originalOffsetOrClientPtr, derives startIndex from that original offset when a real element buffer is bound (and from offsetInBytes for client data), and uses startIndex as the walk origin while still emitting device buffer offsets from offsetInBytes. This realigns the split logic with the absolute restart indices so the computed sub-draw ranges stay within bounds.

Key insight
The single mistake was conflating a converted-pool byte offset with the origin of restart indices that are absolute to the original GL buffer; the fix distinguishes the “where the indices live in the device buffer” offset (offsetInBytes) from the “index of the first element within the original buffer” origin (startIndex) computed from originalOffsetOrClientPtr.

Attack Path

  1. Enable primitive restart with byte indices An attacker (e.g. WebGL content) issues a draw using GL_UNSIGNED_BYTE indices with GL_PRIMITIVE_RESTART enabled, forcing ANGLE’s Metal backend to convert the indices into a shared GL_UNSIGNED_SHORT pool.
  2. Land at a non-zero pool offset By driving conversions so the converted data is sub-allocated at a non-zero offset within the shared pool, the attacker makes currentIndexOffset = offset / indexTypeBytes diverge from the absolute restart positions returned by BufferMtl::getRestartIndices.
  3. Trigger mis-aligned draw splitting VertexArrayMtl::getDrawIndices walks the restart ranges against the inflated currentIndexOffset, producing DrawCommandRange entries whose indexCount/offset no longer match the valid converted index region.
  4. Out-of-bounds read of index data The mis-computed sub-draw ranges cause the GPU/backend to read index bytes outside the intended buffer region, matching the advisory’s “Out of bounds read in ANGLE.” (inferred)
  5. Leak or corrupt render state Data read from out-of-bounds index memory can influence rendering, potentially exposing adjacent buffer contents; any escalation beyond the read is not demonstrated by the patch. (inferred)

Impact Assessment

An attacker who can submit crafted primitive-restart draws with GL_UNSIGNED_BYTE indices (reachable from untrusted WebGL/ANGLE clients) can cause an out-of-bounds read in ANGLE running in the GPU process, with the precondition that primitive restart is enabled and the converted indices land at a non-zero shared-pool offset. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Any use of the leaked data for information disclosure or further memory-corruption escalation is (inferred) and not established by the patch itself.

Files Changed

  • src/libANGLE/renderer/metal/ContextMtl.mm
  • src/libANGLE/renderer/metal/VertexArrayMtl.h
  • src/libANGLE/renderer/metal/VertexArrayMtl.mm
  • src/tests/gl_tests/StateChangeTest.cpp

Audit Directions

  • Offset frame-of-reference mismatches
    Audit any code that mixes a converted/pooled buffer byte offset with element positions derived from the original GL buffer (restart indices, index ranges), since dividing a pool offset by type size does not yield an absolute element index.
  • Index conversion sub-allocations
    Review other backends and paths that convert GL_UNSIGNED_BYTE/GL_UNSIGNED_SHORT indices into shared pools with non-zero offsets, checking that draw-splitting math stays relative to the correct origin.
  • Primitive-restart draw splitting
    Scrutinize getRestartIndices/getRestartIndicesFromClientData consumers for consistent client-vs-buffer offset handling and for bounds validation of the emitted DrawCommandRange ranges.
From ca1dbd0b011ca3eeaae3a280bdd79cc8e22d211e Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <lehoangquyen@chromium.org>
Date: Tue, 12 May 2026 23:40:16 +0800
Subject: [PATCH] Metal: fix primitive restart with converted indices

When using GL_UNSIGNED_BYTE indices, ANGLE's Metal backend converts
them to GL_UNSIGNED_SHORT in a shared pool.
VertexArrayMtl::getDrawIndices calculates currentIndexOffset by dividing
the pool offset by the index type size, making it relative to the start
of the pool sub-allocation. However, restart ranges from
BufferMtl::getRestartIndices are absolute element indices relative to
the original GL buffer. This mismatch causes incorrect draw splitting
when a non-zero pool offset is present, as currentIndexOffset is
artificially inflated.

Bug: angleproject:511737097
Change-Id: I80e2d0d20d38376aa912c851954701fac510ea46
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7839560
Reviewed-by: Kenneth Russell <kbr@chromium.org>
Commit-Queue: Quyen Le <lehoangquyen@google.com>
---

diff --git a/src/libANGLE/renderer/metal/ContextMtl.mm b/src/libANGLE/renderer/metal/ContextMtl.mm
index 21f9753..3fb5453 100644
--- a/src/libANGLE/renderer/metal/ContextMtl.mm
+++ b/src/libANGLE/renderer/metal/ContextMtl.mm
@@ -804,7 +804,7 @@
     // as drawIdxBuffer.
     const std::vector<DrawCommandRange> drawCommands =
         mVertexArray->getDrawIndices(context, type, convertedType, originalMode, mode, idxBuffer,
-                                     (uint32_t)count, convertedOffset);
+                                     (uint32_t)count, indices, convertedOffset);
 
     bool isNoOp = false;
     ANGLE_TRY(setupDraw(context, 0, count, instances, type, indices, false, &isNoOp));
diff --git a/src/libANGLE/renderer/metal/VertexArrayMtl.h b/src/libANGLE/renderer/metal/VertexArrayMtl.h
index f08ff02..66aa2bc 100644
--- a/src/libANGLE/renderer/metal/VertexArrayMtl.h
+++ b/src/libANGLE/renderer/metal/VertexArrayMtl.h
@@ -69,7 +69,8 @@
                                                  gl::PrimitiveMode mode,
                                                  mtl::BufferRef idxBuffer,
                                                  uint32_t indexCount,
-                                                 size_t offset);
+                                                 const void *originalOffsetOrClientPtr,
+                                                 size_t offsetInBytes);
 
   private:
     void reset(ContextMtl *context);
diff --git a/src/libANGLE/renderer/metal/VertexArrayMtl.mm b/src/libANGLE/renderer/metal/VertexArrayMtl.mm
index 7f103f0..3c1baf6 100644
--- a/src/libANGLE/renderer/metal/VertexArrayMtl.mm
+++ b/src/libANGLE/renderer/metal/VertexArrayMtl.mm
@@ -747,7 +747,8 @@
                                                              gl::PrimitiveMode mode,
                                                              mtl::BufferRef clientBuffer,
                                                              uint32_t indexCount,
-                                                             size_t offset)
+                                                             const void *originalOffsetOrClientPtr,
+                                                             size_t offsetInBytes)
 {
     ContextMtl *contextMtl = mtl::GetImpl(glContext);
     std::vector<DrawCommandRange> drawCommands;
@@ -759,23 +760,31 @@
     bool indicesRewritten = (originalMode != mode);
     if ((!isSimpleType && !indicesRewritten) || !glContext->getState().isPrimitiveRestartEnabled())
     {
-        drawCommands.push_back({indexCount, offset});
+        drawCommands.push_back({indexCount, offsetInBytes});
         return drawCommands;
     }
 
     const std::vector<IndexRange> *restartIndices;
     std::vector<IndexRange> clientIndexRange;
     const gl::Buffer *glElementArrayBuffer = getElementArrayBuffer();
+    size_t startIndex;
     if (glElementArrayBuffer)
     {
         BufferMtl *idxBuffer = mtl::GetImpl(glElementArrayBuffer);
         restartIndices       = &idxBuffer->getRestartIndices(contextMtl, originalIndexType);
+        // restartIndices is relative to the original element buffer, hence we use the original
+        // offset to calculate startIndex.
+        size_t originalOffsetInBytes = reinterpret_cast<size_t>(originalOffsetOrClientPtr);
+        startIndex = originalOffsetInBytes / gl::GetDrawElementsTypeSize(originalIndexType);
     }
     else
     {
         clientIndexRange =
             BufferMtl::getRestartIndicesFromClientData(contextMtl, indexType, clientBuffer);
         restartIndices = &clientIndexRange;
+        // For client indices, restartIndices is relative to the clientBuffer, we use offsetInBytes
+        // to calculate startIndex.
+        startIndex = offsetInBytes / gl::GetDrawElementsTypeSize(indexType);
     }
 
     uint32_t nIndicesPerPrimitive;
@@ -851,8 +860,7 @@
 
     const GLuint indexTypeBytes = gl::GetDrawElementsTypeSize(indexType);
     uint32_t indicesLeft        = indexCount;
-    size_t currentIndexOffset   = offset / indexTypeBytes;
-    const size_t baseOffset     = currentIndexOffset;
+    size_t currentIndexOffset   = startIndex;
 
     auto addDrawCommand = [&](uint32_t count, size_t elementOffset) {
         // Skip slices that don't contain enough indices to form a single primitive.
@@ -862,7 +870,7 @@
             uint32_t transformedCount = (count - stripExclude) * factor;
             // Scale the offset to account for the expansion factor.
             size_t transformedOffset =
-                offset + (elementOffset - baseOffset) * (size_t)factor * indexTypeBytes;
+                offsetInBytes + (elementOffset - startIndex) * (size_t)factor * indexTypeBytes;
             drawCommands.push_back({transformedCount, transformedOffset});
         }
     };
diff --git a/src/tests/gl_tests/StateChangeTest.cpp b/src/tests/gl_tests/StateChangeTest.cpp
index 669c715..73b5e34 100644
--- a/src/tests/gl_tests/StateChangeTest.cpp
+++ b/src/tests/gl_tests/StateChangeTest.cpp
@@ -10448,6 +10448,138 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Tests that primitive restart works correctly when drawing with an unsigned byte element array
+// buffer that is updated between draw calls. Buffer updates for unsigned bytes might result in
+// faulty index conversion in some backends.
+TEST_P(StateChangeTestES3, PrimitiveRestartWithUnsignedBytesIndexBufferUpdates)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
+
+    GLint posAttrib = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+    ASSERT_EQ(0, posAttrib);
+
+    // Two triangles forming a full-screen quad split along the diagonal.
+    // Triangle 1: upper-left half  (-1,-1), (1,1), (-1,1)
+    // Triangle 2: lower-right half (-1,-1), (1,-1), (1,1)
+    std::vector<Vector3> positionData = {
+        {-1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f},  {-1.0f, 1.0f, 0.0f},  // Triangle 1
+        {-1.0f, -1.0f, 0.0f}, {1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f}    // Triangle 2
+    };
+
+    // Indices for Triangle 1 only
+    std::vector<GLubyte> indices1 = {0, 1, 2};
+
+    GLBuffer posBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, posBuffer);
+    glBufferData(GL_ARRAY_BUFFER, positionData.size() * sizeof(positionData[0]),
+                 positionData.data(), GL_STATIC_DRAW);
+    glVertexAttribPointer(posAttrib, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glEnableVertexAttribArray(posAttrib);
+
+    GLBuffer indexBuffer;
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, 7, nullptr, GL_STREAM_DRAW);
+
+    const int w = getWindowWidth();
+    const int h = getWindowHeight();
+
+    glClearColor(0, 0, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
+
+    // Draw 1: Red color, Triangle 1
+    glUniform4f(colorLoc, 1, 0, 0, 1);  // Red
+    glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, indices1.size(), indices1.data());
+    glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, nullptr);
+
+    // Verify after Draw 1
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::red);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::black);
+
+    // Update index buffer to have Triangle 1, Restart, Triangle 2
+    std::vector<GLubyte> indices2 = {0, 1, 2, 0xFF, 3, 4, 5};
+    glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, indices2.size(), indices2.data());
+
+    // Draw 2: Blue color, both triangles
+    glUniform4f(colorLoc, 0, 0, 1, 1);  // Blue
+    glDrawElements(GL_TRIANGLES, 7, GL_UNSIGNED_BYTE, nullptr);
+
+    // Verify after Draw 2
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::blue);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::blue);
+
+    ASSERT_GL_NO_ERROR();
+}
+
+// Tests that primitive restart works correctly when drawing with an unsigned byte client indices
+// that are updated between draw calls.
+TEST_P(StateChangeTestES3, PrimitiveRestartWithUnsignedBytesClientIndexData)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/StateChangeTest.cpp b/src/tests/gl_tests/StateChangeTest.cpp
index 669c715..73b5e34 100644
--- a/src/tests/gl_tests/StateChangeTest.cpp
+++ b/src/tests/gl_tests/StateChangeTest.cpp
@@ -10448,6 +10448,138 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Tests that primitive restart works correctly when drawing with an unsigned byte element array
+// buffer that is updated between draw calls. Buffer updates for unsigned bytes might result in
+// faulty index conversion in some backends.
+TEST_P(StateChangeTestES3, PrimitiveRestartWithUnsignedBytesIndexBufferUpdates)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
+
+    GLint posAttrib = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+    ASSERT_EQ(0, posAttrib);
+
+    // Two triangles forming a full-screen quad split along the diagonal.
+    // Triangle 1: upper-left half  (-1,-1), (1,1), (-1,1)
+    // Triangle 2: lower-right half (-1,-1), (1,-1), (1,1)
+    std::vector<Vector3> positionData = {
+        {-1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f},  {-1.0f, 1.0f, 0.0f},  // Triangle 1
+        {-1.0f, -1.0f, 0.0f}, {1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f}    // Triangle 2
+    };
+
+    // Indices for Triangle 1 only
+    std::vector<GLubyte> indices1 = {0, 1, 2};
+
+    GLBuffer posBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, posBuffer);
+    glBufferData(GL_ARRAY_BUFFER, positionData.size() * sizeof(positionData[0]),
+                 positionData.data(), GL_STATIC_DRAW);
+    glVertexAttribPointer(posAttrib, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glEnableVertexAttribArray(posAttrib);
+
+    GLBuffer indexBuffer;
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, 7, nullptr, GL_STREAM_DRAW);
+
+    const int w = getWindowWidth();
+    const int h = getWindowHeight();
+
+    glClearColor(0, 0, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
+
+    // Draw 1: Red color, Triangle 1
+    glUniform4f(colorLoc, 1, 0, 0, 1);  // Red
+    glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, indices1.size(), indices1.data());
+    glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, nullptr);
+
+    // Verify after Draw 1
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::red);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::black);
+
+    // Update index buffer to have Triangle 1, Restart, Triangle 2
+    std::vector<GLubyte> indices2 = {0, 1, 2, 0xFF, 3, 4, 5};
+    glBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, indices2.size(), indices2.data());
+
+    // Draw 2: Blue color, both triangles
+    glUniform4f(colorLoc, 0, 0, 1, 1);  // Blue
+    glDrawElements(GL_TRIANGLES, 7, GL_UNSIGNED_BYTE, nullptr);
+
+    // Verify after Draw 2
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::blue);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::blue);
+
+    ASSERT_GL_NO_ERROR();
+}
+
+// Tests that primitive restart works correctly when drawing with an unsigned byte client indices
+// that are updated between draw calls.
+TEST_P(StateChangeTestES3, PrimitiveRestartWithUnsignedBytesClientIndexData)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
+
+    GLint posAttrib = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+    ASSERT_EQ(0, posAttrib);
+
+    // Two triangles forming a full-screen quad split along the diagonal.
+    // Triangle 1: upper-left half  (-1,-1), (1,1), (-1,1)
+    // Triangle 2: lower-right half (-1,-1), (1,-1), (1,1)
+    std::vector<Vector3> positionData = {
+        {-1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f},  {-1.0f, 1.0f, 0.0f},  // Triangle 1
+        {-1.0f, -1.0f, 0.0f}, {1.0f, -1.0f, 0.0f}, {1.0f, 1.0f, 0.0f}    // Triangle 2
+    };
+
+    // Indices for Triangle 1 only
+    std::vector<GLubyte> indices1 = {0, 1, 2};
+
+    GLBuffer posBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, posBuffer);
+    glBufferData(GL_ARRAY_BUFFER, positionData.size() * sizeof(positionData[0]),
+                 positionData.data(), GL_STATIC_DRAW);
+    glVertexAttribPointer(posAttrib, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glEnableVertexAttribArray(posAttrib);
+
+    // No element array buffer bound
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
+
+    const int w = getWindowWidth();
+    const int h = getWindowHeight();
+
+    glClearColor(0, 0, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
+
+    // Draw 1: Red color, Triangle 1
+    glUniform4f(colorLoc, 1, 0, 0, 1);  // Red
+    glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, indices1.data());
+
+    // Verify after Draw 1
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::red);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::black);
+
+    // Indices for both triangles
+    std::vector<GLubyte> indices2 = {0, 1, 2, 0xFF, 3, 4, 5};
+
+    // Draw 2: Blue color, both triangles
+    glUniform4f(colorLoc, 0, 0, 1, 1);  // Blue
+    glDrawElements(GL_TRIANGLES, 7, GL_UNSIGNED_BYTE, indices2.data());
+
+    // Verify after Draw 2
+    EXPECT_PIXEL_COLOR_EQ(w / 4, h / 2, GLColor::blue);
+    EXPECT_PIXEL_COLOR_EQ(3 * w / 4, h / 2, GLColor::blue);
+
+    ASSERT_GL_NO_ERROR();
+}
+
 // Tests that primitive restart for patches can be queried when tessellation shaders are available,
 // and that its value is independent of whether primitive restart is enabled.
 TEST_P(StateChangeTestES31, PrimitiveRestartForPatchQuery)
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential GPU OOB Read in ANGLE Metal via getDrawIndices Coordinate Mismatch

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: ANGLE’s Metal backend incorrectly calculates index buffer slices for primitive restart when an index conversion uses pool allocation. A coordinate mismatch between the pool allocation offset and the original WebGL buffer indices causes the slicing logic to miss the primitive restart sentinel, passing 0xFFFF to Metal. Since Metal lacks primitive restart support for non-strip primitives and bounds checking is disabled, this leads to an out-of-bounds read from the GPU heap.

Affected files:

  • third_party/angle/src/libANGLE/renderer/metal/VertexArrayMtl.mm
  • third_party/angle/src/libANGLE/renderer/metal/ContextMtl.mm
  • third_party/angle/src/libANGLE/renderer/metal/BufferMtl.mm

Estimated timestamp from git blame: 2026-05-06

Overview

A potential vulnerability exists in ANGLE’s Metal backend where a coordinate system mismatch in the index buffer slicing logic allows the primitive restart index to be passed to Metal drawing commands for non-strip primitives. This results in an out-of-bounds (OOB) vertex fetch from the GPU heap, which can be observed by the web page to leak cross-origin GPU data.

Vulnerability Details

When a WebGL2 application uses gl.drawElements with UNSIGNED_BYTE indices, ANGLE’s Metal backend must convert these indices to UNSIGNED_SHORT (u16) because Metal’s support for u8 indices is limited. This is handled by VertexArrayMtl::convertIndexBuffer.

The converted indices are allocated from a shared mtl::BufferPool. The offset into this pool is returned via idxBufferOffsetOut, which includes the sub-allocation offset (e.g., X bytes) within the shared pool and any alignment padding.

Subsequently, VertexArrayMtl::getDrawIndices is called to slice the draw call into multiple ranges to handle primitive restart, as Metal does not natively support primitive restart for non-strip primitives (e.g., GL_POINTS, GL_LINES, GL_TRIANGLES).

The bug lies in how VertexArrayMtl::getDrawIndices calculates the current index and compares it against known restart index positions:

// VertexArrayMtl.mm line 854
size_t currentIndexOffset = offset / indexTypeBytes;
// ... later in a loop
if (range.restartBegin > currentIndexOffset)

Here, offset is the byte offset into the converted pool buffer. Therefore, currentIndexOffset is an element offset relative to the start of the Metal pool’s sub-allocation (inflated by X).

However, the restart ranges (range.restartBegin) are obtained from BufferMtl::getRestartIndices:

// BufferMtl.mm line 510
angle::Span<const uint8_t> data = getBufferDataReadOnly(ctx, 0);
// ...
ranges = CalculateRestartRanges<uint8_t>(data);

These restart ranges are calculated from byte 0 of the original WebGL ELEMENT_ARRAY_BUFFER. They are absolute element indices relative to the WebGL buffer, not the Metal pool buffer.

Because of this mismatch, if a pool allocation offset is present, currentIndexOffset will be artificially inflated. The comparison range.restartBegin > currentIndexOffset fails to correctly identify the restart index’s position. Consequently, the restart sentinel (promoted from 0xFF to 0xFFFF) is not sliced out and is included in the draw command’s range.

Impact

Since Metal is told to draw with a range that includes 0xFFFF, and it does not natively recognize 0xFFFF as a restart index for non-strip primitives, it treats it as a valid vertex index.

Furthermore, ANGLE disables robustBufferAccessBehaviorKHR on Metal (in DisplayMtl.mm), meaning there are no backend-level bounds checks for vertex fetches. The GPU will attempt to fetch vertex 65535. If the bound vertex buffer is smaller than this, it results in an out-of-bounds read of the GPU heap.

An attacker can leak cross-origin GPU memory by rendering this OOB data to a floating-point render target and reading the pixels back into JavaScript.

Potential Reproduction Steps

Please note: These are suggested steps to trigger the vulnerability. Our tooling agent cannot run code to verify them.

  1. On macOS with ANGLE-Metal (default), obtain a WebGL2 context.
  2. Create an ARRAY_BUFFER with a small number of vertices (e.g. 4) and bind it to a vertex attribute; configure the vertex shader to pass this attribute to the fragment shader to be written to a render target.
  3. Create an ELEMENT_ARRAY_BUFFER with UNSIGNED_BYTE indices, intentionally including the restart index 0xFF (e.g., [0, 1, 2, 0xFF, 4]).
  4. Call gl.drawElements(gl.POINTS, count, gl.UNSIGNED_BYTE, offset) where offset does not align with kIndexBufferOffsetAlignment (4), e.g., offset 7. This forces conversion and pool allocation.
  5. Observe that the draw call includes vertex 65535 by reading back the rendered pixels from the render target via gl.readPixels().
  6. To guarantee a non-zero pool offset, perform multiple index conversions or sub-data updates prior to the draw call to advance the BufferPool allocation pointer.

Suggested Fix

Ensure that the slicing logic uses the same coordinate space for comparisons. One approach is to calculate the slices based on the original WebGL buffer’s element index and then apply the pool offset and alignment translation to the generated DrawCommandRange offset values, rather than attempting to slice using the post-conversion pool offsets.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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.

View on issue tracker
Uploaded PoC files