CVE-2026-10907
Overview
Files Changed
src/libANGLE/TransformFeedback.cppsrc/libANGLE/TransformFeedback.hsrc/libANGLE/capture/capture_gles_ext_params.cppsrc/libANGLE/validationES.cppsrc/libANGLE/validationES.h
Patch
From 31151bbc3505a9fe84e64529c7f105a7cd8c40a6 Mon Sep 17 00:00:00 2001
From: Geoff Lang <geofflang@chromium.org>
Date: Mon, 13 Apr 2026 14:48:57 -0400
Subject: [PATCH] Validate TF buffer size for multidraw calls.
Validation for transform feedback output buffer size was done per draw
call and the space available was accumulated after the draw finished.
For multidraw calls, this did not correctly calculate the counts.
Refactor this validation out of the common draw call validation and do
it in the higher level validation functions which are aware of how many
draws will be submitted.
Bug: chromium:489071023
Change-Id: Id1976906235a174688cf3c586ef7af49ae81dff9
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7758139
Commit-Queue: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Kenneth Russell <kbr@chromium.org>
---
diff --git a/src/libANGLE/TransformFeedback.cpp b/src/libANGLE/TransformFeedback.cpp
index 175f0bc..05608a2 100644
--- a/src/libANGLE/TransformFeedback.cpp
+++ b/src/libANGLE/TransformFeedback.cpp
@@ -194,10 +194,17 @@
return mState.mPrimitiveMode;
}
-bool TransformFeedback::checkBufferSpaceForDraw(GLsizei count, GLsizei primcount) const
+bool TransformFeedback::checkBufferSpaceForDraw(const GLsizei *counts,
+ const GLsizei *primcounts,
+ GLsizei drawcount) const
{
- auto vertices =
- mState.mVerticesDrawn + GetVerticesNeededForDraw(mState.mPrimitiveMode, count, primcount);
+ auto vertices = angle::CheckedNumeric<GLsizeiptr>(mState.mVerticesDrawn);
+ for (GLsizei drawID = 0; drawID < drawcount; ++drawID)
+ {
+ GLsizei primcount = ANGLE_UNSAFE_BUFFERS(primcounts ? primcounts[drawID] : 1);
+ GLsizei count = ANGLE_UNSAFE_BUFFERS(counts[drawID]);
+ vertices += GetVerticesNeededForDraw(mState.mPrimitiveMode, count, primcount);
+ }
return vertices.IsValid() && vertices.ValueOrDie() <= mState.mVertexCapacity;
}
diff --git a/src/libANGLE/TransformFeedback.h b/src/libANGLE/TransformFeedback.h
index 9eb3479..a56c62e 100644
--- a/src/libANGLE/TransformFeedback.h
+++ b/src/libANGLE/TransformFeedback.h
@@ -77,8 +77,10 @@
bool isPaused() const;
PrimitiveMode getPrimitiveMode() const;
// Validates that the vertices produced by a draw call will fit in the bound transform feedback
- // buffers.
- bool checkBufferSpaceForDraw(GLsizei count, GLsizei primcount) const;
+ // buffers. primcounts may be nullptr for non-instanced draw calls.
+ bool checkBufferSpaceForDraw(const GLsizei *counts,
+ const GLsizei *primcounts,
+ GLsizei drawcount) const;
// This must be called after each draw call when transform feedback is enabled to keep track of
// how many vertices have been written to the buffers. This information is needed by
// checkBufferSpaceForDraw because each draw call appends vertices to the buffers starting just
diff --git a/src/libANGLE/capture/capture_gles_ext_params.cpp b/src/libANGLE/capture/capture_gles_ext_params.cpp
index 9e67858..89734a1 100644
--- a/src/libANGLE/capture/capture_gles_ext_params.cpp
+++ b/src/libANGLE/capture/capture_gles_ext_params.cpp
@@ -508,7 +508,7 @@
GLsizei drawcount,
ParamCapture *paramCapture)
{
- UNIMPLEMENTED();
+ CaptureArray(firsts, drawcount, paramCapture);
}
void CaptureMultiDrawArraysANGLE_counts(const State &glState,
@@ -518,7 +518,7 @@
GLsizei drawcount,
ParamCapture *paramCapture)
{
- UNIMPLEMENTED();
+ CaptureArray(counts, drawcount, paramCapture);
}
void CaptureMultiDrawArraysInstancedANGLE_firsts(const State &glState,
@@ -529,7 +529,7 @@
GLsizei drawcount,
ParamCapture *paramCapture)
{
- UNIMPLEMENTED();
+ CaptureArray(firsts, drawcount, paramCapture);
}
void CaptureMultiDrawArraysInstancedANGLE_counts(const State &glState,
@@ -540,7 +540,7 @@
GLsizei drawcount,
ParamCapture *paramCapture)
{
- UNIMPLEMENTED();
+ CaptureArray(counts, drawcount, paramCapture);
}
void CaptureMultiDrawArraysInstancedANGLE_instanceCounts(const State &glState,
@@ -551,7 +551,7 @@
GLsizei drawcount,
ParamCapture *paramCapture)
{
- UNIMPLEMENTED();
+ CaptureArray(instanceCounts, drawcount, paramCapture);
}
void CaptureMultiDrawElementsANGLE_counts(const State &glState,
diff --git a/src/libANGLE/validationES.cpp b/src/libANGLE/validationES.cpp
index bd51e02..f9f902f 100644
--- a/src/libANGLE/validationES.cpp
+++ b/src/libANGLE/validationES.cpp
@@ -1525,6 +1525,27 @@
return true;
}
+bool ValidateDrawArraysTransformFeedbackBufferSize(const Context *context,
+ angle::EntryPoint entryPoint,
+ const GLsizei *counts,
+ const GLsizei *primcounts,
+ GLsizei drawcount)
+{
+ if (ANGLE_UNLIKELY(context->getStateCache().isTransformFeedbackActiveUnpaused()) &&
+ ANGLE_UNLIKELY(!context->supportsGeometryOrTesselation()))
+ {
+ const State &state = context->getState();
+ TransformFeedback *curTransformFeedback = state.getCurrentTransformFeedback();
+ if (!curTransformFeedback->checkBufferSpaceForDraw(counts, primcounts, drawcount))
+ {
+ ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, err::kTransformFeedbackBufferTooSmall);
+ return false;
+ }
+ }
+
+ return true;
+}
+
Program *GetValidProgramNoResolve(const Context *context,
angle::EntryPoint entryPoint,
ShaderProgramID id)
@@ -4496,6 +4517,11 @@
return false;
}
+ if (!ValidateDrawArraysTransformFeedbackBufferSize(context, entryPoint, &count, &primcount, 1))
+ {
+ return false;
+ }
+
return ValidateDrawInstancedANGLE(context, entryPoint);
}
@@ -4511,6 +4537,11 @@
return false;
}
+ if (!ValidateDrawArraysTransformFeedbackBufferSize(context, entryPoint, &count, &primcount, 1))
+ {
+ return false;
+ }
+
return true;
}
diff --git a/src/libANGLE/validationES.h b/src/libANGLE/validationES.h
index ce18079..4eaad41 100644
--- a/src/libANGLE/validationES.h
+++ b/src/libANGLE/validationES.h
@@ -98,6 +98,15 @@
const void *ptr,
bool pureInteger);
+// Validation of transform feedback buffer output size for various DrawArrays calls.
+// `primcounts` can be null for non-instanced calls.
+// If this function returns false, an error has been generated.
+bool ValidateDrawArraysTransformFeedbackBufferSize(const Context *context,
+ angle::EntryPoint entryPoint,
+ const GLsizei *counts,
+ const GLsizei *primcounts,
+ GLsizei drawcount);
+
// Returns valid program if id is a valid program name
// Errors INVALID_OPERATION if valid shader is given and returns NULL
// Errors INVALID_VALUE otherwise and returns NULL
@@ -941,18 +950,6 @@
return false;
}
- if (ANGLE_UNLIKELY(context->getStateCache().isTransformFeedbackActiveUnpaused()) &&
- ANGLE_UNLIKELY(!context->supportsGeometryOrTesselation()))
- {
- const State &state = context->getState();
- TransformFeedback *curTransformFeedback = state.getCurrentTransformFeedback();
- if (!curTransformFeedback->checkBufferSpaceForDraw(count, primcount))
- {
- ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, err::kTransformFeedbackBufferTooSmall);
Regression Test / PoC
diff --git a/src/tests/gl_tests/TransformFeedbackTest.cpp b/src/tests/gl_tests/TransformFeedbackTest.cpp
index 387971e..b4fdec1 100644
--- a/src/tests/gl_tests/TransformFeedbackTest.cpp
+++ b/src/tests/gl_tests/TransformFeedbackTest.cpp
@@ -4736,6 +4736,124 @@
glEndTransformFeedback();
}
+// Test validation of buffer bounds checking for transform feedback with multidraw commands
+TEST_P(WebGLTransformFeedbackTest, TooSmallBuffersMultiDraw)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_multi_draw"));
+
+ glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glEnable(GL_RASTERIZER_DISCARD);
+
+ // Set the program's transform feedback varyings (just gl_Position)
+ std::vector<std::string> tfVaryings;
+ tfVaryings.push_back("gl_Position");
+ compileDefaultProgram(tfVaryings, GL_INTERLEAVED_ATTRIBS);
+ GLint positionLocation = glGetAttribLocation(mProgram, essl1_shaders::PositionAttrib());
+
+ glUseProgram(mProgram);
+
+ const GLfloat vertices[] = {
+ -1.0f, 1.0f, 0.5f, -1.0f, -1.0f, 0.5f, 1.0f, -1.0f, 0.5f,
+ -1.0f, 1.0f, 0.5f, 1.0f, -1.0f, 0.5f, 1.0f, 1.0f, 0.5f,
+ };
+
+ GLBuffer buffer;
+ glBindBuffer(GL_ARRAY_BUFFER, buffer);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
+ glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+ glEnableVertexAttribArray(positionLocation);
+
+ const GLsizei verticesToDraw = 3;
+ const size_t stride = sizeof(float) * 4;
+ const GLsizei drawcount = 2;
+ const size_t bytesNeeded = stride * verticesToDraw * drawcount;
+
+ const GLsizei firsts[drawcount] = {0, 0};
+ const GLsizei counts[drawcount] = {verticesToDraw, verticesToDraw};
+
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, mTransformFeedbackBuffer);
+
+ // Set up the buffer to be the right size
+ uint8_t tfData[bytesNeeded] = {0};
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, bytesNeeded, &tfData, GL_STATIC_DRAW);
+
+ glBeginTransformFeedback(GL_POINTS);
+ glMultiDrawArraysANGLE(GL_POINTS, firsts, counts, drawcount);
+ EXPECT_GL_NO_ERROR();
+ glEndTransformFeedback();
+
+ // Set up the buffer to be too small
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, bytesNeeded - 1, &tfData, GL_STATIC_DRAW);
+
+ glBeginTransformFeedback(GL_POINTS);
+ EXPECT_GL_NO_ERROR();
+ glMultiDrawArraysANGLE(GL_POINTS, firsts, counts, drawcount);
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+ glEndTransformFeedback();
+}
+
+// Test validation of buffer bounds checking for transform feedback with multidraw instanced
+// commands
+TEST_P(WebGLTransformFeedbackTest, TooSmallBuffersMultiDrawInstanced)
+{
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_multi_draw"));
+ ANGLE_SKIP_TEST_IF(!EnsureGLExtensionEnabled("GL_ANGLE_instanced_arrays"));
+
+ glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ glEnable(GL_RASTERIZER_DISCARD);
+
+ // Set the program's transform feedback varyings (just gl_Position)
+ std::vector<std::string> tfVaryings;
+ tfVaryings.push_back("gl_Position");
+ compileDefaultProgram(tfVaryings, GL_INTERLEAVED_ATTRIBS);
+ GLint positionLocation = glGetAttribLocation(mProgram, essl1_shaders::PositionAttrib());
+
+ glUseProgram(mProgram);
+
+ const GLfloat vertices[] = {
+ -1.0f, 1.0f, 0.5f, -1.0f, -1.0f, 0.5f, 1.0f, -1.0f, 0.5f,
+ -1.0f, 1.0f, 0.5f, 1.0f, -1.0f, 0.5f, 1.0f, 1.0f, 0.5f,
+ };
+
+ GLBuffer buffer;
+ glBindBuffer(GL_ARRAY_BUFFER, buffer);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
+ glVertexAttribPointer(positionLocation, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+ glEnableVertexAttribArray(positionLocation);
+
+ const GLsizei verticesToDraw = 3;
+ const size_t stride = sizeof(float) * 4;
+ const GLsizei drawcount = 2;
+ const GLsizei instanceCount = 2;
+ const size_t bytesNeeded = stride * verticesToDraw * drawcount * instanceCount;
+
+ const GLsizei firsts[drawcount] = {0, 0};
+ const GLsizei counts[drawcount] = {verticesToDraw, verticesToDraw};
+ const GLsizei instanceCounts[drawcount] = {instanceCount, instanceCount};
+
+ glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, mTransformFeedbackBuffer);
+
+ // Set up the buffer to be the right size
+ uint8_t tfData[bytesNeeded] = {0};
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, bytesNeeded, &tfData, GL_STATIC_DRAW);
+
+ glBeginTransformFeedback(GL_POINTS);
+ glMultiDrawArraysInstancedANGLE(GL_POINTS, firsts, counts, instanceCounts, drawcount);
+ EXPECT_GL_NO_ERROR();
+ glEndTransformFeedback();
+
+ // Set up the buffer to be too small
+ glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, bytesNeeded - 1, &tfData, GL_STATIC_DRAW);
+
+ glBeginTransformFeedback(GL_POINTS);
+ EXPECT_GL_NO_ERROR();
+ glMultiDrawArraysInstancedANGLE(GL_POINTS, firsts, counts, instanceCounts, drawcount);
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+ glEndTransformFeedback();
+}
+
// Test that deleting a buffer bound to a transform feedback slot that is not used by the current
// program.
TEST_P(TransformFeedbackTest, StaleBufferBinding)
Original Bug Report
Transform Feedback OOB Write via Multi-Draw Cumulative Overflow
Report description
Transform Feedback OOB Write via Multi-Draw Cumulative Overflow
Bug location
Where do you want to report your vulnerability?
Chrome VRP β Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Summary
ANGLE’s ValidateMultiDrawArraysANGLE() checks each sub-draw’s transform feedback (XFB) buffer space against a stale mVerticesDrawn counter that is never updated between validation iterations. Sub-draws that individually fit within the XFB buffer but collectively exceed its capacity all pass validation. The backend then executes all sub-draws sequentially, writing past the XFB buffer boundary.
Root Cause
The Bug: Stale Accumulator in Multi-Draw Validation Loop
The root cause is a stale accumulator leading to cumulative overflow β the validation loop reads mVerticesDrawn without advancing it between iterations, so every sub-draw is checked against the same base offset. This gap is deterministic and single-threaded: the validation phase simply never updates the counter that the execution phase later advances correctly. Each sub-draw individually fits within the buffer, but their cumulative writes exceed its capacity.
// validationES2.cpp β ValidateMultiDrawArraysANGLE
bool ValidateMultiDrawArraysANGLE(const Context *context,
angle::EntryPoint entryPoint,
PrimitiveMode mode,
const GLint *firsts,
const GLsizei *counts,
GLsizei drawcount)
{
if (drawcount < 0) { ... return false; }
for (GLsizei drawID = 0; drawID < drawcount; ++drawID)
{
if (!ValidateDrawArrays(context, entryPoint, mode,
firsts[drawID], counts[drawID]))
return false;
// *** BUG: mVerticesDrawn NOT updated between iterations ***
// Each sub-draw validates against the same stale counter
}
return true;
}
The same stale-accumulator pattern exists in all multi-draw validation functions β each loops over sub-draws calling per-draw validation without advancing mVerticesDrawn between iterations:
// validationES2.cpp β ValidateMultiDrawElementsANGLE (same pattern)
for (GLsizei drawID = 0; drawID < drawcount; ++drawID)
{
if (!ValidateDrawElements(context, entryPoint, mode, counts[drawID], type, indices[drawID]))
return false;
// BUG: mVerticesDrawn not updated between iterations
}
// validationES3.cpp β ValidateMultiDrawArraysInstancedANGLE (same pattern)
for (GLsizei drawID = 0; drawID < drawcount; ++drawID)
{
if (!ValidateDrawArraysInstancedBase(context, entryPoint, mode, firsts[drawID],
counts[drawID], instanceCounts[drawID], 0))
return false;
// BUG: mVerticesDrawn not updated β instanceCounts amplifies overflow
}
// validationES3.cpp β ValidateMultiDrawElementsInstancedANGLE (same pattern)
for (GLsizei drawID = 0; drawID < drawcount; ++drawID)
{
if (!ValidateDrawElementsInstancedBase(context, entryPoint, mode, counts[drawID], type,
indices[drawID], instanceCounts[drawID], 0))
return false;
// BUG: mVerticesDrawn not updated β instanceCounts amplifies overflow
}
The XFB Space Check (called per sub-draw via ValidateDrawArraysCommon)
// validationES.h β inside ValidateDrawArraysCommon
if (ANGLE_UNLIKELY(context->getStateCache().isTransformFeedbackActiveUnpaused()) &&
ANGLE_UNLIKELY(!context->supportsGeometryOrTesselation()))
{
const State &state = context->getState();
TransformFeedback *curTransformFeedback = state.getCurrentTransformFeedback();
if (!curTransformFeedback->checkBufferSpaceForDraw(count, primcount))
{
ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, err::kTransformFeedbackBufferTooSmall);
return false;
}
}
The Counter Check (reads stale mVerticesDrawn)
// TransformFeedback.cpp β checkBufferSpaceForDraw
bool TransformFeedback::checkBufferSpaceForDraw(GLsizei count, GLsizei primcount) const
{
auto vertices = mState.mVerticesDrawn + // <-- STALE: never advanced during validation
GetVerticesNeededForDraw(mState.mPrimitiveMode, count, primcount);
return vertices.IsValid() && vertices.ValueOrDie() <= mState.mVertexCapacity;
}
The Counter Update (only called AFTER execution, not during validation)
// TransformFeedback.cpp β onVerticesDrawn
void TransformFeedback::onVerticesDrawn(const Context *context, GLsizei count, GLsizei primcount)
{
ASSERT(mState.mActive && !mState.mPaused);
mState.mVerticesDrawn =
(mState.mVerticesDrawn + GetVerticesNeededForDraw(mState.mPrimitiveMode, count, primcount))
.ValueOrDie();
// ...
}
Execution Flow
Validation phase (all pass β stale mVerticesDrawn = 0):
Sub-draw 0: checkBufferSpaceForDraw(400,1) β 0+400 <= 1024 β
Sub-draw 1: checkBufferSpaceForDraw(400,1) β 0+400 <= 1024 β (STALE!)
... (all 10 sub-draws pass)
Execution phase (MULTI_DRAW_BLOCK macro in renderer_utils.cpp):
Sub-draw 0: writes 400 vertices at offset 0, mVerticesDrawn = 400
Sub-draw 1: writes 400 vertices at offset 1600, mVerticesDrawn = 800
Sub-draw 2: writes 400 vertices at offset 3200, mVerticesDrawn = 1200 β OVERFLOW START
Sub-draw 3: writes 400 vertices at offset 4800, mVerticesDrawn = 1600
...
Sub-draw 9: writes 400 vertices at offset 14400, mVerticesDrawn = 4000
β 2976 vertices Γ 4 bytes = 11,904 bytes OOB write
Reproduction Steps
Cross-Tab Rendering Corruption
This PoC demonstrates that an attacker webpage can corrupt the GPU buffer data of a victim webpage open in a separate tab. Both tabs share Chrome’s GPU process.
- Open Chrome.
- Open
poc_render_verify_victim_min.htmlin Tab 1 β it renders 3 colored triangles (red, green, blue), sprays 4096 Γ 4KB GPU buffers with tagged sentinel data, then starts a rendering integrity monitoring loop - Open
poc_render_verify_attacker_min.htmlin Tab 2 β click “Attack (100 rounds)” - The attacker page will:
- Force ANGLE BufferManager GC eviction (60 temporary contexts destroyed + 3Γ600 buffer spray/free cycles + 256MB allocation pressure)
- Allocate a 4KB XFB buffer (same size class as victim’s buffers)
- Execute 100 XFB overflow rounds via
multiDrawArraysWEBGL(POINTS, ...)with 10 sub-draws of 400 vertices each - Each sub-draw passes validation individually (400 < 1024) because
mVerticesDrawnis never updated between iterations - Each round writes 12KB past the 4KB buffer boundary into adjacent GPU memory
- Expected victim behavior (if no bug): Rendering stays identical, all buffers clean
- Actual (confirmed on Apple M4 Pro, macOS, Metal backend): “GPU RENDERING CORRUPTION DETECTED” β victim tab’s triangles disappear or move to wrong positions, spray buffer sentinel data overwritten
Note: The cross-tab attack relies on GPU heap status (GC eviction + buffer spray) to place victim and attacker buffers adjacently. (multiple attempts may be required)
How it works:
- Both tabs share Chrome’s single GPU process and Metal device
- ANGLE’s per-context
BufferManagercaches freed buffers; GC eviction returns them to Metal - Attacker triggers GC eviction (destroy 60 temp contexts + 3Γ600 spray/free + 256MB pressure)
- Victim allocates 4096 Γ 4KB buffers β Metal reuses freed regions, placing them adjacent to attacker’s XFB buffer
- Attacker executes XFB overflow β writes 12KB past buffer boundary into victim’s VBO/spray buffers
- Victim’s rendering loop detects pixel differences β GPU rendering corruption confirmed
Cross-tab attack timeline:
Tab 1 (Victim): Allocate VBO β Render triangles β Monitor rendering
Tab 2 (Attacker): GC eviction β Allocate XFB buf β multiDrawArrays overflow
β
Tab 1 (Victim): Re-render β readPixels β PIXEL DIFFS DETECTED
β "GPU RENDERING CORRUPTION DETECTED"
Attached Files
| File | Description |
|---|---|
poc_render_verify_attacker_min.html |
Cross-tab attack PoC β executes XFB overflow after GC eviction to corrupt victim’s GPU buffers (CSS-stripped minimal version) |
poc_render_verify_victim_min.html |
Cross-tab victim PoC β renders 3 triangles + 4096Γ4KB buffer spray, monitors rendering integrity (CSS-stripped minimal version) |
Impact analysis
Impact
An attacker can corrputs GPU memory. (POC shows that an attacker page corrupts the rendering output of a victim page in a different tab.)
The cause
What version of Chrome have you found the security issue in?
145.0.7632.117 stable
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption
How would you like to be publicly acknowledged for your report?
sweetchip