CVE-2026-14386
Overview
Files Changed
scripts/code_generation_hashes/Metal_default_shaders.jsonsrc/libANGLE/renderer/metal/ContextMtl.mmsrc/libANGLE/renderer/metal/VertexArrayMtl.hsrc/libANGLE/renderer/metal/VertexArrayMtl.mm
Patch
From a7194a87823c73ad6fc915e87e292332bd8464b0 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Mon, 04 May 2026 18:43:18 +0000
Subject: [PATCH] [ANGLE][Metal] Fix GPU memory disclosure in index rewriting
When rewriting index buffers for provoking vertex workarounds, the
Metal backend could leave portions of the allocated output buffer
uninitialized. Because these buffers are recycled from a BufferPool
without being cleared, unwritten slots retained stale indices from
previous operations.
This caused a GPU memory disclosure vulnerability because:
1. The `fixIndexBuffer` compute kernel returned early upon encountering
a primitive restart, skipping writes to reserved output slots.
2. The `ContextMtl::drawElementsImpl` logic sliced the draw commands
based on the original index buffer's restart points, but failed to
transform the slice `count` and `offset` to account for the buffer
expansion (e.g., TriangleStrip to Triangles).
This CL fixes the issue by:
- Updating `outputPrimitive` in `rewrite_indices.metal` to explicitly
write the correct `restartIndex` (either 0xFFFF or 0xFFFFFFFF) into
all output slots whenever a thread returns early or skips indices.
- Transforming the `DrawCommandRange` slices in `ContextMtl.mm` when
the index buffer has been expanded, ensuring subsequent indexed
draw calls correctly index into the expanded buffer layout.
- Adding a regression test.
Fixed: b/499047960
Change-Id: I2034492e2016c91064025f2b0705767d1da21783
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7814681
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/scripts/code_generation_hashes/Metal_default_shaders.json b/scripts/code_generation_hashes/Metal_default_shaders.json
index 504c6e9..79e6c2a 100644
--- a/scripts/code_generation_hashes/Metal_default_shaders.json
+++ b/scripts/code_generation_hashes/Metal_default_shaders.json
@@ -22,11 +22,11 @@
"src/libANGLE/renderer/metal/shaders/gen_mtl_internal_shaders.py":
"368cb14cf2005e682edc4d4afce8d7b9",
"src/libANGLE/renderer/metal/shaders/mtl_internal_shaders_autogen.metal":
- "9056c1be9b9afffe12fe0f0bb3f1fce4",
+ "0b9c23d53bdc8739f660e492907bc494",
"src/libANGLE/renderer/metal/shaders/mtl_internal_shaders_src_autogen.h":
- "9aedbda4c5d3c6689f606f40c19e1fce",
+ "9c768791b9d0a599e86e48c25663f62c",
"src/libANGLE/renderer/metal/shaders/rewrite_indices.metal":
- "a29210b023ed3393e1342d94e338d449",
+ "94585ecc47d4baff8acb23e52fc85055",
"src/libANGLE/renderer/metal/shaders/visibility.metal":
"b82aa740cf4b0aed606aacef1024beea"
}
diff --git a/src/libANGLE/renderer/metal/ContextMtl.mm b/src/libANGLE/renderer/metal/ContextMtl.mm
index c5645c3..35bc62f 100644
--- a/src/libANGLE/renderer/metal/ContextMtl.mm
+++ b/src/libANGLE/renderer/metal/ContextMtl.mm
@@ -784,6 +784,7 @@
size_t provokingVertexAdditionalOffset = 0;
+ gl::PrimitiveMode originalMode = mode;
if (requiresIndexRewrite(context->getState(), mode))
{
// Line strips and triangle strips are rewritten to flat line arrays and tri arrays.
@@ -801,8 +802,10 @@
// indices.
// It's safe to use idxBuffer in this case, as it will contain the same count and restart ranges
// as drawIdxBuffer.
- const std::vector<DrawCommandRange> drawCommands = mVertexArray->getDrawIndices(
- context, type, convertedType, mode, idxBuffer, convertedCounti32, convertedOffset);
+ const std::vector<DrawCommandRange> drawCommands =
+ mVertexArray->getDrawIndices(context, type, convertedType, originalMode, mode, idxBuffer,
+ (uint32_t)count, convertedOffset);
+
bool isNoOp = false;
ANGLE_TRY(setupDraw(context, 0, count, instances, type, indices, false, &isNoOp));
if (!isNoOp)
diff --git a/src/libANGLE/renderer/metal/VertexArrayMtl.h b/src/libANGLE/renderer/metal/VertexArrayMtl.h
index b7981ba..f08ff02 100644
--- a/src/libANGLE/renderer/metal/VertexArrayMtl.h
+++ b/src/libANGLE/renderer/metal/VertexArrayMtl.h
@@ -65,7 +65,8 @@
std::vector<DrawCommandRange> getDrawIndices(const gl::Context *glContext,
gl::DrawElementsType originalIndexType,
gl::DrawElementsType indexType,
- gl::PrimitiveMode primitiveMode,
+ gl::PrimitiveMode originalMode,
+ gl::PrimitiveMode mode,
mtl::BufferRef idxBuffer,
uint32_t indexCount,
size_t offset);
diff --git a/src/libANGLE/renderer/metal/VertexArrayMtl.mm b/src/libANGLE/renderer/metal/VertexArrayMtl.mm
index a0e48bf..7f103f0 100644
--- a/src/libANGLE/renderer/metal/VertexArrayMtl.mm
+++ b/src/libANGLE/renderer/metal/VertexArrayMtl.mm
@@ -743,7 +743,8 @@
std::vector<DrawCommandRange> VertexArrayMtl::getDrawIndices(const gl::Context *glContext,
gl::DrawElementsType originalIndexType,
gl::DrawElementsType indexType,
- gl::PrimitiveMode primitiveMode,
+ gl::PrimitiveMode originalMode,
+ gl::PrimitiveMode mode,
mtl::BufferRef clientBuffer,
uint32_t indexCount,
size_t offset)
@@ -753,14 +754,15 @@
// The indexed draw needs to be split to separate draw commands in case primitive restart is
// enabled and the drawn primitive supports primitive restart. Otherwise the whole indexed draw
// can be sent as one draw command.
- bool isSimpleType = primitiveMode == gl::PrimitiveMode::Points ||
- primitiveMode == gl::PrimitiveMode::Lines ||
- primitiveMode == gl::PrimitiveMode::Triangles;
- if (!isSimpleType || !glContext->getState().isPrimitiveRestartEnabled())
+ bool isSimpleType = mode == gl::PrimitiveMode::Points || mode == gl::PrimitiveMode::Lines ||
+ mode == gl::PrimitiveMode::Triangles;
+ bool indicesRewritten = (originalMode != mode);
+ if ((!isSimpleType && !indicesRewritten) || !glContext->getState().isPrimitiveRestartEnabled())
{
drawCommands.push_back({indexCount, offset});
return drawCommands;
}
+
const std::vector<IndexRange> *restartIndices;
std::vector<IndexRange> clientIndexRange;
const gl::Buffer *glElementArrayBuffer = getElementArrayBuffer();
@@ -775,27 +777,95 @@
BufferMtl::getRestartIndicesFromClientData(contextMtl, indexType, clientBuffer);
restartIndices = &clientIndexRange;
}
- // Reminder, offset is in bytes, not elements.
- // Slice draw commands based off of indices.
+
uint32_t nIndicesPerPrimitive;
- switch (primitiveMode)
+ // If the indices were rewritten to fix the provoking vertex convention, the index buffer
+ // might have been expanded (e.g. from TriangleStrip to Triangles). The restart indices
+ // we found earlier are based on the original unexpanded buffer. We must transform the slices
+ // we find to match the layout of the expanded rewritten buffer.
+ uint32_t factor = 1;
+ uint32_t stripExclude = 0;
+
+ if (indicesRewritten)
{
- case gl::PrimitiveMode::Points:
- nIndicesPerPrimitive = 1;
- break;
- case gl::PrimitiveMode::Lines:
- nIndicesPerPrimitive = 2;
- break;
- case gl::PrimitiveMode::Triangles:
- nIndicesPerPrimitive = 3;
- break;
- default:
- UNREACHABLE();
- return drawCommands;
+ switch (mode)
+ {
+ case gl::PrimitiveMode::Lines:
+ if (originalMode == gl::PrimitiveMode::LineStrip ||
+ originalMode == gl::PrimitiveMode::LineLoop)
+ {
+ nIndicesPerPrimitive = 1;
+ // LineStrip/LineLoop to Lines expansion:
+ // N indices produce max(N-1, 0) lines.
+ // Rewritten buffer contains max(N-1, 0) * 2 indices.
+ factor = 2;
+ stripExclude = 1;
+ }
+ else
+ {
+ UNREACHABLE();
+ return drawCommands;
+ }
+ break;
+ case gl::PrimitiveMode::Triangles:
+ if (originalMode == gl::PrimitiveMode::TriangleStrip ||
+ originalMode == gl::PrimitiveMode::TriangleFan)
+ {
+ nIndicesPerPrimitive = 1;
+ // TriangleStrip/TriangleFan to Triangles expansion:
+ // N indices produce max(N-2, 0) triangles.
+ // Rewritten buffer contains max(N-2, 0) * 3 indices.
+ factor = 3;
+ stripExclude = 2;
+ }
+ else
+ {
+ UNREACHABLE();
+ return drawCommands;
+ }
+ break;
+ default:
+ UNREACHABLE();
+ return drawCommands;
+ }
}
+ else
Regression Test / PoC
diff --git a/src/tests/gl_tests/ProvokingVertexTest.cpp b/src/tests/gl_tests/ProvokingVertexTest.cpp
index fc5fb4a..f7dfc8f 100644
--- a/src/tests/gl_tests/ProvokingVertexTest.cpp
+++ b/src/tests/gl_tests/ProvokingVertexTest.cpp
@@ -768,6 +768,44 @@
glDrawArrays(GL_TRIANGLE_STRIP, 0, count);
}
+// Test that drawing TriangleStrip with primitive restart and flat shading doesn't read out of
+// bounds. Regression test for GPU memory disclosure vulnerability during index rewriting.
+TEST_P(ProvokingVertexTestMetal, PrimitiveRestartWithTriangleStrip)
+{
+ glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
+
+ GLfloat halfPixel = 1.0f / static_cast<GLfloat>(getWindowWidth());
+
+ // 3 vertices
+ GLint vertexData[] = {1, 2, 3};
+ GLfloat positionData[] = {-1.0f + halfPixel, -1.0f, -1.0f + halfPixel, 1.0f,
+ 1.0f - halfPixel, -1.0f};
+
+ glVertexAttribIPointer(mIntAttribLocation, 1, GL_INT, 0, vertexData);
+
+ GLint positionLocation = glGetAttribLocation(mProgram, "position");
+ glEnableVertexAttribArray(positionLocation);
+ glVertexAttribPointer(positionLocation, 2, GL_FLOAT, GL_FALSE, 0, positionData);
+
+ // [0, 1, 2, R, R, R, R] -> Triangle Strip with 10 indices
+ const GLuint R = 0xFFFFFFFF;
+ GLuint indexData[] = {0, 1, 2, R, R, R, R, R, R, R};
+
+ GLBuffer indexBuffer;
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indexData), indexData, GL_STATIC_DRAW);
+
+ glUseProgram(mProgram);
+ glDrawElements(GL_TRIANGLE_STRIP, 10, GL_UNSIGNED_INT, 0);
+
+ ASSERT_GL_NO_ERROR();
+
+ // Verify it rendered the single triangle correctly
+ GLint pixelValue[4] = {0};
+ glReadPixels(0, 0, 1, 1, GL_RGBA_INTEGER, GL_INT, &pixelValue);
+ EXPECT_EQ(vertexData[2], pixelValue[0]); // Flat shading with provoking vertex last (index 2)
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(ProvokingVertexTest);
ANGLE_INSTANTIATE_TEST_ES3(ProvokingVertexTest);
Original Bug Report
Potential GPU memory disclosure in ANGLE Metal via uninitialized index buffer rewrite
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 security team.
Overview: A potential vulnerability in ANGLE’s Metal backend allows a WebGL program to disclose GPU memory. During index buffer rewriting for primitive restarts with flat varyings, uninitialized buffer slots containing attacker-controlled stale data are bypassed by an early return and subsequently drawn due to a slicing logic error. Because Metal does not enforce vertex buffer bounds checking, these stale indices can cause out-of-bounds reads into adjacent GPU memory.
Affected files:
third_party/angle/src/libANGLE/renderer/metal/shaders/rewrite_indices.metalthird_party/angle/src/libANGLE/renderer/metal/ProvokingVertexHelper.mmthird_party/angle/src/libANGLE/renderer/metal/ContextMtl.mmthird_party/angle/src/libANGLE/renderer/metal/VertexArrayMtl.mmthird_party/angle/src/libANGLE/renderer/metal/mtl_buffer_pool.mm
Estimated timestamp from git blame: 2021-10-07
Overview
There is a potential information disclosure vulnerability in ANGLE’s Metal backend. The issue occurs when WebGL2 applications use gl.drawElements with primitive restart enabled and flat varyings (which defaults to LastVertexConvention). To emulate this convention on Metal, ANGLE rewrites the index buffer. A combination of uninitialized memory reuse, an early return in the rewrite compute kernel, and a scaling bug in draw command generation allows an attacker to fetch out-of-bounds vertex attributes from the GPU heap.
Note: The following analysis and steps are potential, as our tooling agent does not currently have the capability to run code to produce a fully working proof-of-concept.
Technical Details
- Uninitialized Buffer Reuse: During index buffer rewriting,
ProvokingVertexHelper::preconditionIndexBuffersub-allocates memory for the new index buffer usingmtl::BufferPool::allocate. When reusing previously freed buffers, this pool does not zero or clear the memory (third_party/angle/src/libANGLE/renderer/metal/mtl_buffer_pool.mm). - Early Return in Compute Kernel: The index rewriting kernel
fixIndexBuffer(third_party/angle/src/libANGLE/renderer/metal/shaders/rewrite_indices.metal) processes primitives. ForTriangleStrip, if a thread encounters the primitive restart index, it hits an early return (rewrite_indices.metal:177-181) before writing anything to its assigned output slots. Consequently, these slots retain whatever stale data was previously in theBufferPoolmemory. - Draw Command Slicing Bug: After rewriting,
ContextMtl::drawElementsImplcallsVertexArrayMtl::getDrawIndicesto split the draw around the restart indices. It passes the expanded, rewritten index count (a triangle strip scales up by ~3x when converted to a triangle list) but still relies on the restart offsets from the original, unscaled index buffer.getDrawIndices(VertexArrayMtl.mm:800-845) fails to account for this scaling factor, calculating incorrect slice boundaries that encompass the unwritten, stale slots in the rewritten buffer. - Validation Bypass: The frontend CPU index validation (
ComputeTypedIndexRange) correctly ignores the restart index. As long as the non-restart indices in the original buffer are within bounds, the draw call passes validation. - Out-of-Bounds Fetch: In ANGLE’s Metal backend,
robustBufferAccessBehaviorKHRis explicitly set tofalse, and vertex attribute fetches rely on standard Metal[[attribute(n)]]qualifiers which lack implicit bounds checking. When the GPU executes the flawed draw commands, it reads the stale, attacker-controlled indices from the skipped slots, causing an out-of-bounds read into adjacent GPU memory.
Potential Steps to Trigger
An attacker could potentially exploit this by performing the following sequence from a malicious WebGL webpage:
- Prime the BufferPool: Issue a large, crafted draw call or buffer upload designed to allocate a dynamic buffer in the Metal backend and fill it with very large, specific integer values (designed to point out-of-bounds).
- Free the Memory: Allow the operation to complete, returning the memory to the
BufferPoolfree list. - Trigger the Bug: Enable
gl.PRIMITIVE_RESTART_FIXED_INDEX, set up a shader program withflatvaryings, and issue agl.drawElementscall usinggl.TRIANGLE_STRIPwhere some indices are the primitive restart index (0xFFFFFFFF). - Exfiltrate: The out-of-bounds data fetched by the GPU will be passed to the fragment shader via the
flatvarying. Render this data to a framebuffer and read it back to JavaScript usinggl.readPixels().
Suggested Fix
- Fix Compute Kernel Output: Modify
rewrite_indices.metalso that iffoundRestartis true, instead of returning early and leaving memory uninitialized, the kernel writes degenerate indices (e.g., zeroes, or repeating the same valid index) into the output slots to ensure no stale data is left behind. - Fix Draw Command Slicing: Correct the logic in
VertexArrayMtl::getDrawIndices(or howContextMtlcalls it) to account for the topology scaling factor (e.g., $N$ to $3N-6$ for triangle strips) when calculatingnIndicesInSliceandcurrentIndexOffseton a rewritten index buffer. - Defense in Depth: Consider clearing or zeroing out memory retrieved from the free list in
mtl::BufferPool::allocateif it is being used for operations that may perform sparse/partial writes.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.