CVE-2026-14400
Overview
Background
- Transform Feedback (XFB)
- an OpenGL ES mechanism that captures the outputs of the vertex shader into a bound buffer (
GL_TRANSFORM_FEEDBACK_BUFFER) during a draw call. - Independent primitives
- primitive modes like
PrimitiveMode::TrianglesandPrimitiveMode::Lineswhere each primitive consumes a fixed number of vertices, so a vertex count not evenly divisible leaves incomplete leftover vertices. - `angle::CheckedNumeric`
- an ANGLE arithmetic wrapper that detects overflow and lets callers extract a validated result via
ValueOrDie(). - `ContextMtl`
- the ANGLE Metal backend’s per-context object that translates GL draw calls into Metal command encoding.
Root Cause Analysis
When transform feedback is active and unpaused in the Metal backend, the draw path in ContextMtl.mm used the raw application-supplied vertex count to size and drive XFB output. For independent primitive modes such as triangles and lines, a count that is not a multiple of the per-primitive vertex count produces trailing incomplete primitives, and processing those leftover vertices caused incorrect buffer offset calculations for the transform feedback output buffer. This violated the invariant that XFB only emits complete primitives, so the backend could compute offsets or write ranges beyond what the buffer was sized to hold, yielding an out-of-bounds write.
The fix computes gl::GetVerticesNeededForDraw(mode, count, 1) and rounds count down to the largest number of vertices that form complete primitives before any XFB processing occurs. Because Metal only supports up to ES 3.0 (where the vertex shader has no side effects), dropping the leftover vertices is observationally safe, and Points mode is unaffected since every point is a complete primitive.
count into transform feedback buffer sizing for independent primitives; the fix rounds the count down to whole-primitive granularity via gl::GetVerticesNeededForDraw so no incomplete primitive ever drives an offset or write.Attack Path
- Set up transform feedback
A WebGL/ES 3.0 client compiles a program with a transform feedback varying, binds a
GL_TRANSFORM_FEEDBACK_BUFFERsized for whole primitives, and callsglBeginTransformFeedback(GL_TRIANGLES). - Issue a mismatched draw
The client calls
glDrawArraysInstanced(GL_TRIANGLES, 0, count, instances)with acountthat is not a multiple of 3 (e.g.11), leaving incomplete primitives per instance. - Trigger the bad offset math
In
ContextMtl.mmthe Metal backend processes the leftover vertices for XFB, causing incorrect buffer offset calculations for the output buffer. - Out-of-bounds write The miscalculated offset drives a write past the transform feedback buffer’s bounds. (inferred) The specific sink offset and downstream consequence are not shown by the diff.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
Contextsrc/libANGLE/TransformFeedback.h |
modified | |
Programsrc/libANGLE/TransformFeedback.h |
modified | |
TransformFeedbackStatesrc/libANGLE/TransformFeedback.h |
modified |
Files Changed
src/libANGLE/TransformFeedback.hsrc/libANGLE/renderer/metal/ContextMtl.mmsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/gl_tests/TransformFeedbackTest.cpp
Audit Directions
- Un-rounded counts in XFB pathsAudit other ANGLE backends (Vulkan, GL, D3D) and draw entry points for transform feedback buffer sizing or offset math that uses the raw
countinstead of a whole-primitive rounded value. - Independent-primitive assumptionsLook for code that assumes vertex counts divide evenly into
Triangles/Linesprimitives without validating divisibility, especially where the remainder influences buffer offsets or write ranges. - Instanced draw scalingReview paths that multiply per-instance vertex counts across instances, where a per-instance rounding error compounds and can push cumulative offsets past a bound-checked buffer.
Patch
From 1618174f833943eb9c90733990eb4adc7e46dfd4 Mon Sep 17 00:00:00 2001 From: Le Hoang Quyen <[email protected]> Date: Fri, 15 May 2026 21:10:01 +0800 Subject: [PATCH] Metal: Fix potential OOB write in Transform Feedback Fixes a bug where incomplete primitives in transform feedback could cause incorrect buffer offset calculations, potentially leading to out-of-bounds writes in the Metal backend. We now round down the vertex count for independent primitives when XFB is active. Bug: angleproject:513010645 Change-Id: I82b91500108fb1a7059e43479e7af87702d33b6d Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7851496 Commit-Queue: Quyen Le <[email protected]> Reviewed-by: Kenneth Russell <[email protected]> Commit-Queue: Shahbaz Youssefi <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Auto-Submit: Quyen Le <[email protected]> Reviewed-by: Geoff Lang <[email protected]> --- diff --git a/src/libANGLE/TransformFeedback.h b/src/libANGLE/TransformFeedback.h index 8028d82..d78d6d6 100644 --- a/src/libANGLE/TransformFeedback.h +++ b/src/libANGLE/TransformFeedback.h @@ -11,6 +11,7 @@ #include "common/PackedEnums.h" #include "common/angleutils.h" +#include "common/mathutil.h" #include "libANGLE/Debug.h" #include "angle_gl.h" @@ -29,6 +30,10 @@ class Context; class Program; +angle::CheckedNumeric<GLsizeiptr> GetVerticesNeededForDraw(PrimitiveMode primitiveMode, + GLsizei count, + GLsizei primcount); + class TransformFeedbackState final : angle::NonCopyable { public: diff --git a/src/libANGLE/renderer/metal/ContextMtl.mm b/src/libANGLE/renderer/metal/ContextMtl.mm index 3fb5453..753e332 100644 --- a/src/libANGLE/renderer/metal/ContextMtl.mm +++ b/src/libANGLE/renderer/metal/ContextMtl.mm @@ -450,6 +450,23 @@ // Real instances count. Zero means this is not instanced draw. GLsizei instanceCount = instances ? instances : 1; + if (mState.isTransformFeedbackActiveUnpaused()) + { + // ES 3.0 requires XFB mode to be points, lines, or triangles. + // For this workaround, we assume the draw mode is also one of these. + CHECK(mode == gl::PrimitiveMode::Triangles || mode == gl::PrimitiveMode::Lines || + mode == gl::PrimitiveMode::Points); + + // Transform feedback only outputs complete primitives. For independent primitives + // (triangles and lines), any leftover vertices do not form a primitive and should + // not be processed for transform feedback. Since Metal only supports up to ES 3.0 + // (where VS cannot have side effects), it is safe to round down the count to avoid + // processing them and hitting bugs with incomplete primitives in XFB. + // The helper function also returns the correct value for Points mode (no changes). + GLsizeiptr verticesNeeded = gl::GetVerticesNeededForDraw(mode, count, 1).ValueOrDie(); + count = static_cast<GLsizei>(verticesNeeded); + } + if (mCullAllPolygons && gl::IsPolygonMode(mode)) { return angle::Result::Continue; diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index 93de867..41a9ab7 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -2742,6 +2742,13 @@ 500930576 OPENGL : ReadPixelsTest.LargeTexture/* = SKIP // Crashes inside AMD Metal driver when copying the texture -> buffer 500930576 METAL AMD : ReadPixelsTest.LargeTexture/* = SKIP + +// XFB with incomplete primitives fail on GL and Vulkan +513536751 INTEL OPENGL : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP +// fails on Vulkan without transform feedback extension +513536751 VULKAN : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/*NoSupportsTransformFeedbackExtension* = SKIP +513536751 VULKAN SWIFTSHADER : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP + // Fails test on Metal 499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB/* = SKIP diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp index bc13f64..63126cd 100644 --- a/src/tests/gl_tests/TransformFeedbackTest.cpp +++ b/src/tests/gl_tests/TransformFeedbackTest.cpp @@ -5273,6 +5273,86 @@ glFinish(); } +// Test that transform feedback with instanced drawing and count that results in incomplete +// primitives works correctly when the buffer is large enough to store all complete primitives from +// all instances. +TEST_P(TransformFeedbackTest, InstancedOverflowIncompletePrimitive) +{ + // We need ES3 for transform feedback and instancing. + + constexpr char kVS[] = R"(#version 300 es + out float out_value; + void main() { + out_value = float(gl_VertexID) + float(gl_InstanceID) * 100.0; + gl_Position = vec4(0.0, 0.0, 0.0, 1.0); + })"; + + constexpr char kFS[] = R"(#version 300 es + out mediump vec4 color; + void main() { + color = vec4(0.0, 1.0, 0.0, 1.0); + })"; + + std::vector<std::string> tfVaryings; + tfVaryings.push_back("out_value"); + + mProgram = CompileProgramWithTransformFeedback(kVS, kFS, tfVaryings, GL_INTERLEAVED_ATTRIBS); + ASSERT_NE(0u, mProgram); + + glUseProgram(mProgram); + + // Buffer size: enough for 18 vertices (3 triangles * 3 vertices * 2 instances). + // Each vertex writes 1 float. + const size_t kBufferSize = 18 * sizeof(float); + + GLBuffer tfBuffer; + glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, tfBuffer); + glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kBufferSize, nullptr, GL_STATIC_DRAW); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, tfBuffer); + + GLQuery primitivesWrittenQuery; + glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, primitivesWrittenQuery); + + glBeginTransformFeedback(GL_TRIANGLES); + + // Draw 11 vertices (3 triangles and 2 vertices), 2 instances. + // Total expected vertices if buffer was large enough: + // Instance 0: 9 vertices (3 triangles) + 2 vertices (incomplete) -> only 9 written. + // Instance 1: 9 vertices (3 triangles) + 2 vertices (incomplete) -> only 9 written. + glDrawArraysInstanced(GL_TRIANGLES, 0, 11, 2); + + glEndTransformFeedback(); + glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN); + ASSERT_GL_NO_ERROR(); + + // Map buffer and check results. + void *mappedBuffer = + glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kBufferSize, GL_MAP_READ_BIT); + ASSERT_NE(nullptr, mappedBuffer); + + float *mappedFloats = static_cast<float *>(mappedBuffer); + + // Instance 0 should fill the first 9 floats with values 0 to 8. + for (unsigned int i = 0; i < 9; ++i) + { + EXPECT_EQ(mappedFloats[i], float(i)) << "At index " << i; + } + + // Instance 1 should fill the next 9 floats with values 100 to 108. + for (unsigned int i = 0; i < 9; ++i) + { + EXPECT_EQ(mappedFloats[9 + i], 100.0f + float(i)) << "At index " << (9 + i); + } + + glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER); + + // Check primitives written query result. + GLuint primitivesWritten = 0; + glGetQueryObjectuiv(primitivesWrittenQuery, GL_QUERY_RESULT_EXT, &primitivesWritten); + // 3 triangles from instance 0 + 3 triangles from instance 1 = 6 triangles. + EXPECT_EQ(6u, primitivesWritten); +} + GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TransformFeedbackTest); ANGLE_INSTANTIATE_TEST_ES3_AND(TransformFeedbackTest, ES3_VULKAN().disable(Feature::SupportsTransformFeedbackExtension),
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 93de867..41a9ab7 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2742,6 +2742,13 @@
500930576 OPENGL : ReadPixelsTest.LargeTexture/* = SKIP
// Crashes inside AMD Metal driver when copying the texture -> buffer
500930576 METAL AMD : ReadPixelsTest.LargeTexture/* = SKIP
+
+// XFB with incomplete primitives fail on GL and Vulkan
+513536751 INTEL OPENGL : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP
+// fails on Vulkan without transform feedback extension
+513536751 VULKAN : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/*NoSupportsTransformFeedbackExtension* = SKIP
+513536751 VULKAN SWIFTSHADER : TransformFeedbackTest.InstancedOverflowIncompletePrimitive/* = SKIP
+
// Fails test on Metal
499091328 MAC AMD METAL : Texture2DArrayTestES3.ReformatStagedBufferUpdatesLayerCountOOB/* = SKIP
diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp
index bc13f64..63126cd 100644
--- a/src/tests/gl_tests/TransformFeedbackTest.cpp
+++ b/src/tests/gl_tests/TransformFeedbackTest.cpp
@@ -5273,6 +5273,86 @@
glFinish();
}
+// Test that transform feedback with instanced drawing and count that results in incomplete
+// primitives works correctly when the buffer is large enough to store all complete primitives from
+// all instances.
+TEST_P(TransformFeedbackTest, InstancedOverflowIncompletePrimitive)
+{
+ // We need ES3 for transform feedback and instancing.
+
+ constexpr char kVS[] = R"(#version 300 es
+ out float out_value;
+ void main() {
+ out_value = float(gl_VertexID) + float(gl_InstanceID) * 100.0;
+ gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
+ })";
+
+ constexpr char kFS[] = R"(#version 300 es
+ out mediump vec4 color;
+ void main() {
+ color = vec4(0.0, 1.0, 0.0, 1.0);
+ })";
+
+ std::vector<std::string> tfVaryings;
+ tfVaryings.push_back("out_value");
+
+ mProgram = CompileProgramWithTransformFeedback(kVS, kFS, tfVaryings, GL_INTERLEAVED_ATTRIBS);
+ ASSERT_NE(0u, mProgram);
+
+ glUseProgram(mProgram);
+
+ // Buffer size: enough for 18 vertices (3 triangles * 3 vertices * 2 instances).
+ // Each vertex writes 1 float.
+ const size_t kBufferSize = 18 * sizeof(float);
+
+ GLBuffer tfBuffer;
+ glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, tfBuffer);
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kBufferSize, nullptr, GL_STATIC_DRAW);
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, tfBuffer);
+
+ GLQuery primitivesWrittenQuery;
+ glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, primitivesWrittenQuery);
+
+ glBeginTransformFeedback(GL_TRIANGLES);
+
+ // Draw 11 vertices (3 triangles and 2 vertices), 2 instances.
+ // Total expected vertices if buffer was large enough:
+ // Instance 0: 9 vertices (3 triangles) + 2 vertices (incomplete) -> only 9 written.
+ // Instance 1: 9 vertices (3 triangles) + 2 vertices (incomplete) -> only 9 written.
+ glDrawArraysInstanced(GL_TRIANGLES, 0, 11, 2);
+
+ glEndTransformFeedback();
+ glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN);
+ ASSERT_GL_NO_ERROR();
+
+ // Map buffer and check results.
+ void *mappedBuffer =
+ glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kBufferSize, GL_MAP_READ_BIT);
+ ASSERT_NE(nullptr, mappedBuffer);
+
+ float *mappedFloats = static_cast<float *>(mappedBuffer);
+
+ // Instance 0 should fill the first 9 floats with values 0 to 8.
+ for (unsigned int i = 0; i < 9; ++i)
+ {
+ EXPECT_EQ(mappedFloats[i], float(i)) << "At index " << i;
+ }
+
+ // Instance 1 should fill the next 9 floats with values 100 to 108.
+ for (unsigned int i = 0; i < 9; ++i)
+ {
+ EXPECT_EQ(mappedFloats[9 + i], 100.0f + float(i)) << "At index " << (9 + i);
+ }
+
+ glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
+
+ // Check primitives written query result.
+ GLuint primitivesWritten = 0;
+ glGetQueryObjectuiv(primitivesWrittenQuery, GL_QUERY_RESULT_EXT, &primitivesWritten);
+ // 3 triangles from instance 0 + 3 triangles from instance 1 = 6 triangles.
+ EXPECT_EQ(6u, primitivesWritten);
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(TransformFeedbackTest);
ANGLE_INSTANTIATE_TEST_ES3_AND(TransformFeedbackTest,
ES3_VULKAN().disable(Feature::SupportsTransformFeedbackExtension),