CVE-2026-8567
Overview
Files Changed
src/libANGLE/renderer/metal/ContextMtl.mmsrc/libANGLE/renderer/metal/ProvokingVertexHelper.hsrc/libANGLE/renderer/metal/ProvokingVertexHelper.mm
Patch
From 52ba614db7e9df373eb33f2c431e47e657b173a9 Mon Sep 17 00:00:00 2001 From: Geoff Lang <[email protected]> Date: Mon, 23 Mar 2026 17:10:53 -0400 Subject: [PATCH] Metal: Protect against overflow in provoking vertex index count Update the ProvokingVertexHelper methods to pass in index counts as GLsizei which matches what the API gives us and return index counts in uint32_t which is what is passed to Metal. Do internal math in 64 bits and then validate the results fit in 32 bits. Bug: chromium:484986863 Change-Id: I56553a3deddc98834645c0fab4129dbc65a830d6 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7695152 Reviewed-by: Shahbaz Youssefi <[email protected]> Reviewed-by: Kimmo Kinnunen <[email protected]> Commit-Queue: Geoff Lang <[email protected]> --- diff --git a/src/libANGLE/renderer/metal/ContextMtl.mm b/src/libANGLE/renderer/metal/ContextMtl.mm index 4a01efb..3506996 100644 --- a/src/libANGLE/renderer/metal/ContextMtl.mm +++ b/src/libANGLE/renderer/metal/ContextMtl.mm @@ -666,7 +666,7 @@ GLuint baseInstance) { - size_t outIndexCount = 0; + uint32_t outIndexCount = 0; size_t outIndexOffset = 0; gl::DrawElementsType convertedType = gl::DrawElementsType::UnsignedInt; gl::PrimitiveMode outIndexMode = gl::PrimitiveMode::InvalidEnum; @@ -675,7 +675,6 @@ ANGLE_TRY(mProvokingVertexHelper.generateIndexBuffer( mtl::GetImpl(context), first, count, mode, convertedType, outIndexCount, outIndexOffset, outIndexMode, drawIdxBuffer)); - GLsizei outIndexCounti32 = static_cast<GLsizei>(outIndexCount); // Note: we don't need to pass the generated index buffer to ContextMtl::setupDraw. // Because setupDraw only needs to operate on the original vertex buffers & PrimitiveMode. @@ -722,20 +721,20 @@ MTLIndexType mtlIdxType = mtl::GetIndexType(convertedType); \ if (instances == 0) \ { \ - mRenderEncoder.drawIndexed(mtlType, outIndexCounti32, mtlIdxType, drawIdxBuffer, \ + mRenderEncoder.drawIndexed(mtlType, outIndexCount, mtlIdxType, drawIdxBuffer, \ outIndexOffset); \ } \ else \ { \ if (baseInstance == 0) \ { \ - mRenderEncoder.drawIndexedInstanced(mtlType, outIndexCounti32, mtlIdxType, \ + mRenderEncoder.drawIndexedInstanced(mtlType, outIndexCount, mtlIdxType, \ drawIdxBuffer, outIndexOffset, instances); \ } \ else \ { \ mRenderEncoder.drawIndexedInstancedBaseVertexBaseInstance( \ - mtlType, outIndexCounti32, mtlIdxType, drawIdxBuffer, outIndexOffset, \ + mtlType, outIndexCount, mtlIdxType, drawIdxBuffer, outIndexOffset, \ instances, 0, baseInstance); \ } \ } \ @@ -792,15 +791,11 @@ if (requiresIndexRewrite(context->getState(), mode)) { - size_t outIndexCount = 0; - gl::PrimitiveMode newMode = gl::PrimitiveMode::InvalidEnum; + // Line strips and triangle strips are rewritten to flat line arrays and tri arrays. ANGLE_TRY(mProvokingVertexHelper.preconditionIndexBuffer( mtl::GetImpl(context), idxBuffer, count, convertedOffset, - mState.isPrimitiveRestartEnabled(), mode, convertedType, outIndexCount, - provokingVertexAdditionalOffset, newMode, drawIdxBuffer)); - // Line strips and triangle strips are rewritten to flat line arrays and tri arrays. - convertedCounti32 = (uint32_t)outIndexCount; - mode = newMode; + mState.isPrimitiveRestartEnabled(), mode, convertedType, convertedCounti32, + provokingVertexAdditionalOffset, mode, drawIdxBuffer)); } else { diff --git a/src/libANGLE/renderer/metal/ProvokingVertexHelper.h b/src/libANGLE/renderer/metal/ProvokingVertexHelper.h index 5389753..b25f1da 100644 --- a/src/libANGLE/renderer/metal/ProvokingVertexHelper.h +++ b/src/libANGLE/renderer/metal/ProvokingVertexHelper.h @@ -28,22 +28,22 @@ ProvokingVertexHelper(ContextMtl *context); angle::Result preconditionIndexBuffer(ContextMtl *context, mtl::BufferRef indexBuffer, - size_t indexCount, + GLsizei indexCount, size_t indexOffset, bool primitiveRestartEnabled, gl::PrimitiveMode primitiveMode, gl::DrawElementsType elementsType, - size_t &outIndexCount, + uint32_t &outIndexCount, size_t &outIndexOffset, gl::PrimitiveMode &outPrimitiveMode, mtl::BufferRef &outNewBuffer); angle::Result generateIndexBuffer(ContextMtl *context, size_t first, - size_t indexCount, + GLsizei indexCount, gl::PrimitiveMode primitiveMode, gl::DrawElementsType elementsType, - size_t &outIndexCount, + uint32_t &outIndexCount, size_t &outIndexOffset, gl::PrimitiveMode &outPrimitiveMode, mtl::BufferRef &outNewBuffer); diff --git a/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm b/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm index 407117b..eeda9bc 100644 --- a/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm +++ b/src/libANGLE/renderer/metal/ProvokingVertexHelper.mm @@ -9,11 +9,13 @@ #include "libANGLE/renderer/metal/ProvokingVertexHelper.h" #import <Foundation/Foundation.h> +#include "common/base/anglebase/numerics/checked_math.h" #include "libANGLE/Display.h" #include "libANGLE/renderer/metal/ContextMtl.h" #include "libANGLE/renderer/metal/DisplayMtl.h" #include "libANGLE/renderer/metal/mtl_common.h" #include "libANGLE/renderer/metal/shaders/rewrite_indices_shared.h" + namespace rx { @@ -21,7 +23,8 @@ { constexpr size_t kInitialIndexBufferSize = 0xFFFF; // Initial 64k pool. } -static inline uint primCountForIndexCount(const uint fixIndexBufferKey, const uint indexCount) +static inline uint32_t primCountForIndexCount(const uint fixIndexBufferKey, + const GLsizei indexCount) { const uint fixIndexBufferMode = (fixIndexBufferKey >> MtlFixIndexBufferKeyModeShift) & MtlFixIndexBufferKeyModeMask; @@ -33,45 +36,57 @@ case MtlFixIndexBufferKeyLines: return indexCount / 2; case MtlFixIndexBufferKeyLineStrip: - return (uint)MAX(0, (int)indexCount - 1); + // Prevent underflow with subtraction and avoid casting to a signed type + return std::max(indexCount - 1, 0); case MtlFixIndexBufferKeyLineLoop: - return (uint)MAX(0, (int)indexCount); + return indexCount; case MtlFixIndexBufferKeyTriangles: return indexCount / 3; case MtlFixIndexBufferKeyTriangleStrip: - return (uint)MAX(0, (int)indexCount - 2); + // Prevent underflow with subtraction and avoid casting to a signed type + return std::max(indexCount - 2, 0); case MtlFixIndexBufferKeyTriangleFan: - return (uint)MAX(0, (int)indexCount - 2); + // Prevent underflow with subtraction and avoid casting to a signed type + return std::max(indexCount - 2, 0); default: ASSERT(false); return 0; } } -static inline uint indexCountForPrimCount(const uint fixIndexBufferKey, const uint primCount) +static inline bool indexCountForPrimCount(const uint fixIndexBufferKey, + const uint32_t primCount, + uint32_t *outIndexCount) { + const uint fixIndexBufferMode = (fixIndexBufferKey >> MtlFixIndexBufferKeyModeShift) & MtlFixIndexBufferKeyModeMask; + + uint32_t indicesPerPrimitive = 0; switch (fixIndexBufferMode) { case MtlFixIndexBufferKeyPoints: - return primCount; + indicesPerPrimitive = 1; + break; case MtlFixIndexBufferKeyLines: - return primCount * 2; case MtlFixIndexBufferKeyLineStrip: - return primCount * 2; case MtlFixIndexBufferKeyLineLoop: - return primCount * 2; + indicesPerPrimitive = 2; + break; case MtlFixIndexBufferKeyTriangles: - return primCount * 3; case MtlFixIndexBufferKeyTriangleStrip: - return primCount * 3; case MtlFixIndexBufferKeyTriangleFan: - return primCount * 3; + indicesPerPrimitive = 3;
Regression Test / PoC
diff --git a/src/tests/gl_tests/ProvokingVertexTest.cpp b/src/tests/gl_tests/ProvokingVertexTest.cpp
index 59b9128..fc5fb4a 100644
--- a/src/tests/gl_tests/ProvokingVertexTest.cpp
+++ b/src/tests/gl_tests/ProvokingVertexTest.cpp
@@ -745,9 +745,35 @@
checkFlatQuadColors(kWidth, kHeight, GLColor::red, GLColor::green);
}
+// Only run these tests on Metal. Other backends tend to time out the test suite but not crash.
+class ProvokingVertexTestMetal : public ProvokingVertexTest
+{};
+
+// Test that a very large draw call with flat shading doesn't cause an integer overflow in the Metal
+// backend.
+TEST_P(ProvokingVertexTestMetal, LargeDrawTriangleFan)
+{
+ GLsizei count = 1431655768;
+ glUseProgram(mProgram);
+ glDrawArrays(GL_TRIANGLE_FAN, 0, count);
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+}
+
+// Test that a very large draw call with flat shading doesn't cause an integer overflow in the Metal
+// backend.
+TEST_P(ProvokingVertexTestMetal, LargeDrawTriangleStrip)
+{
+ GLsizei count = 1431655768;
+ glUseProgram(mProgram);
+ glDrawArrays(GL_TRIANGLE_STRIP, 0, count);
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ProvokingVertexTest);
ANGLE_INSTANTIATE_TEST_ES3(ProvokingVertexTest);
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ProvokingVertexTestMetal);
+ANGLE_INSTANTIATE_TEST(ProvokingVertexTestMetal, ES3_METAL());
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ProvokingVertexBufferUpdateTest);
ANGLE_INSTANTIATE_TEST_ES3(ProvokingVertexBufferUpdateTest);
Original Bug Report
ANGLE Metal ProvokingVertexHelper uint32 Overflow causes GPU OOB Write
Report description
ANGLE Metal ProvokingVertexHelper uint32 Overflow causes GPU OOB Write
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
A uint32 integer overflow in ANGLE’s Metal backend ProvokingVertexHelper::generateIndexBuffer() allows any WebGL 2 webpage to trigger a ~17GB GPU heap buffer overflow. The function indexCountForPrimCount() computes primCount * 3 in 32-bit unsigned arithmetic. With count=1431655768, primCount = count - 2 = 1431655766, then primCount * 3 = 0x100000002 wraps to 2, allocating 8 bytes while the GPU compute shader dispatches 1.4 billion primitives writing ~17GB past the allocation.
This produces a controlled write primitive: the attacker-chosen firstVertex value is written to every 3rd uint32 across ~17GB of GPU memory, corrupting other WebGL buffers. The corrupted data is readable from JavaScript via getBufferSubData().
The shader must use a flat interpolation qualifier to trigger the ProvokingVertexHelper code path (ANGLE’s provoking vertex convention rewrite). Without flat, ANGLE routes through the safe TriangleFanBoundCheck in mtl_utils.mm which correctly rejects the oversized draw. With flat, ANGLE routes through the vulnerable indexCountForPrimCount in ProvokingVertexHelper.mm. No user interaction, no permissions, no flags required. Runs in the GPU process and affects all macOS (Intel + Apple Silicon confirmed), Metal backend only.
Affected Code
Primary overflow in ProvokingVertexHelper.mm:51-75:
static inline uint indexCountForPrimCount(const uint fixIndexBufferKey, const uint primCount)
{
switch (fixIndexBufferMode) {
case MtlFixIndexBufferKeyTriangleStrip:
return primCount * 3; // uint32 OVERFLOW when primCount > UINT32_MAX/3
case MtlFixIndexBufferKeyTriangleFan:
return primCount * 3; // same overflow
}
}
Undersized allocation in ProvokingVertexHelper.mm:238-244:
uint primCount = primCountForIndexCount(indexBufferKey, (uint32_t)indexCount);
uint newIndexCount = indexCountForPrimCount(indexBufferKey, primCount); // wraps to 2
ANGLE_TRY(mIndexBuffers.allocate(context, newIndexCount * indexSize, ...)); // 8 bytes!
// GPU dispatch: 22.4M thread groups writing ~17GB to the 8-byte buffer
Silent error handling in ContextMtl.mm:2956-2961: checkCommandBufferError() only checks MTLCommandBufferErrorOutOfMemory. All other errors (including GPU timeout/internal error from the 17GB write) are silently ignored. Corrupted buffers remain accessible.
Steps to Reproduce
Prerequisites: Chrome on macOS (any version using ANGLE Metal backend, Chrome 96+).
Minimal overflow (overflow_minimal.html) - no flags required:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
"file:///path/to/overflow_minimal.html"
- Open
overflow_minimal.htmlin Chrome on macOS - The overflow triggers automatically on page load
- Expected:
drawArraysaccepted with GL error 0x0000, then after 30s: “Context survived. ANGLE silently handled the GPU timeout.” Chrome’s stderr will show:mtl_command_buffer.mm:693 (onCommandBufferCompleted): Completed MTLCommandBuffer failed, and error is Caused GPU Timeout Error (00000002:kIOAccelCommandBufferCallbackErrorTimeout).
Write primitive (write_primitive.html) - requires --in-process-gpu:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --in-process-gpu \
"file:///path/to/write_primitive.html"
- Open
write_primitive.htmlin Chrome on macOS with--in-process-gpu - Click “Run Write Primitive Test”
- Wait ~30 seconds (15s GPU write + readback)
- Expected result: 200/600 victim buffers show corruption. Attacker-chosen
firstVertexvalue0x2AAAAAA0appears every 3rd uint32 in victim buffers, readable viagetBufferSubData(). The output will showCONFIRMED: Attacker value 0x2AAAAAA0 in 200 victim buffers. - If batch 0 shows 0 corrupted, click again without refreshing (race timing).
The --in-process-gpu flag runs the GPU process in-process, which changes GPU error recovery timing and allows readback of corrupted buffer data before the driver’s error recovery cleans it up. On Chrome stable without this flag, the overflow fires and corrupts cross-buffer memory (visible as 0xDEADBEEF markers overwritten to zeros in 297/600 buffers), but the GPU error recovery zeros corrupted regions before JavaScript can read the overflow’s index pattern. The --in-process-gpu flag is NOT required for the bug to trigger - the overflow is reachable from any renderer via normal IPC to the GPU process.
Tested on:
- Chrome 144.0.7559.133 (Official Build) (x86_64), macOS 15.7.2
- Chrome 146.0.7676.0 (Developer Build) (x86_64), macOS 15.7.2
Note on hardware variability: The write primitive PoC requires winning a race condition to place victim buffers in the GPU overflow write path. On my test hardware (Intel/AMD MacBook Pro 15,3), this succeeds on the first attempt with 200/600 buffers corrupted (batch 0: 200/200). On Apple Silicon (M1), the GPU error recovery mechanism (kIOGPUCommandBufferCallbackErrorInnocentVictim) kills in-flight command buffers as collateral damage, preventing readback of corrupted data in our testing - though the overflow itself still fires (visible as GPU reset in kernel logs). The overflow_minimal.html PoC is the simplest way to confirm the overflow triggers on any hardware.
System DoS (no flags required)
Opening any of the PoCs in default Chrome (no flags) may trigger kernel-level GPU resets or “visual snow”. The macOS kernel GPU restart report directly names genIndexBuffer as the hung shader. This was followed by WindowServer watchdog timeout and full system freeze requiring forced reboot.
Fix
Promote primCount * 3 to 64-bit and validate before allocation:
static inline uint64_t indexCountForPrimCount(const uint fixIndexBufferKey, const uint primCount)
{
switch (fixIndexBufferMode) {
case MtlFixIndexBufferKeyTriangleStrip:
case MtlFixIndexBufferKeyTriangleFan:
return static_cast<uint64_t>(primCount) * 3;
}
}
// In generateIndexBuffer():
uint64_t newIndexCount = indexCountForPrimCount(indexBufferKey, primCount);
ANGLE_CHECK(context, newIndexCount <= std::numeric_limits<uint32_t>::max(),
"Index count overflow in provoking vertex rewrite", GL_OUT_OF_MEMORY);
Note: A safe pattern already exists in ANGLE at mtl_utils.mm:1577-1598 (TriangleFanBoundCheck + GetTriangleFanIndicesCount) which uses size_t arithmetic with explicit overflow checks. The vulnerable indexCountForPrimCount deviates from this existing safe pattern.
Additionally, checkCommandBufferError() at ContextMtl.mm:2956 should handle MTLCommandBufferErrorTimeout and other error codes instead of silently ignoring them.
Bisect
Introducing commit: da3db87ec4a491a650d86d3d2776466a48135972 (“Upstream latest changes to Metal backend from Apple to 7/1/2021”), committed October 1, 2021. Review: https://chromium-review.googlesource.com/c/angle/angle/+/3167010. Bug: angleproject:6395. The primCount * 3 overflow has been present since this initial commit of ProvokingVertexHelper.mm. The preconditionIndexBuffer path (line 193-199) has the same overflow.
Earliest affected stable release: Chrome 96 (stable November 16, 2021). Chrome 95 branched September 9, 2021, before this commit landed. Chrome 96 branched October 7, 2021, after.
The rewrite_indices.metal compute shader lacks ANGLE_KERNEL_GUARD bounds checking. While it has a manual if(prim < primCount) guard, this checks against the correct (non-overflowed) primitive count, not the output buffer size. The safe TriangleFanBoundCheck pattern at mtl_utils.mm:1577 predates this code (Copyright 2019).
Impact analysis
GPU process controlled write primitive from any webpage.
| Primitive | PoC | Evidence |
|---|---|---|
| Write | write_primitive.html |
193/200 victim buffers contain attacker-chosen firstVertex value 0x2AAAAAA0 every 3rd uint32, readable via getBufferSubData(). |
| System DoS | Any PoC, no flags | 10 kernel GPU resets + 5 WindowServer crashes in one session. Kernel report names genIndexBuffer as hung shader. Full system freeze requiring forced reboot. |
Write characterization: the overflow writes ~17GB sequentially through GPU VA space. The TRIANGLE_FAN index rewrite pattern {firstVertex+prim+2, firstVertex, firstVertex+prim+1} means every 3rd uint32 = the attacker-chosen firstVertex value exactly. The other 2/3 are sequential counters offset by firstVertex. Data is readable from JavaScript via getBufferSubData(). The write is sequential and the attacker controls 1/3 of the written values.
The cause
What version of Chrome have you found the security issue in?
Chrome 144.0.7559.133 Stable
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)
How would you like to be publicly acknowledged for your report?
cinzinga