High patch-now ~ plausible CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIPlausible
Reviewer notes: mProgram already existed pre-patch; only mProgramPipeline/mPPOPrograms are new. Freed object is not identified by the diff, and XFB holds a ref on the program. PPO paths are not WebGL2-reachable; Vulkan-only end() change suggests backend dependence.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker497533569
Fix commit835b70c0c913 (angle/angle) +1063/-6
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-05-02 (public git)
Disclosed2026-05-27
Patch gap25 days — fix public in source before the advisory

Background

Transform Feedback (XFB)
A GLES feature that captures vertex/geometry shader outputs (varyings) into buffer objects; it can be paused with glPauseTransformFeedback and later resumed with glResumeTransformFeedback.
`ProgramExecutable`
The linked, executable state of a Program or ProgramPipeline that defines which varyings XFB records and how buffer bindings map; the XFB object caches this at begin().
`ProgramPipeline` (PPO)
A separable-program container bound via glBindProgramPipeline; when a monolithic Program is bound it overrides any bound PPO for draw and XFB purposes.
`gl::TransformFeedbackState`
Backing state of a TransformFeedback object that records the program/PPO and cached buffer bindings active when XFB began.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: mProgram already existed pre-patch; only mProgramPipeline/mPPOPrograms are new. Freed object is not identified by the diff, and XFB holds a ref on the program. PPO paths are not WebGL2-reachable; Vulkan-only end() change suggests backend dependence.

When transform feedback begins, the TransformFeedback object caches the ProgramExecutable and buffer bindings of the currently active program (or PPO), because those define the varyings being captured. GLES permits an application to pause XFB, switch to a completely different Program or ProgramPipeline, and then resume, but ANGLE never validated that the program/PPO active at resume matched the one recorded at begin().

As a result, a resume with a different program caused XFB to keep using the stale cached executable and buffer state from the original program, which may have since been deleted, relinked, or had its shader stages changed. In the Vulkan backend the cached data from begin() was also not cleared on end(), so outdated executable and buffer-binding information could be consumed when binding transform feedback buffers. The combination let the GPU process write captured varyings through dangling executable/buffer state, producing a use-after-free.

The fix records both mProgram and mProgramPipeline (plus mPPOPrograms) in TransformFeedbackState at begin(), rejects mismatching resumes with GL_INVALID_OPERATION, and clears cached data in TransformFeedbackVk::end().

Key insight
XFB caches an executable and its buffer bindings at begin() but the resume path trusted the current program without confirming it was the same program, so a swap-then-resume dereferenced state whose owning program could already be freed or relinked.

Attack Path

  1. Begin capture Bind a program with transform-feedback varyings, set up XFB buffers, and call glBeginTransformFeedback, caching that program’s executable/buffer state.
  2. Pause Call glPauseTransformFeedback to suspend capture while leaving the XFB object active.
  3. Swap or invalidate program Bind a different Program/ProgramPipeline, or delete/relink the original so its cached executable and buffers become dangling.
  4. Resume Call glResumeTransformFeedback, which (pre-fix) proceeds using the stale cached executable/buffer bindings from the freed program.
  5. Trigger UAF Issue a draw that makes the GPU process write varyings through the dangling executable/buffer state, causing a use-after-free.

Impact Assessment

This is a use-after-free reachable from WebGL2 transform-feedback APIs, giving an attacker controlled writes through freed executable/buffer state in the GPU process. Successful exploitation can corrupt GPU-process memory and, chained with other primitives, lead to sandboxed-renderer/GPU-process code execution. The bug requires no special privileges beyond scripting a WebGL2 context.

Files Changed

  • src/libANGLE/Context.cpp
  • src/libANGLE/ErrorStrings.h
  • src/libANGLE/TransformFeedback.cpp

Audit Directions

  • Cached-executable lifetime across pause/resume
    Audit any state that snapshots a ProgramExecutable or buffer bindings at a begin/start call and is later consumed at resume/end; verify the owning object cannot be deleted, relinked, or swapped in between without invalidation.
  • Program vs. PPO override paths
    Review sites where a bound Program overrides a ProgramPipeline; ensure both are consistently recorded and validated so a swap between the two does not resurrect stale state.
  • Backend cache clearing on `end()`
    Check each renderer backend’s end()/teardown for XFB (and similar cached-binding features) to confirm cached data is cleared, preventing reuse of outdated bindings on the next begin().
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