Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Tracker497533569
Fix commit835b70c0c913 (angle/angle) +1063/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Files Changed

  • src/libANGLE/Context.cpp
  • src/libANGLE/ErrorStrings.h
  • src/libANGLE/TransformFeedback.cpp
From 835b70c0c9130e261c31e81252e0a30c51c43846 Mon Sep 17 00:00:00 2001
From: Amirali Abdolrashidi <[email protected]>
Date: Fri, 24 Apr 2026 18:37:23 -0700
Subject: [PATCH] Add xfb validation for resuming w/ another program

* Added the following to TransformFeedbackState for validation checks:
  * mProgramPipeline
  * mPPOPrograms

* Updated TransformFeedback::begin() to take a program pipeline pointer
  as well.
  * Also updated its Context function so it would pass nullptr for the
    PPO if a program is bound, since programs should override PPOs.

* Updated ValidateResumeTransformFeedback() for scenarios involving
  changing programs or PPOsso the call to resume transform feedback
  fails with GL_INVALID_OPERATION in the following cases:
  * if the same program from the beginning of XFB is not in use
  * if the same PPO from the beginning of XFB is not in use
  * if a shader stage in the PPO changes
  * if a shader program is used when a PPO is already active

* Updated TransformFeedbackVk::end() to clear the cached data to
  prevent from using outdated info when binding transform feedback
  buffers.

* Added unit tests to TransformFeedbackTest for program testing.
* Added unit tests to ProgramPipelineXFBTest31 for the PPO testing.

Bug: chromium:497533569
Change-Id: Id1891e4a5b0be31f14a6ec1f7f8c73e9a22b5b3c
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7793853
Reviewed-by: Charlie Lao <[email protected]>
Commit-Queue: Amirali Abdolrashidi <[email protected]>
Reviewed-by: Shahbaz Youssefi <[email protected]>
---

diff --git a/src/libANGLE/Context.cpp b/src/libANGLE/Context.cpp
index 9a8d68c..742a100 100644
--- a/src/libANGLE/Context.cpp
+++ b/src/libANGLE/Context.cpp
@@ -3869,7 +3869,11 @@
     ASSERT(!transformFeedback->isPaused());
 
     // TODO: http://anglebug.com/42265705: Handle PPOs
-    ANGLE_CONTEXT_TRY(transformFeedback->begin(this, primitiveMode, mState.getProgram()));
+    // Since programs should override PPOs, no PPO is passed to the transform feedback if a program
+    // is active.
+    Program *program                 = mState.getProgram();
+    ProgramPipeline *programPipeline = program == nullptr ? mState.getProgramPipeline() : nullptr;
+    ANGLE_CONTEXT_TRY(transformFeedback->begin(this, primitiveMode, program, programPipeline));
     onActiveTransformFeedbackChange();
 }
 
diff --git a/src/libANGLE/ErrorStrings.h b/src/libANGLE/ErrorStrings.h
index 589a3da..a6ecd74 100644
--- a/src/libANGLE/ErrorStrings.h
+++ b/src/libANGLE/ErrorStrings.h
@@ -648,6 +648,10 @@
 inline constexpr const char *kTransformFeedbackNotPaused = "The active Transform Feedback object is not paused.";
 inline constexpr const char *kTransformFeedbackPaused = "The active Transform Feedback object is paused.";
 inline constexpr const char *kTransformFeedbackProgramBinary = "Cannot change program binary while program is associated with an active transform feedback object.";
+inline constexpr const char *kTransformFeedbackProgramNotSameAtResume = "The program when transform feedback began is no longer bound.";
+inline constexpr const char *kTransformFeedbackProgramOverridingPipelineAtResume = "A bound program is overriding the bound pipeline from when transform feedback began.";
+inline constexpr const char *kTransformFeedbackPipelineNotSameAtResume = "The program pipeline when transform feedback began is no longer bound.";
+inline constexpr const char *kTransformFeedbackPipelineChangedStagesAtResume = "The shader stages of the bound program pipeline have changed since transform feedback began.";
 inline constexpr const char *kTransformFeedbackTargetActive = "Target is TRANSFORM_FEEDBACK_BUFFER and transform feedback is currently active.";
 inline constexpr const char *kTransformFeedbackUseProgram = "Cannot change active program while transform feedback is unpaused.";
 inline constexpr const char *kTransformFeedbackVaryingIndexOutOfRange = "Index must be less than the transform feedback varying count in the program.";
diff --git a/src/libANGLE/TransformFeedback.cpp b/src/libANGLE/TransformFeedback.cpp
index 05608a2..6f31b93 100644
--- a/src/libANGLE/TransformFeedback.cpp
+++ b/src/libANGLE/TransformFeedback.cpp
@@ -54,6 +54,7 @@
       mVerticesDrawn(0),
       mVertexCapacity(0),
       mProgram(nullptr),
+      mProgramPipeline(nullptr),
       mIndexedBuffers(maxIndexedBuffers)
 {}
 
@@ -138,7 +139,8 @@
 
 angle::Result TransformFeedback::begin(const Context *context,
                                        PrimitiveMode primitiveMode,
-                                       Program *program)
+                                       Program *program,
+                                       ProgramPipeline *programPipeline)
 {
     // TODO: http://anglebug.com/42264023: This method should take in as parameter a
     // ProgramExecutable instead of a Program.
@@ -148,7 +150,14 @@
     mState.mPrimitiveMode = primitiveMode;
     mState.mPaused        = false;
     mState.mVerticesDrawn = 0;
+
+    // Program and PPO are not both passed to the transform feedback object. In the event both are
+    // bound, program takes precedence.
+    ASSERT(program == nullptr || programPipeline == nullptr);
     bindProgram(context, program);
+    bindProgramPipeline(context, programPipeline);
+    bindPPOPrograms(programPipeline);
+
     recomputeVertexCapacity(context);
     return angle::Result::Continue;
 }
@@ -161,11 +170,20 @@
     mState.mPaused         = false;
     mState.mVerticesDrawn  = 0;
     mState.mVertexCapacity = 0;
-    if (mState.mProgram)
+    if (mState.mProgram != nullptr)
     {
         mState.mProgram->release(context);
         mState.mProgram = nullptr;
     }
+    if (mState.mProgramPipeline != nullptr)
+    {
+        mState.mProgramPipeline->release(context);
+        mState.mProgramPipeline = nullptr;
+    }
+    for (const ShaderType shaderType : gl::AllShaderTypes())
+    {
+        mState.mPPOPrograms[shaderType].value = 0;
+    }
     return angle::Result::Continue;
 }
 
@@ -241,6 +259,41 @@
     }
 }
 
+void TransformFeedback::bindProgramPipeline(const Context *context,
+                                            ProgramPipeline *programPipeline)
+{
+    if (mState.mProgramPipeline != programPipeline)
+    {
+        if (mState.mProgramPipeline != nullptr)
+        {
+            mState.mProgramPipeline->release(context);
+        }
+        mState.mProgramPipeline = programPipeline;
+        if (mState.mProgramPipeline != nullptr)
+        {
+            mState.mProgramPipeline->addRef();
+        }
+    }
+}
+
+void TransformFeedback::bindPPOPrograms(ProgramPipeline *programPipeline)
+{
+    if (programPipeline == nullptr)
+    {
+        for (const ShaderType shaderType : gl::AllShaderTypes())
+        {
+            mState.mPPOPrograms[shaderType].value = 0;
+        }
+        return;
+    }
+
+    for (const ShaderType shaderType : gl::AllShaderTypes())
+    {
+        const Program *program                = programPipeline->getShaderProgram(shaderType);
+        mState.mPPOPrograms[shaderType].value = program != nullptr ? program->id().value : 0;
+    }
+}
+
 void TransformFeedback::recomputeVertexCapacity(const Context *context)
 {
     // In one of the angle_unittests - "TransformFeedbackTest.SideEffectsOfStartAndStop"
@@ -272,6 +325,29 @@
     return mState.mProgram != nullptr && mState.mProgram->id().value == program.value;
 }
 
+bool TransformFeedback::hasBoundProgramPipeline(ProgramPipelineID programPipeline) const
+{
+    return mState.mProgramPipeline != nullptr &&
+           mState.mProgramPipeline->id().value == programPipeline.value;
+}
+
+bool TransformFeedback::hasSamePPOPrograms(ProgramPipeline *programPipeline) const
+{
+    for (const ShaderType shaderType : gl::AllShaderTypes())
+    {
+        GLuint shaderProgramIDValue =
+            (programPipeline != nullptr && programPipeline->getShaderProgram(shaderType) != nullptr)
+                ? programPipeline->getShaderProgram(shaderType)->id().value
+                : 0;
+        if (mState.mPPOPrograms[shaderType].value != shaderProgramIDValue)
+        {
+            return false;
+        }
+    }
+
+    return true;
+}
+
 angle::Result TransformFeedback::detachBuffer(const Context *context, BufferID bufferID)
 {
     bool isBound = context->isCurrentTransformFeedback(this);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/libANGLE/TransformFeedback_unittest.cpp b/src/libANGLE/TransformFeedback_unittest.cpp
index c922f11..9706d73 100644
--- a/src/libANGLE/TransformFeedback_unittest.cpp
+++ b/src/libANGLE/TransformFeedback_unittest.cpp
@@ -74,7 +74,7 @@
     EXPECT_FALSE(mFeedback->isActive());
     EXPECT_CALL(*mImpl, begin(nullptr, gl::PrimitiveMode::Triangles));
     EXPECT_EQ(angle::Result::Continue,
-              mFeedback->begin(nullptr, gl::PrimitiveMode::Triangles, nullptr));
+              mFeedback->begin(nullptr, gl::PrimitiveMode::Triangles, nullptr, nullptr));
     EXPECT_TRUE(mFeedback->isActive());
     EXPECT_EQ(gl::PrimitiveMode::Triangles, mFeedback->getPrimitiveMode());
     EXPECT_CALL(*mImpl, end(nullptr));
@@ -89,7 +89,7 @@
     EXPECT_FALSE(mFeedback->isActive());
     EXPECT_CALL(*mImpl, begin(nullptr, gl::PrimitiveMode::Triangles));
     EXPECT_EQ(angle::Result::Continue,
-              mFeedback->begin(nullptr, gl::PrimitiveMode::Triangles, nullptr));
+              mFeedback->begin(nullptr, gl::PrimitiveMode::Triangles, nullptr, nullptr));
     EXPECT_FALSE(mFeedback->isPaused());
     EXPECT_CALL(*mImpl, pause(nullptr));
     EXPECT_EQ(angle::Result::Continue, mFeedback->pause(nullptr));
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 28972c0..399b0fd 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -420,6 +420,8 @@
 494270619 MAC OPENGL : BaseInstanceOverflowTest.BaseInstanceOverflow/* = SKIP
 504886981 MAC OPENGL : RobustResourceInitTestES3.PartiallyInitializedTextureWithNonZeroBase/* = SKIP
 504886981 MAC OPENGL : RobustResourceInitTestES3.ReadbackWithMippedTexture/* = SKIP
+497533569 MAC INTEL OPENGL : TransformFeedbackTest.ProgramSwitchDuringPauseAndResume/* = SKIP
+497533569 MAC INTEL OPENGL : TransformFeedbackTest.ProgramSwitchDuringPauseAndResumeWithBufferChange/* = SKIP
 
 // BlitFramebufferTest.ScissoredMultisampleStencil failures
 42262159 MAC INTEL OPENGL : BlitFramebufferTest.ScissoredMultisampleStencil/* = SKIP
@@ -2065,6 +2067,11 @@
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineTest31.VaryingLocationMismatch/* = SKIP
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.SeparableProgramWithXFBSeparateMode/* = SKIP
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.VaryingIOBlockSeparableProgramWithXFB/* = SKIP
+410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.ChangeShaderStageDuringPauseAndResume/* = SKIP
+410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.ChangeShaderStageDuringPauseAndResumeWithBufferChange/* = SKIP
+410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.PipelineSwitchDuringPauseAndResume/* = SKIP
+410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.BindProgramDuringPauseAndResume/* = SKIP
+410630704 SAMSUNG GALAXYS24EXYNOS GLES : ProgramPipelineXFBTest31.UseProgramAndPPOThenUnbindProgramAndResume/* = SKIP
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : SRGBFramebufferTestES3.BlitFramebuffer/* = SKIP
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : SRGBFramebufferTest.MultipleFramebuffers/* = SKIP
 410630704 SAMSUNG GALAXYS24EXYNOS GLES : SRGBFramebufferTest.NegativeLifetimeTracking/* = SKIP
diff --git a/src/tests/gl_tests/ProgramPipelineTest.cpp b/src/tests/gl_tests/ProgramPipelineTest.cpp
index 014e44e..23cfe01 100644
--- a/src/tests/gl_tests/ProgramPipelineTest.cpp
+++ b/src/tests/gl_tests/ProgramPipelineTest.cpp
@@ -1680,6 +1680,660 @@
     EXPECT_GL_NO_ERROR();
 }
 
+// Test that resuming transform feedback after changing a pipeline shader stage results in
+// validation error.
+TEST_P(ProgramPipelineXFBTest31, ChangeShaderStageDuringPauseAndResume)
+{
+    // Only the Vulkan backend supports PPOs
+    ANGLE_SKIP_TEST_IF(!IsVulkan());
+
+    const char *kVS1 = R"(#version 310 es
+out float tfVarying10;
+out float tfVarying11;
+out float tfVarying12;
+out float tfVarying13;
+void main() {
+    tfVarying10 = 1.0;
+    tfVarying11 = 2.0;
+    tfVarying12 = 3.0;
+    tfVarying13 = 4.0;
+    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
+})";
+    const char *kVS2 = R"(#version 310 es
+out float tfVarying20;
+out float tfVarying21;
+void main() {
+    tfVarying20 = -1.0;
+    tfVarying21 = -2.0;
+    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
+})";
+
+    const char *tfVaryings1[] = {"tfVarying10", "tfVarying11", "tfVarying12", "tfVarying13"};
+    const char *tfVaryings2[] = {"tfVarying20", "tfVarying21"};
+
+    GLShader vs1(GL_VERTEX_SHADER);
+    GLuint vsProgram1 = glCreateProgram();
+    glShaderSource(vs1, 1, &kVS1, nullptr);
+    glCompileShader(vs1);
+    glProgramParameteri(vsProgram1, GL_PROGRAM_SEPARABLE, GL_TRUE);
+    glAttachShader(vsProgram1, vs1);
+    glTransformFeedbackVaryings(vsProgram1, 4, tfVaryings1, GL_SEPARATE_ATTRIBS);
+    glLinkProgram(vsProgram1);
+    EXPECT_GL_NO_ERROR();
+
+    GLShader vs2(GL_VERTEX_SHADER);
+    GLuint vsProgram2 = glCreateProgram();
+    glShaderSource(vs2, 1, &kVS2, nullptr);
+    glCompileShader(vs2);
+    glProgramParameteri(vsProgram2, GL_PROGRAM_SEPARABLE, GL_TRUE);
+    glAttachShader(vsProgram2, vs2);
+    glTransformFeedbackVaryings(vsProgram2, 2, tfVaryings2, GL_SEPARATE_ATTRIBS);
+    glLinkProgram(vsProgram2);
+
+    glEnable(GL_RASTERIZER_DISCARD);
+
+    // XFB buffers
+    GLBuffer xfbBuffers[4];
+    constexpr GLsizei kInitSize = 4 * 1024;
+    for (int i = 0; i < 4; ++i)
+    {
+        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[i]);
+        glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kInitSize, nullptr, GL_DYNAMIC_DRAW);
+        glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, xfbBuffers[i]);
+        ASSERT_GL_NO_ERROR();
+    }
+
+    // Use the first program which uses four buffers.
+    GLProgramPipeline pipeline;
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram1);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+
+    glBeginTransformFeedback(GL_POINTS);
+    glDrawArrays(GL_POINTS, 0, 1);
+    glEndTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    // Validate the XFB values for the first program.
+    for (int i = 0; i < 4; ++i)
+    {
+        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[i]);
+        const float *bufferData = reinterpret_cast<float *>(
+            glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, sizeof(GLfloat), GL_MAP_READ_BIT));
+        ASSERT_NE(nullptr, bufferData);
+        EXPECT_EQ(*bufferData, 1.0f + static_cast<float>(i));
+        glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
+    }
+
+    // Update the pipeline to use the second program which only uses the first two buffers.
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram2);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+    glBeginTransformFeedback(GL_POINTS);
+    glPauseTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    // Resuming transform feedback after changing a shader stage should result in validation error.
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram1);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+    ASSERT_GL_NO_ERROR();
+    glResumeTransformFeedback();
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    glDrawArrays(GL_POINTS, 0, 1);
+    ASSERT_GL_NO_ERROR();
+
+    // Resuming transform feedback with the same program from the beginning is OK.
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram2);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+    ASSERT_GL_NO_ERROR();
+    glResumeTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    glDrawArrays(GL_POINTS, 0, 1);
+    ASSERT_GL_NO_ERROR();
+
+    glEndTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    // Validate the XFB values for the second program.
+    for (int i = 0; i < 2; ++i)
+    {
+        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[i]);
+        const float *bufferData = reinterpret_cast<float *>(
+            glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, sizeof(GLfloat), GL_MAP_READ_BIT));
+        ASSERT_NE(nullptr, bufferData);
+        EXPECT_EQ(*bufferData, -(1.0f + static_cast<float>(i)));
+        glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
+    }
+
+    glDeleteProgram(vsProgram1);
+    glDeleteProgram(vsProgram2);
+}
+
+// Test that resuming transform feedback after changing a pipeline shader stage results in
+// validation error, and using the original shader after updating the size of one of its buffers
+// works.
+TEST_P(ProgramPipelineXFBTest31, ChangeShaderStageDuringPauseAndResumeWithBufferChange)
+{
+    // Only the Vulkan backend supports PPOs
+    ANGLE_SKIP_TEST_IF(!IsVulkan());
+
+    const char *kVS1 = R"(#version 310 es
+out float tfVarying10;
+out float tfVarying11;
+out float tfVarying12;
+out float tfVarying13;
+void main() {
+    tfVarying10 = 1.0;
+    tfVarying11 = 2.0;
+    tfVarying12 = 3.0;
+    tfVarying13 = 4.0;
+    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
+})";
+    const char *kVS2 = R"(#version 310 es
+out float tfVarying20;
+out float tfVarying21;
+void main() {
+    tfVarying20 = -1.0;
+    tfVarying21 = -2.0;
+    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
+})";
+
+    const char *tfVaryings1[] = {"tfVarying10", "tfVarying11", "tfVarying12", "tfVarying13"};
+    const char *tfVaryings2[] = {"tfVarying20", "tfVarying21"};
+
+    GLShader vs1(GL_VERTEX_SHADER);
+    GLuint vsProgram1 = glCreateProgram();
+    glShaderSource(vs1, 1, &kVS1, nullptr);
+    glCompileShader(vs1);
+    glProgramParameteri(vsProgram1, GL_PROGRAM_SEPARABLE, GL_TRUE);
+    glAttachShader(vsProgram1, vs1);
+    glTransformFeedbackVaryings(vsProgram1, 4, tfVaryings1, GL_SEPARATE_ATTRIBS);
+    glLinkProgram(vsProgram1);
+    EXPECT_GL_NO_ERROR();
+
+    GLShader vs2(GL_VERTEX_SHADER);
+    GLuint vsProgram2 = glCreateProgram();
+    glShaderSource(vs2, 1, &kVS2, nullptr);
+    glCompileShader(vs2);
+    glProgramParameteri(vsProgram2, GL_PROGRAM_SEPARABLE, GL_TRUE);
+    glAttachShader(vsProgram2, vs2);
+    glTransformFeedbackVaryings(vsProgram2, 2, tfVaryings2, GL_SEPARATE_ATTRIBS);
+    glLinkProgram(vsProgram2);
+
+    glEnable(GL_RASTERIZER_DISCARD);
+
+    // XFB buffers
+    GLBuffer xfbBuffers[4];
+    constexpr GLsizei kInitSize = 4 * 1024;
+    for (int i = 0; i < 4; ++i)
+    {
+        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[i]);
+        glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kInitSize, nullptr, GL_DYNAMIC_DRAW);
+        glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, xfbBuffers[i]);
+        ASSERT_GL_NO_ERROR();
+    }
+
+    // Use the first program which uses four buffers.
+    GLProgramPipeline pipeline;
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram1);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+
+    glBeginTransformFeedback(GL_POINTS);
+    glDrawArrays(GL_POINTS, 0, 1);
+    glEndTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    // Validate the XFB values for the first program.
+    for (int i = 0; i < 4; ++i)
+    {
+        glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[i]);
+        const float *bufferData = reinterpret_cast<float *>(
+            glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, sizeof(GLfloat), GL_MAP_READ_BIT));
+        ASSERT_NE(nullptr, bufferData);
+        EXPECT_EQ(*bufferData, 1.0f + static_cast<float>(i));
+        glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
+    }
+
+    // One of the buffers that will not be used in the second program is expanded.
+    constexpr GLsizei kLargeSize = 8 * 1024 * 1024;
+    glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, xfbBuffers[2]);
+    glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kLargeSize, nullptr, GL_DYNAMIC_DRAW);
+    ASSERT_GL_NO_ERROR();
+
+    // Update the pipeline to use the second program which only uses the first two buffers.
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram2);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+    glBeginTransformFeedback(GL_POINTS);
+    glPauseTransformFeedback();
+    ASSERT_GL_NO_ERROR();
+
+    // Resuming transform feedback after changing a shader stage should result in validation error.
+    glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram1);
+    glBindProgramPipeline(pipeline);
+    glUseProgram(0);
+    ASSERT_GL_NO_ERROR();
+    glResumeTransformFeedback();
+    EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+    glDrawArrays(GL_POINTS, 0, 1);
+    ASSERT_GL_NO_ERROR();
+
+    // Resuming transform feedback with the same shaders from the beginning is OK.
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

Potential GPU memory corruption via Transform Feedback state confusion in ANGLE Vulkan

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A vulnerability in ANGLE’s Vulkan backend allows a compromised renderer to bypass transform feedback validation and cause the GPU to write data into stale or reallocated memory regions. This is caused by a failure to validate program consistency on resume and a failure to clear cached Vulkan buffer handles.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/TransformFeedbackVk.cpp
  • third_party/angle/src/libANGLE/validationES3.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/validationES.cpp
  • third_party/angle/src/libANGLE/validationES2.cpp
  • gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
  • gpu/command_buffer/service/gles2_cmd_decoder.cc

Estimated timestamp from git blame: 2025-01-22

Description

An issue exists in ANGLE’s Vulkan backend where stale VkBuffer handles and offsets can be reused during transform feedback (TF) operations after a program switch and a pause/resume cycle. This leads to an out-of-bounds write or use-after-free scenario on the GPU, where vertex shader output is DMA-written into memory regions that may have been reallocated for other purposes (such as indirect-draw buffers or uniform buffers).

The vulnerability stems from three interacting issues in validation logic and the Vulkan backend implementation:

  1. Validation Omission: ValidateResumeTransformFeedback (in third_party/angle/src/libANGLE/validationES3.cpp) does not verify that the currently bound program matches the program that was active when glBeginTransformFeedback was called. This violates the GLES 3.0 specification (§2.15.2) and allows a paused TF object to be resumed with a completely different program.
  2. Incomplete Cleanup: TransformFeedbackVk::end() (in third_party/angle/src/libANGLE/renderer/vulkan/TransformFeedbackVk.cpp) correctly detaches observer bindings (mBufferObserverBindings.reset()) but fails to clear the aggressively cached Vulkan handles in mBufferHandles, mBufferOffsets, and mBufferSizes. Consequently, if a buffer is reallocated (e.g., via glBufferData), the cached handle and offset become stale because the observer that would normally trigger a cache update (onSubjectStateChange) has been removed.
  3. Missing Refresh on Resume: TransformFeedbackVk::resume() skips the call to initializeXFBVariables() when the native VK_EXT_transform_feedback extension is used (i.e., contextVk->getFeatures().emulateTransformFeedback.enabled is false). This prevents the stale cache from being refreshed during the resume operation.

While Blink includes renderer-side validation to prevent resuming with the wrong program, the GPU process’s command decoders (e.g., GLES2DecoderImpl::DoResumeTransformFeedback) forward the ResumeTransformFeedback command to ANGLE without performing equivalent program identity checks. This allows a compromised renderer to bypass the check entirely.

Potential Reproduction Steps

The following sequence outlines how a compromised renderer could theoretically trigger the vulnerability by sending raw GLES3 commands to the GPU process on a platform using ANGLE’s Vulkan backend with native transform feedback support:

  1. Setup: Bind 4 buffers using glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, i, buffer[i]) for indices 0 to 3.
  2. Use Program A: glUseProgram(progA) where progA is compiled to output 4 separate-attribs XFB varyings.
  3. Begin TF: glBeginTransformFeedback(GL_POINTS). TransformFeedbackVk resolves all 4 buffers, binds observers, and caches their raw VkBuffer handles into mBufferHandles[0..3].
  4. End TF: glEndTransformFeedback(). The observers are reset, but the cached handles in mBufferHandles are not cleared.
  5. Reallocate Buffer: Call glBufferData on the buffers bound to indices 2 and 3. This causes BufferVk to release the old Vulkan suballocation and acquire a new one. The stale cache in TransformFeedbackVk is not updated because the observers were removed in step 4.
  6. Use Program B: glUseProgram(progB) where progB is compiled to output only 2 separate-attribs XFB varyings.
  7. Begin TF: glBeginTransformFeedback(GL_POINTS). Because progB only has 2 outputs, only slots 0 and 1 of the cache are refreshed. Slots 2 and 3 remain stale from the first session.
  8. Pause TF: glPauseTransformFeedback().
  9. Switch Program: glUseProgram(progA). This is permitted by ValidateUseProgram because TF is active but paused.
  10. Resume TF: glResumeTransformFeedback(). Validation passes because ValidateResumeTransformFeedback incorrectly omits the check for program identity. The Vulkan backend resume() skips the cache refresh on the native Vulkan path.
  11. Execute: glDrawArrays(GL_POINTS, 0, N). The dirty bits handler (ContextVk::handleDirtyGraphicsTransformFeedbackBuffersExtension) queries progA for the buffer count (which is 4). It retrieves the mBufferHandles array and binds all 4 handles to the Vulkan pipeline (vkCmdBindTransformFeedbackBuffersEXT).

The GPU then DMA-writes the vertex shader output for slots 2 and 3 into the physical memory regions pointed to by the stale handles. If the attacker groomed the GPU memory, these freed suballocations could have been reallocated for a victim buffer, leading to arbitrary memory corruption within the highly privileged GPU process context. MiraclePtr offers no protection here, as this is a GPU memory issue.

Suggested Fix

To remediate this issue, the following changes are recommended:

  1. Enforce Validation: Update ValidateResumeTransformFeedback in third_party/angle/src/libANGLE/validationES3.cpp to correctly enforce the GLES 3.0 specification. It must check that the currently bound program matches the program that was active when glBeginTransformFeedback was called (stored in the TransformFeedback object).
  2. Clear Stale Caches: Update TransformFeedbackVk::end() in third_party/angle/src/libANGLE/renderer/vulkan/TransformFeedbackVk.cpp to explicitly clear mBufferHandles, mBufferOffsets, and mBufferSizes when it resets the observer bindings.
  3. Ensure Safe Resume: Consider modifying TransformFeedbackVk::resume() to refresh the cache (by calling initializeXFBVariables()) even when native transform feedback is used, to ensure the backend state accurately reflects the current bindings.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from 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.

View on issue tracker