Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker536626343
Fix commitbfbd2ccf3ee1 (angle/angle) +141/-34
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • src/libANGLE/renderer/metal/ProvokingVertexHelper.mm
  • src/tests/gl_tests/DrawElementsTest.cpp
From bfbd2ccf3ee14e66f162b333e9f0aa7cc6cb32c0 Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <[email protected]>
Date: Wed, 22 Jul 2026 14:56:29 +0800
Subject: [PATCH] Metal: Fix index buffer rewriting OOB write and rendering bug

When rewriting index buffers for provoking vertex conventions (e.g.,
flat shading with primitive restart enabled), the output buffer was
previously sized using primCountForIndexCount(count).

When primitive restart markers appeared at the beginning of the draw
window or between primitive ranges, clippedRange.begin shifted the
starting offset forward, causing the compute shader to write past the
end of the pre-allocated buffer and corrupt trailing primitive indices.

This patch extracts the dispatch ranges in a single pass into an
angle::FastVector and computes the exact required buffer capacity using
CheckedNumeric::Max, ensuring full buffer bounds coverage and correct
rendering.

Bug: chromium:536626343
Change-Id: Iecd8a3b9a280cb4b330a6d9373996839b2299250
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8129619
Reviewed-by: Geoff Lang <[email protected]>
Commit-Queue: Quyen Le <[email protected]>
---

diff --git a/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm b/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm
index 26d0f7e..7577677 100644
--- a/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm
+++ b/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm
@@ -9,6 +9,7 @@
 
 #include "libANGLE/renderer/metal/ProvokingVertexHelper.h"
 #import <Foundation/Foundation.h>
+#include "common/FastVector.h"
 #include "common/base/anglebase/numerics/checked_math.h"
 #include "libANGLE/Display.h"
 #include "libANGLE/renderer/metal/ContextMtl.h"
@@ -21,6 +22,13 @@
 
 namespace
 {
+struct IndexRewriteRange
+{
+    uint32_t indexCount;
+    uint32_t primitiveCount;
+    size_t srcOffset;
+    size_t dstOffset;
+};
 constexpr size_t kInitialIndexBufferSize = 0xFFFF;  // Initial 64k pool.
 }
 static inline uint32_t primCountForIndexCount(const uint fixIndexBufferKey,
@@ -223,31 +231,9 @@
             return angle::Result::Stop;
     }
 
-    // Maximum primitive/index count needed for buffer allocation, based on the full draw window.
-    uint32_t totalPrimCount     = primCountForIndexCount(indexBufferKey, count);
-    uint32_t totalNewIndexCount = 0;
-    ANGLE_CHECK_GL_MATH(
-        context, indexCountForPrimCount(indexBufferKey, totalPrimCount, &totalNewIndexCount));
-
     const size_t indexTypeShift = gl::GetDrawElementsTypeShift(indexBufferType);
-    size_t firstIndexOffset     = firstIndex << indexTypeShift;
-    size_t newFirstIndexOffset  = firstIndexOffset;
-    if (mode != newMode)
-    {
-        newFirstIndexOffset *= perPrimitiveIndexCount;
-    }
-    angle::CheckedNumeric<size_t> checkedBufferSize(totalNewIndexCount);
-    checkedBufferSize <<= indexTypeShift;
-    checkedBufferSize += newFirstIndexOffset;
-    ANGLE_CHECK_GL_MATH(context, checkedBufferSize.IsValid());
-    mtl::BufferSlice newBuffer;
-    ANGLE_TRY(mIndexBuffers.allocate(context, checkedBufferSize.ValueOrDie(), &newBuffer));
-
-    mtl::ComputeCommandEncoder *encoder =
-        context->getComputeCommandEncoderWithoutEndingRenderEncoder();
-    const bool isForGenerateIndices = false;
-    ANGLE_TRY(
-        prepareCommandEncoderForFunction(context, encoder, indexBufferKey, isForGenerateIndices));
+    angle::FastVector<IndexRewriteRange, 4> rewriteRanges;
+    angle::CheckedNumeric<size_t> checkedBufferSize = 0;
 
     const size_t lastIndex = firstIndex + count - 1;
     for (const auto &range : drawIndexRanges)
@@ -267,28 +253,52 @@
         {
             continue;
         }
-        size_t beginOffset = clippedRange.begin << indexTypeShift;
+        angle::CheckedNumeric<size_t> srcOffset = clippedRange.begin;
+        srcOffset <<= indexTypeShift;
+
         uint32_t primitiveCount;
-        size_t newBeginOffset;
+        angle::CheckedNumeric<size_t> dstOffset = srcOffset;
         if (mode == newMode)
         {
             primitiveCount = indexCount / perPrimitiveIndexCount;
-            newBeginOffset = clippedRange.begin << indexTypeShift;
         }
         else
         {
             // Expanded modes: `N` source indices produce `(N - perPrimitiveIndexCount + 1)`
             // primitives.
             primitiveCount = indexCount - perPrimitiveIndexCount + 1;
-            newBeginOffset = (clippedRange.begin << indexTypeShift) * perPrimitiveIndexCount;
+            dstOffset *= perPrimitiveIndexCount;
         }
 
-        auto threadsPerThreadgroup = MTLSizeMake(MIN(primitiveCount, 64u), 1, 1);
-        encoder->setBuffer(indexBuffer.buffer(), indexBuffer.offset() + beginOffset, 0);
-        encoder->setBufferForWrite(newBuffer.buffer(), newBuffer.offset() + newBeginOffset, 1);
-        encoder->setData(indexCount, 2);
-        encoder->setData(primitiveCount, 3);
-        encoder->dispatch(MTLSizeMake((static_cast<NSUInteger>(primitiveCount) +
+        ANGLE_CHECK_GL_MATH(context, srcOffset.IsValid() && dstOffset.IsValid());
+        rewriteRanges.push_back(
+            {indexCount, primitiveCount, srcOffset.ValueOrDie(), dstOffset.ValueOrDie()});
+
+        angle::CheckedNumeric<size_t> rangeEndOffset(primitiveCount);
+        rangeEndOffset *= perPrimitiveIndexCount;
+        rangeEndOffset <<= indexTypeShift;
+        rangeEndOffset += dstOffset;
+        checkedBufferSize = checkedBufferSize.Max(rangeEndOffset);
+    }
+
+    ANGLE_CHECK_GL_MATH(context, checkedBufferSize.IsValid());
+    mtl::BufferSlice newBuffer;
+    ANGLE_TRY(mIndexBuffers.allocate(context, checkedBufferSize.ValueOrDie(), &newBuffer));
+
+    mtl::ComputeCommandEncoder *encoder =
+        context->getComputeCommandEncoderWithoutEndingRenderEncoder();
+    const bool isForGenerateIndices = false;
+    ANGLE_TRY(
+        prepareCommandEncoderForFunction(context, encoder, indexBufferKey, isForGenerateIndices));
+
+    for (const IndexRewriteRange &rangeInfo : rewriteRanges)
+    {
+        auto threadsPerThreadgroup = MTLSizeMake(MIN(rangeInfo.primitiveCount, 64u), 1, 1);
+        encoder->setBuffer(indexBuffer.buffer(), indexBuffer.offset() + rangeInfo.srcOffset, 0);
+        encoder->setBufferForWrite(newBuffer.buffer(), newBuffer.offset() + rangeInfo.dstOffset, 1);
+        encoder->setData(rangeInfo.indexCount, 2);
+        encoder->setData(rangeInfo.primitiveCount, 3);
+        encoder->dispatch(MTLSizeMake((static_cast<NSUInteger>(rangeInfo.primitiveCount) +
                                        threadsPerThreadgroup.width - 1) /
                                           threadsPerThreadgroup.width,
                                       1, 1),
diff --git a/src/tests/gl_tests/DrawElementsTest.cpp b/src/tests/gl_tests/DrawElementsTest.cpp
index 9c9ef46..ace2d81 100644
--- a/src/tests/gl_tests/DrawElementsTest.cpp
+++ b/src/tests/gl_tests/DrawElementsTest.cpp
@@ -967,6 +967,103 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test a large flat-shaded GL_TRIANGLES draw with primitive restart markers at the
+// beginning of the index buffer. Previously, the Metal backend would size the
+// rewritten index buffer for the primitive-aligned count while writing at absolute
+// per-range offsets, so the last write for the shifted range landed past the allocation.
+// Uses distinct vertices for every quad so the final quad specifically validates that the
+// rewritten provoking vertex indices at the end of the buffer are rendered correctly.
+TEST_P(DrawElementsTest, FlatTrianglesLargePrimitiveRestartAtBegin)
+{
+    constexpr char kFlatVS[] = R"(#version 300 es
+in vec4 a_position;
+in float a_mark;
+flat out float v_mark;
+void main()
+{
+    v_mark = a_mark;
+    gl_Position = a_position;
+})";
+    constexpr char kFlatFS[] = R"(#version 300 es
+precision highp float;
+flat in float v_mark;
+out vec4 fragColor;
+void main()
+{
+    fragColor = v_mark >= 0.5 ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+    ANGLE_GL_PROGRAM(program, kFlatVS, kFlatFS);
+    glUseProgram(program);
+
+    constexpr GLsizei kQuads = 5000;
+    std::vector<Vector3> vertices(kQuads * 4, Vector3(0.0f, 0.0f, 0.0f));
+    std::vector<GLfloat> marks(kQuads * 4, 0.0f);
+
+    // Quad kQuads - 1 covers the full screen and has provoking marks set to 1.0 (green).
+    size_t lastQuadBase        = (kQuads - 1) * 4;
+    vertices[lastQuadBase + 0] = Vector3(-1.0f, -1.0f, 0.0f);
+    vertices[lastQuadBase + 1] = Vector3(1.0f, -1.0f, 0.0f);
+    vertices[lastQuadBase + 2] = Vector3(-1.0f, 1.0f, 0.0f);
+    vertices[lastQuadBase + 3] = Vector3(1.0f, 1.0f, 0.0f);
+
+    // Last vertex convention, triangles {0,1,2, 2,1,3}: 2 and 3 are provoking.
+    marks[lastQuadBase + 2] = 1.0f;
+    marks[lastQuadBase + 3] = 1.0f;
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/DrawElementsTest.cpp b/src/tests/gl_tests/DrawElementsTest.cpp
index 9c9ef46..ace2d81 100644
--- a/src/tests/gl_tests/DrawElementsTest.cpp
+++ b/src/tests/gl_tests/DrawElementsTest.cpp
@@ -967,6 +967,103 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test a large flat-shaded GL_TRIANGLES draw with primitive restart markers at the
+// beginning of the index buffer. Previously, the Metal backend would size the
+// rewritten index buffer for the primitive-aligned count while writing at absolute
+// per-range offsets, so the last write for the shifted range landed past the allocation.
+// Uses distinct vertices for every quad so the final quad specifically validates that the
+// rewritten provoking vertex indices at the end of the buffer are rendered correctly.
+TEST_P(DrawElementsTest, FlatTrianglesLargePrimitiveRestartAtBegin)
+{
+    constexpr char kFlatVS[] = R"(#version 300 es
+in vec4 a_position;
+in float a_mark;
+flat out float v_mark;
+void main()
+{
+    v_mark = a_mark;
+    gl_Position = a_position;
+})";
+    constexpr char kFlatFS[] = R"(#version 300 es
+precision highp float;
+flat in float v_mark;
+out vec4 fragColor;
+void main()
+{
+    fragColor = v_mark >= 0.5 ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
+})";
+    ANGLE_GL_PROGRAM(program, kFlatVS, kFlatFS);
+    glUseProgram(program);
+
+    constexpr GLsizei kQuads = 5000;
+    std::vector<Vector3> vertices(kQuads * 4, Vector3(0.0f, 0.0f, 0.0f));
+    std::vector<GLfloat> marks(kQuads * 4, 0.0f);
+
+    // Quad kQuads - 1 covers the full screen and has provoking marks set to 1.0 (green).
+    size_t lastQuadBase        = (kQuads - 1) * 4;
+    vertices[lastQuadBase + 0] = Vector3(-1.0f, -1.0f, 0.0f);
+    vertices[lastQuadBase + 1] = Vector3(1.0f, -1.0f, 0.0f);
+    vertices[lastQuadBase + 2] = Vector3(-1.0f, 1.0f, 0.0f);
+    vertices[lastQuadBase + 3] = Vector3(1.0f, 1.0f, 0.0f);
+
+    // Last vertex convention, triangles {0,1,2, 2,1,3}: 2 and 3 are provoking.
+    marks[lastQuadBase + 2] = 1.0f;
+    marks[lastQuadBase + 3] = 1.0f;
+
+    GLBuffer vertexBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
+    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices[0]) * vertices.size(), vertices.data(),
+                 GL_STATIC_DRAW);
+    GLint posLocation = glGetAttribLocation(program, "a_position");
+    ASSERT_NE(-1, posLocation);
+    glEnableVertexAttribArray(posLocation);
+    glVertexAttribPointer(posLocation, 3, GL_FLOAT, GL_FALSE, 0, 0);
+
+    GLBuffer markBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, markBuffer);
+    glBufferData(GL_ARRAY_BUFFER, sizeof(marks[0]) * marks.size(), marks.data(), GL_STATIC_DRAW);
+    GLint markLocation = glGetAttribLocation(program, "a_mark");
+    ASSERT_NE(-1, markLocation);
+    glEnableVertexAttribArray(markLocation);
+    glVertexAttribPointer(markLocation, 1, GL_FLOAT, GL_FALSE, 0, 0);
+
+    // Two restart markers followed by quads with distinct vertex indices. The index count is
+    // large enough to exceed backend staging buffer limits and count % 3 == 2 so primitive
+    // alignment differs from the raw count.
+    constexpr GLuint kRestart   = 0xFFFFFFFFu;
+    std::vector<GLuint> indices = {kRestart, kRestart};
+    indices.reserve(2 + static_cast<size_t>(kQuads) * 6);
+    for (GLuint q = 0; q < static_cast<GLuint>(kQuads); ++q)
+    {
+        GLuint base = q * 4;
+        indices.push_back(base + 0);
+        indices.push_back(base + 1);
+        indices.push_back(base + 2);
+        indices.push_back(base + 2);
+        indices.push_back(base + 1);
+        indices.push_back(base + 3);
+    }
+    ASSERT_EQ(2u, indices.size() % 3);
+
+    GLBuffer elementBuffer;
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, elementBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices[0]) * indices.size(), indices.data(),
+                 GL_STATIC_DRAW);
+    ASSERT_GL_NO_ERROR();
+
+    glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
+    glClearColor(1.f, 0.f, 0.f, 1.f);
+    glClear(GL_COLOR_BUFFER_BIT);
+    glDrawElements(GL_TRIANGLES, static_cast<GLsizei>(indices.size()), GL_UNSIGNED_INT, nullptr);
+    ASSERT_GL_NO_ERROR();
+
+    EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
+    EXPECT_PIXEL_COLOR_EQ(getWindowWidth() - 1, 0, GLColor::green);
+    EXPECT_PIXEL_COLOR_EQ(0, getWindowHeight() - 1, GLColor::green);
+    EXPECT_PIXEL_COLOR_EQ(getWindowWidth() - 1, getWindowHeight() - 1, GLColor::green);
+    ASSERT_GL_NO_ERROR();
+}
+
 // Tests various draw element parameter, vertex buffer contents variants.
 // Does not yet test using GL_BYTE 0xFF, GL_SHORT 0xFFFF with primitive restart off.
 TEST_P(DrawElementsVariantsTest, Draw)
Loading diff…

Original Bug Report

reported by [email protected]

Potential OOB Write in ANGLE Metal ProvokingVertexHelper via Absolute Restart-Shifted Offset 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential out-of-bounds write vulnerability exists in the ANGLE Metal backend when rewriting index buffers for primitive restart. By crafting an index buffer with restart markers at the beginning, an attacker can cause the output buffer’s bind offset to exceed the allocated space by 8 bytes, allowing arbitrary writes past the end of the MTLBuffer.

Affected files:

  • third_party/angle/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm
  • third_party/angle/src/libANGLE/renderer/metal/shaders/rewrite_indices.metal

Estimated timestamp from git blame: 2021-07-06

1. Summary of the Issue (Meant for Human Triage)

A potential out-of-bounds (OOB) write vulnerability has been identified in ANGLE’s Metal backend within ProvokingVertexHelper::preconditionIndexBuffer. Specifically, the output MTLBuffer allocation is sized based on the aggregate (count / perPrim) * perPrim index count. However, during the execution of primitive-restart-delimited ranges, the compute shader fixIndexBuffer is bound using an absolute offset: clippedRange.begin << indexTypeShift.

When an attacker crafts an ELEMENT_ARRAY_BUFFER with primitive-restart markers at the very beginning, the start of the rendered range is shifted past firstIndex. If the restart markers shift the beginning offset by b indices (e.g., b = 2) while the total count satisfies count ≡ b (mod perPrim), the compute shader writes up to (perPrim - 1) * indexSize bytes beyond the end of the allocated MTLBuffer. Because the writes are performed using raw Metal device uint * pointers without bounds-checking, this results in an out-of-bounds memory write in the GPU process.

This issue is reachable by untrusted WebGL2 content running on macOS/iOS (where Metal is the default ANGLE backend). Because the Metal backend is compile-time excluded on Android, this is capped as a High (S1) severity sandbox/GPU process compromise. Note: As our agent tooling does not execute live code, the following steps and exploit vectors are suggested and theoretical based on rigorous static analysis.


2. Proof-of-Concept & Detailed Execution Flow

The following sequence traces the exact execution flow from the attacker’s entry point to the code sink, leading to an 8-byte out-of-bounds write.

Phase 1: Context Setup and Preconditions

  1. WebGL2 Initialization: An attacker creates a WebGL2 context on macOS. Chromium’s passthrough command decoder unconditionally enables GL_PRIMITIVE_RESTART_FIXED_INDEX for WebGL2 contexts via glEnableFn (gpu/command_buffer/service/gles2_cmd_decoder_passthrough.cc:1188-1192).
  2. Shader Preparation: The attacker compiles and links a shader program containing a flat varying attribute (e.g., flat out int v;). ProgramExecutableMtl::linkUpdateHasFlatAttributes detects this and sets mProgramHasFlatAttributes = true (ProgramExecutableMtl.mm:704-712).
  3. Trigger Requirement: With gl::PrimitiveMode::Triangles, mProgramHasFlatAttributes == true, and ANGLE’s default LastVertexConvention (State.cpp:370), the method ContextMtl::requiresIndexRewrite returns true (ContextMtl.mm:2159-2163).

Phase 2: The Malicious Payload & Draw Call Trigger

  1. Payload Crafting: The attacker crafts a Uint32Array containing exactly 24578 elements. Indices 0 and 1 are set to 0xFFFFFFFF (the primitive restart marker). Indices 2 through 24577 are set to an arbitrary 32-bit payload (e.g., 0x41414141).
  2. Triggering the Draw: The array is uploaded to the ELEMENT_ARRAY_BUFFER. The attacker initiates gl.drawElements(gl.TRIANGLES, 24578, gl.UNSIGNED_INT, 0);. This is explicitly orchestrated to be the first large flat-shaded indexed draw in the WebGL context.

Phase 3: Parsing the Index Ranges & Flawed Allocation

  1. Range Extraction: BufferMtl::CalculateDrawIndexRanges<uint32_t> parses the ELEMENT_ARRAY_BUFFER, skips the markers at indices 0 and 1, and returns a single contiguous range: drawIndexRanges = [{begin: 2, end: 24577}] (BufferMtl.mm:476-501).
  2. Allocation Sizing: VertexArrayMtl passes this range into ProvokingVertexHelper::preconditionIndexBuffer with count=24578 and firstIndex=0.
  3. Size Calculation: The code calculates the required size using the aggregate count (ProvokingVertexHelper.mm:227-242):
    • totalPrimCount = 24578 / 3 = 8192 primitives.
    • totalNewIndexCount = 8192 * 3 = 24576 indices.
    • checkedBufferSize = 24576 << 2 = 98304 bytes.
  4. Buffer Allocation: mIndexBuffers.allocate(context, 98304, &newBuffer) requests a buffer. BufferPool::allocate rounds up to 4-byte alignment (98304), and allocates a fresh MTLBuffer of exactly 98304 bytes (mtl_buffer_pool.mm:229-246). Grooming Note: 98304 is an exact multiple of the macOS Metal page size (16384 * 6), so the driver applies zero intra-page tail padding.

Phase 4: Absolute Offset Bind Calculation Flaw

  1. Offset Calculation Flaw: Inside preconditionIndexBuffer, a for loop processes the range {begin: 2, end: 24577} (ProvokingVertexHelper.mm:263-296).
    • indexCount = 24577 - 2 + 1 = 24576.
    • primitiveCount = 24576 / 3 = 8192.
    • CRITICAL FLAW: newBeginOffset is computed using the absolute offset clippedRange.begin << indexTypeShift instead of being relative to firstIndex. Thus, newBeginOffset = 2 << 2 = 8 bytes.
  2. Shader Dispatch: The compute encoder binds the output MTLBuffer (buffer(1)) for writing at offset newBuffer.offset() + newBeginOffset = 0 + 8 = 8. The compute shader is dispatched with 8192 threads.

Phase 5: Unchecked OOB Write in Compute Shader

  1. Execution: The fixIndexBuffer compute shader executes. For the final thread prim = 8191, onIndex and onOutIndex are set to prim * 3 = 24573 (rewrite_indices.metal:267-269).
  2. Reading Payload: The shader reads READ_IDX(24573), READ_IDX(24574), and READ_IDX(24575) relative to the input bind offset of 8, successfully loading the attacker’s 0x41414141 payload from the original ELEMENT_ARRAY_BUFFER elements at indices 24575, 24576, and 24577.
  3. Out-of-Bounds Write: The WRITE_IDX macro performs a raw, bounds-unchecked device uint * store (outIndexBufferUint32[_idx] = _val; _idx++; at rewrite_indices.metal:74-85).
    • Write 1: outIndexBufferUint32[24573] maps to byte 8 + (24573 * 4) = 98300 (valid).
    • Write 2: outIndexBufferUint32[24574] maps to byte 8 + (24574 * 4) = 98304 (OOB: 4 bytes past the 98304 byte allocation).
    • Write 3: outIndexBufferUint32[24575] maps to byte 8 + (24575 * 4) = 98308 (OOB: 8 bytes past the 98304 byte allocation).
  4. Result: Up to 8 bytes of fully attacker-controlled data are written past the end of the MTLStorageModeShared buffer into adjacent mapped memory inside the GPU process.

Suggested Fix

In third_party/angle/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm:276, the output offset calculation should be updated to be relative to firstIndex rather than an absolute bound, preventing the forward shift. For example:

newBeginOffset = (clippedRange.begin - firstIndex) << indexTypeShift;

Alternatively, checkedBufferSize (lines 239-242) must be adjusted to account for the maximum absolute end index of all provided drawIndexRanges rather than relying solely on the aggregate count.


3. Technical Verification Details (Automated Audit Logs)

> Determination: verifiedProvokingVertexHelper::preconditionIndexBuffer sizes its output MTLBuffer from ⌊count/3⌋·3 indices but binds it at absolute offset clippedRange.begin << indexTypeShift for each primitive-restart-delimited range, then dispatches fixIndexBuffer which writes primitiveCount·3 contiguous uint32s from that offset. When restart markers shift clippedRange.begin past firstIndex by b∈{1,2} while count≡b (mod 3), the compute kernel writes up to 8 bytes past the end of the MTLBuffer with attacker-chosen ELEMENT_ARRAY_BUFFER values. A-SERVER reachable via WebGL2 on macOS (ANGLE-Metal default).

Prior Critic Double-Check Verdict:

  • Severity: High (S1)

  • Brief Notes / Reasoning: The vulnerability accurately describes an 8-byte out-of-bounds (OOB) write in the ANGLE Metal backend within the GPU process. The root cause is a sizing and offset mismatch in ProvokingVertexHelper::preconditionIndexBuffer: the output MTLBuffer allocation is sized based on the aggregate (count / 3) * 3 elements (for Triangles), but the fixIndexBuffer compute shader is bound using the absolute offset clippedRange.begin << indexTypeShift. When primitive restart markers are placed at the beginning of the buffer, the shader writes (indexCount / 3) * 3 elements starting from clippedRange.begin. If count ≡ 2 (mod 3), the write ends at index count, which is 2 elements (8 bytes) beyond the allocated size.

    The attacker fully controls the payload (via the ELEMENT_ARRAY_BUFFER) and the allocation size. By making the allocation size a multiple of the page size, the attacker avoids intra-page padding, causing the 8-byte write to reliably corrupt adjacent memory.

    Per the severity guidelines, a web-reachable GPU process memory corruption is typically Critical (S0) because the GPU process is unsandboxed on Android. However, because this is explicitly a [Metal-backend] vulnerability, it is compile-time excluded on Android. Evaluated at the least-sandboxed platform on which it actually runs (macOS/iOS), the GPU process is sandboxed, so this constitutes a sandbox compromise, correctly capping the severity at High (S1). Furthermore, the Medium (S2) cap for the Metal shader compiler does not apply here; the OOB write occurs during the execution of a precompiled ANGLE compute kernel (fixIndexBuffer) writing to GPU memory, rather than during shader compilation.

Exhaustive Code Audit Ledger:

  1. Buffer Allocation Size Evaluation: ProvokingVertexHelper.mm:227-244 confirms sizing logic:
    uint32_t totalPrimCount = primCountForIndexCount(indexBufferKey, count);
    uint32_t totalNewIndexCount = 0;
    ANGLE_CHECK_GL_MATH(context, indexCountForPrimCount(indexBufferKey, totalPrimCount, &totalNewIndexCount));
    angle::CheckedNumeric<size_t> checkedBufferSize(totalNewIndexCount);
    checkedBufferSize <<= indexTypeShift;
    checkedBufferSize += newFirstIndexOffset;
    ANGLE_TRY(mIndexBuffers.allocate(context, checkedBufferSize.ValueOrDie(), &newBuffer));
    
  2. Absolute Offset Write Application: ProvokingVertexHelper.mm:263-288 shows the flaw:
    DrawIndexRange clippedRange{std::max(range.begin, firstIndex), std::min(range.end, lastIndex)};
    uint32_t indexCount = static_cast<uint32_t>(clippedRange.end - clippedRange.begin + 1);
    if (mode == newMode) {
        primitiveCount = indexCount / perPrimitiveIndexCount;
        newBeginOffset = clippedRange.begin << indexTypeShift; // ERROR: Absolute offset
    }
    encoder->setBufferForWrite(newBuffer.buffer(), newBuffer.offset() + newBeginOffset, 1);
    
  3. Unchecked Shader Writes: rewrite_indices.metal:74-85, 267-269 confirms lack of bounds checking:
    #define WRITE_IDX(_idx, _val) ({
        if(outIndexBufferIsUint16) { outIndexBufferUint16[(_idx)] = _val; }
        if(outIndexBufferIsUint32) { outIndexBufferUint32[(_idx)] = _val; }
        _idx++;
    })
    // ... 
    case MtlFixIndexBufferKeyTriangles:
        onIndex = prim * 3;
        onOutIndex = prim * 3;
        break;
    
  4. BufferPool Sizing & Alignment: mtl_buffer_pool.mm:229-246 confirms exact allocation without additional safety padding if a specific page-aligned size is requested beyond mInitialSize.
  5. WebGL2 Default Enablement: gpu/command_buffer/service/gles2_cmd_decoder_passthrough.cc:1188-1192 confirms GL_PRIMITIVE_RESTART_FIXED_INDEX is enabled by default for all WebGL2 contexts, guaranteeing reachability for uncompromised web content.

Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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