CVE-2026-14396
Overview
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_BYTEindices intoGL_UNSIGNED_SHORTand stores the result in a shared buffer, so the converted data begins at a non-zero sub-allocationoffsetwithin that pool. - `DrawCommandRange`
- a
{indexCount, offset}pair describing one sub-draw;VertexArrayMtl::getDrawIndicessplits a restart-enabled draw into a list of these ranges. - `getRestartIndices`
BufferMtl::getRestartIndicesreturns 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.
offsetInBytes) from the “index of the first element within the original buffer” origin (startIndex) computed from originalOffsetOrClientPtr.Attack Path
- Enable primitive restart with byte indices
An attacker (e.g. WebGL content) issues a draw using
GL_UNSIGNED_BYTEindices withGL_PRIMITIVE_RESTARTenabled, forcing ANGLE’s Metal backend to convert the indices into a sharedGL_UNSIGNED_SHORTpool. - Land at a non-zero pool offset
By driving conversions so the converted data is sub-allocated at a non-zero
offsetwithin the shared pool, the attacker makescurrentIndexOffset = offset / indexTypeBytesdiverge from the absolute restart positions returned byBufferMtl::getRestartIndices. - Trigger mis-aligned draw splitting
VertexArrayMtl::getDrawIndiceswalks the restart ranges against the inflatedcurrentIndexOffset, producingDrawCommandRangeentries whoseindexCount/offsetno longer match the valid converted index region. - 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)
- 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
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.mmsrc/libANGLE/renderer/metal/VertexArrayMtl.hsrc/libANGLE/renderer/metal/VertexArrayMtl.mmsrc/tests/gl_tests/StateChangeTest.cpp
Audit Directions
- Offset frame-of-reference mismatchesAudit 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-allocationsReview other backends and paths that convert
GL_UNSIGNED_BYTE/GL_UNSIGNED_SHORTindices into shared pools with non-zero offsets, checking that draw-splitting math stays relative to the correct origin. - Primitive-restart draw splittingScrutinize
getRestartIndices/getRestartIndicesFromClientDataconsumers for consistent client-vs-buffer offset handling and for bounds validation of the emittedDrawCommandRangeranges.
Patch
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);
Regression Test / PoC
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)
Original Bug Report
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.mmthird_party/angle/src/libANGLE/renderer/metal/ContextMtl.mmthird_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.
- On macOS with ANGLE-Metal (default), obtain a WebGL2 context.
- Create an
ARRAY_BUFFERwith 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. - Create an
ELEMENT_ARRAY_BUFFERwithUNSIGNED_BYTEindices, intentionally including the restart index 0xFF (e.g.,[0, 1, 2, 0xFF, 4]). - Call
gl.drawElements(gl.POINTS, count, gl.UNSIGNED_BYTE, offset)whereoffsetdoes not align withkIndexBufferOffsetAlignment(4), e.g., offset 7. This forces conversion and pool allocation. - Observe that the draw call includes vertex 65535 by reading back the rendered pixels from the render target via
gl.readPixels(). - To guarantee a non-zero pool offset, perform multiple index conversions or sub-data updates prior to the draw call to advance the
BufferPoolallocation 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.