CVE-2026-79131
Overview
Files Changed
src/libANGLE/renderer/metal/ProvokingVertexHelper.mmsrc/tests/gl_tests/DrawElementsTest.cpp
Patch
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; +
Regression Test / PoC
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)
Original Bug Report
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.mmthird_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
- WebGL2 Initialization: An attacker creates a WebGL2 context on macOS. Chromium’s passthrough command decoder unconditionally enables
GL_PRIMITIVE_RESTART_FIXED_INDEXfor WebGL2 contexts viaglEnableFn(gpu/command_buffer/service/gles2_cmd_decoder_passthrough.cc:1188-1192). - Shader Preparation: The attacker compiles and links a shader program containing a
flatvarying attribute (e.g.,flat out int v;).ProgramExecutableMtl::linkUpdateHasFlatAttributesdetects this and setsmProgramHasFlatAttributes = true(ProgramExecutableMtl.mm:704-712). - Trigger Requirement: With
gl::PrimitiveMode::Triangles,mProgramHasFlatAttributes == true, and ANGLE’s defaultLastVertexConvention(State.cpp:370), the methodContextMtl::requiresIndexRewritereturnstrue(ContextMtl.mm:2159-2163).
Phase 2: The Malicious Payload & Draw Call Trigger
- Payload Crafting: The attacker crafts a
Uint32Arraycontaining exactly 24578 elements. Indices0and1are set to0xFFFFFFFF(the primitive restart marker). Indices2through24577are set to an arbitrary 32-bit payload (e.g.,0x41414141). - Triggering the Draw: The array is uploaded to the
ELEMENT_ARRAY_BUFFER. The attacker initiatesgl.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
- Range Extraction:
BufferMtl::CalculateDrawIndexRanges<uint32_t>parses theELEMENT_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). - Allocation Sizing:
VertexArrayMtlpasses this range intoProvokingVertexHelper::preconditionIndexBufferwithcount=24578andfirstIndex=0. - Size Calculation: The code calculates the required size using the aggregate
count(ProvokingVertexHelper.mm:227-242):totalPrimCount = 24578 / 3 = 8192primitives.totalNewIndexCount = 8192 * 3 = 24576indices.checkedBufferSize = 24576 << 2 = 98304bytes.
- Buffer Allocation:
mIndexBuffers.allocate(context, 98304, &newBuffer)requests a buffer.BufferPool::allocaterounds up to 4-byte alignment (98304), and allocates a freshMTLBufferof exactly98304bytes (mtl_buffer_pool.mm:229-246). Grooming Note:98304is 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
- Offset Calculation Flaw: Inside
preconditionIndexBuffer, aforloop processes the range{begin: 2, end: 24577}(ProvokingVertexHelper.mm:263-296).indexCount = 24577 - 2 + 1 = 24576.primitiveCount = 24576 / 3 = 8192.- CRITICAL FLAW:
newBeginOffsetis computed using the absolute offsetclippedRange.begin << indexTypeShiftinstead of being relative tofirstIndex. Thus,newBeginOffset = 2 << 2 = 8bytes.
- Shader Dispatch: The compute encoder binds the output
MTLBuffer(buffer(1)) for writing at offsetnewBuffer.offset() + newBeginOffset = 0 + 8 = 8. The compute shader is dispatched with8192threads.
Phase 5: Unchecked OOB Write in Compute Shader
- Execution: The
fixIndexBuffercompute shader executes. For the final threadprim = 8191,onIndexandonOutIndexare set toprim * 3 = 24573(rewrite_indices.metal:267-269). - Reading Payload: The shader reads
READ_IDX(24573),READ_IDX(24574), andREAD_IDX(24575)relative to the input bind offset of8, successfully loading the attacker’s0x41414141payload from the originalELEMENT_ARRAY_BUFFERelements at indices 24575, 24576, and 24577. - Out-of-Bounds Write: The
WRITE_IDXmacro performs a raw, bounds-uncheckeddevice uint *store (outIndexBufferUint32[_idx] = _val; _idx++;atrewrite_indices.metal:74-85).- Write 1:
outIndexBufferUint32[24573]maps to byte8 + (24573 * 4) = 98300(valid). - Write 2:
outIndexBufferUint32[24574]maps to byte8 + (24574 * 4) = 98304(OOB: 4 bytes past the 98304 byte allocation). - Write 3:
outIndexBufferUint32[24575]maps to byte8 + (24575 * 4) = 98308(OOB: 8 bytes past the 98304 byte allocation).
- Write 1:
- Result: Up to 8 bytes of fully attacker-controlled data are written past the end of the
MTLStorageModeSharedbuffer 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: verified — ProvokingVertexHelper::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 outputMTLBufferallocation is sized based on the aggregate(count / 3) * 3elements (for Triangles), but thefixIndexBuffercompute shader is bound using the absolute offsetclippedRange.begin << indexTypeShift. When primitive restart markers are placed at the beginning of the buffer, the shader writes(indexCount / 3) * 3elements starting fromclippedRange.begin. Ifcount ≡ 2 (mod 3), the write ends at indexcount, 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:
- Buffer Allocation Size Evaluation:
ProvokingVertexHelper.mm:227-244confirms 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)); - Absolute Offset Write Application:
ProvokingVertexHelper.mm:263-288shows 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); - Unchecked Shader Writes:
rewrite_indices.metal:74-85, 267-269confirms 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; - BufferPool Sizing & Alignment:
mtl_buffer_pool.mm:229-246confirms exact allocation without additional safety padding if a specific page-aligned size is requested beyondmInitialSize. - WebGL2 Default Enablement:
gpu/command_buffer/service/gles2_cmd_decoder_passthrough.cc:1188-1192confirmsGL_PRIMITIVE_RESTART_FIXED_INDEXis 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.