Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Sub-componentGL
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker536648933
Fix commit9e9bff3b169c (angle/angle) +234/-20
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-19 (per vendor advisory)
Fix landed2026-08-27 (public git)
Disclosed2026-09-22
Patch gap26 days — fix public in source before the advisory

Background

ANGLE
Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends; the gl backend in src/libANGLE/renderer/gl/ drives a host OpenGL driver.
`ScopedGLState`
an RAII helper in BlitGL.cpp used by blit paths that saves/restores GL state around a blit; its enter() pauses queries and exit() is meant to resume them.
`StateManagerGL`
tracks GL state including active queries, exposing pauseAllQueries/resumeAllQueries and per-target pauseQuery/resumeQuery.
`gl::Query`
a query object; while paused it is held in a temporarily-paused list of raw pointers inside StateManagerGL.

Root Cause Analysis

When a blit path calls scopedState.enter(...), StateManagerGL::pauseAllQueries moves active queries into a temporarily-paused list holding raw pointers. The blit code then executes a sequence of ANGLE_TRY(...) operations, any of which can return early on GL error before reaching the explicit scopedState.exit(context) call. Because the pre-patch destructor only asserted mExited rather than performing cleanup, an early ANGLE_TRY failure destroyed the ScopedGLState without ever resuming the paused queries, so those raw pointers were left dangling in StateManagerGL. When one of those gl::Query objects was subsequently destroyed, the stale pointer in the paused-query list produced a use-after-free. A secondary defect was that StateManagerGL::resumeAllQueries and resumeQuery only cleared the paused-query pointer when resume() succeeded, so a failing resume() could also leave a dangling reference.
Key insight
An RAII destructor that merely asserts an invariant instead of performing cleanup provides no safety on the error path, so any early ANGLE_TRY return between pause and resume leaked a paused query and left StateManagerGL holding a dangling pointer.

Attack Path

  1. Set up active query The attacker (via WebGL/GLES) begins a query so it is active in StateManagerGL when a blit is triggered.
  2. Trigger a blit A WebGL operation that internally invokes a BlitGL path constructs ScopedGLState and calls enter(), which pauseAllQueries and stores the query as a raw pointer.
  3. Force an early error The attacker arranges for one of the blit’s ANGLE_TRY steps to fail, returning before exit(context) runs and (pre-patch) skipping query resumption.
  4. Dangle the pointer ScopedGLState is destroyed via its assert-only destructor, leaving the query in the temporarily-paused list.
  5. Free and reuse The attacker destroys the underlying gl::Query; the stale pointer in StateManagerGL is later dereferenced, yielding a use-after-free that can be groomed for control.

Impact Assessment

This is a use-after-free in the ANGLE gl backend reachable from WebGL content, giving an attacker a dangling gl::Query pointer inside StateManagerGL. Such freed-object dereferences are typically exploitable for memory corruption and, combined with heap grooming, can lead to renderer-process code execution. The bug is triggered through ordinary query and blit operations, so it is reachable from untrusted web content.

Changed Functions

FunctionChangeNotes
ScopedGLState
src/libANGLE/renderer/gl/BlitGL.cpp
modified
TestFunctionsGL
src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
modified
TestQueryGL
src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
modified
StateManagerGLTest
src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
modified

Files Changed

  • src/libANGLE/renderer/gl/StateManagerGL.cpp
  • src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
  • src/libANGLE/renderer/gl/BlitGL.cpp
  • src/tests/angle_unittests.gni

Audit Directions

  • RAII destructors that only assert
    Search for destructors whose body is a bare ASSERT(...) guarding an invariant like mExited; on error paths the invariant is unmet and no cleanup happens, so convert them to perform idempotent cleanup as exit() now does.
  • Pause/resume bracketed by `ANGLE_TRY`
    Audit any pauseAllQueries/pauseQuery (or similar save-state) call followed by ANGLE_TRY sequences before the matching resume; an early return leaves raw-pointer paused lists dangling.
  • Cleanup conditional on success
    Review resume/restore/release helpers that only clear their tracking pointer when the operation succeeds; the pointer should be cleared unconditionally so a failed resume cannot leave a dangling reference.
From 9e9bff3b169c02f7b28faa6f5b857434e4ec26ae Mon Sep 17 00:00:00 2001
From: Tzarial <[email protected]>
Date: Tue, 11 Aug 2026 13:10:08 +0000
Subject: [PATCH] GL: Fix UAF on query destruction after early exit

Ensure that ScopedGLState resumes queries if it exits early due to
an error. This prevents queries from being left in the temporarily-
paused list and causing a UAF when they are destroyed.

Also ensure StateManagerGL::resumeAllQueries and resumeQuery clear
the paused query pointer even if resume() fails.

Bug: b/536648933
Test: StateManagerGLTest.*
Change-Id: I091d644009652a8fcd8ae1aaf0be3c2a7d5cb449
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8239473
Commit-Queue: Tzarial <[email protected]>
Reviewed-by: Geoff Lang <[email protected]>
---
diff --git a/src/libANGLE/renderer/gl/BlitGL.cpp b/src/libANGLE/renderer/gl/BlitGL.cpp
index 449af83..e267cb7 100644
--- a/src/libANGLE/renderer/gl/BlitGL.cpp
+++ b/src/libANGLE/renderer/gl/BlitGL.cpp
@@ -73,13 +73,16 @@
 
     ScopedGLState() {}
 
-    ~ScopedGLState() { ASSERT(mExited); }
+    ~ScopedGLState() { (void)exit(); }
 
     angle::Result enter(const gl::Context *context, gl::Rectangle viewport, int keepState = 0)
     {
+        ASSERT(mContext == nullptr);
         ContextGL *contextGL         = GetImplAs<ContextGL>(context);
         StateManagerGL *stateManager = contextGL->getStateManager();
 
+        mContext = context;
+
         if (!(keepState & KEEP_SCISSOR))
         {
             stateManager->setScissorTestEnabled(false);
@@ -108,9 +111,15 @@
         return stateManager->pauseAllQueries(context);
     }
 
-    angle::Result exit(const gl::Context *context)
+    angle::Result exit()
     {
-        mExited = true;
+        if (mContext == nullptr)
+        {
+            return angle::Result::Continue;
+        }
+
+        const gl::Context *context = mContext;
+        mContext                   = nullptr;
 
         ContextGL *contextGL         = GetImplAs<ContextGL>(context);
         StateManagerGL *stateManager = contextGL->getStateManager();
@@ -119,9 +128,10 @@
         return stateManager->resumeAllQueries(context);
     }
 
-    void willUseTextureUnit(const gl::Context *context, int unit)
+    void willUseTextureUnit(int unit)
     {
-        ContextGL *contextGL = GetImplAs<ContextGL>(context);
+        ASSERT(mContext != nullptr);
+        ContextGL *contextGL = GetImplAs<ContextGL>(mContext);
 
         if (contextGL->getFunctions()->bindSampler)
         {
@@ -130,7 +140,7 @@
     }
 
   private:
-    bool mExited = false;
+    const gl::Context *mContext = nullptr;
 };
 
 angle::Result SetClearState(StateManagerGL *stateManager,
@@ -419,7 +429,7 @@
     // Render to the destination texture, sampling from the scratch texture
     ScopedGLState scopedState;
     ANGLE_TRY(scopedState.enter(context, gl::Rectangle(0, 0, sourceArea.width, sourceArea.height)));
-    scopedState.willUseTextureUnit(context, 0);
+    scopedState.willUseTextureUnit(0);
 
     ANGLE_TRY(setScratchTextureParameter(context, GL_TEXTURE_MIN_FILTER, GL_NEAREST));
     ANGLE_TRY(setScratchTextureParameter(context, GL_TEXTURE_MAG_FILTER, GL_NEAREST));
@@ -460,7 +470,7 @@
     ANGLE_TRY(orphanScratchTextures(context));
     ANGLE_TRY(UnbindAttachment(context, mFunctions, GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0));
 
-    ANGLE_TRY(scopedState.exit(context));
+    ANGLE_TRY(scopedState.exit());
     return angle::Result::Continue;
 }
 
@@ -598,7 +608,7 @@
     // rectangle
     ScopedGLState scopedState;
     ANGLE_TRY(scopedState.enter(context, destArea, ScopedGLState::KEEP_SCISSOR));
-    scopedState.willUseTextureUnit(context, 0);
+    scopedState.willUseTextureUnit(0);
 
     // Set the write color mask to potentially not write alpha
     mStateManager->setColorMask(true, true, true, writeAlpha);
@@ -622,7 +632,7 @@
     ANGLE_TRY(setVAOState(context));
     ANGLE_GL_TRY(context, mFunctions->drawArrays(GL_TRIANGLES, 0, 3));
 
-    ANGLE_TRY(scopedState.exit(context));
+    ANGLE_TRY(scopedState.exit());
     return angle::Result::Continue;
 }
 
@@ -697,7 +707,7 @@
     ScopedGLState scopedState;
     ANGLE_TRY(scopedState.enter(
         context, gl::Rectangle(destOffset.x, destOffset.y, sourceArea.width, sourceArea.height)));
-    scopedState.willUseTextureUnit(context, 0);
+    scopedState.willUseTextureUnit(0);
 
     mStateManager->activeTexture(0);
     mStateManager->bindTexture(source->getType(), source->getTextureID());
@@ -742,7 +752,7 @@
     ANGLE_TRY(UnbindAttachment(context, mFunctions, GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0));
 
     *copySucceededOut = true;
-    ANGLE_TRY(scopedState.exit(context));
+    ANGLE_TRY(scopedState.exit());
     return angle::Result::Continue;
 }
 
@@ -1272,7 +1282,7 @@
     ScopedGLState scopedState;
     ANGLE_TRY(scopedState.enter(
         context, gl::Rectangle(0, 0, sourceBaseLevelSize.width, sourceBaseLevelSize.height)));
-    scopedState.willUseTextureUnit(context, 0);
+    scopedState.willUseTextureUnit(0);
     mStateManager->activeTexture(0);
 
     // Copy source to an intermediate texture.
@@ -1342,7 +1352,7 @@
     ANGLE_TRY(orphanScratchTextures(context));
     ANGLE_TRY(UnbindAttachment(context, mFunctions, GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0));
 
-    ANGLE_TRY(scopedState.exit(context));
+    ANGLE_TRY(scopedState.exit());
     return angle::Result::Continue;
 }
 
diff --git a/src/libANGLE/renderer/gl/StateManagerGL.cpp b/src/libANGLE/renderer/gl/StateManagerGL.cpp
index c212b20..ba1bc56 100644
--- a/src/libANGLE/renderer/gl/StateManagerGL.cpp
+++ b/src/libANGLE/renderer/gl/StateManagerGL.cpp
@@ -2323,29 +2323,31 @@
 
 angle::Result StateManagerGL::resumeAllQueries(const gl::Context *context)
 {
+    angle::ResultAccumulator result;
+
     for (gl::QueryType type : angle::AllEnums<gl::QueryType>())
     {
-        QueryGL *pausedQuery = mTemporaryPausedQueries[type];
+        QueryGL *pausedQuery          = mTemporaryPausedQueries[type];
+        mTemporaryPausedQueries[type] = nullptr;
 
         if (pausedQuery != nullptr)
         {
             ASSERT(mQueries[type] == nullptr);
-            ANGLE_TRY(pausedQuery->resume(context));
-            mTemporaryPausedQueries[type] = nullptr;
+            result = pausedQuery->resume(context);
         }
     }
 
-    return angle::Result::Continue;
+    return result;
 }
 
 angle::Result StateManagerGL::resumeQuery(const gl::Context *context, gl::QueryType type)
 {
-    QueryGL *pausedQuery = mTemporaryPausedQueries[type];
+    QueryGL *pausedQuery          = mTemporaryPausedQueries[type];
+    mTemporaryPausedQueries[type] = nullptr;
 
     if (pausedQuery != nullptr)
     {
         ANGLE_TRY(pausedQuery->resume(context));
-        mTemporaryPausedQueries[type] = nullptr;
     }
 
     return angle::Result::Continue;
diff --git a/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp b/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
new file mode 100644
index 0000000..27ea7a3
--- /dev/null
+++ b/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
@@ -0,0 +1,201 @@
+//
+// Copyright 2026 The ANGLE Project Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+
+#include "gtest/gtest.h"
+
+#include "common/unsafe_buffers.h"
+#include "libANGLE/Caps.h"
+#include "libANGLE/renderer/gl/FunctionsGL.h"
+#include "libANGLE/renderer/gl/QueryGL.h"
+#include "libANGLE/renderer/gl/StateManagerGL.h"
+
+namespace rx
+{
+namespace
+{
+
+void GL_APIENTRY StubBeginQuery(GLenum, GLuint) {}
+void GL_APIENTRY StubEndQuery(GLenum) {}
+GLenum GL_APIENTRY StubGetError()
+{
+    return GL_NO_ERROR;
+}
+void GL_APIENTRY StubGetIntegerv(GLenum pname, GLint *data)
+{
+    if (pname == GL_VIEWPORT || pname == GL_SCISSOR_BOX)
+    {
+        ANGLE_UNSAFE_BUFFERS({
+            data[0] = 0;
+            data[1] = 0;
+            data[2] = 0;
+            data[3] = 0;
+        });
+    }
+}
+
+// Minimal FunctionsGL that never calls into a real driver. Only the entry
+// points touched by StateManagerGL::beginQuery/endQuery are populated.
+class TestFunctionsGL : public FunctionsGL
+{
+  public:
+    TestFunctionsGL()
+    {
+        standard = STANDARD_GL_ES;
+        version  = gl::Version(3, 0);
+        profile  = 0;
+
+        beginQuery  = StubBeginQuery;
+        endQuery    = StubEndQuery;
+        getError    = StubGetError;
+        getIntegerv = StubGetIntegerv;
+    }
+
+  private:
+    void *loadProcAddress(const std::string &function) const override { return nullptr; }
+};
+
+// QueryGL implementation that records pause/resume calls and can be forced to
+// fail resume() so that error-propagation paths in StateManagerGL are covered.
+class TestQueryGL : public QueryGL
+{
+  public:
+    explicit TestQueryGL(gl::QueryType type) : QueryGL(type) {}
+
+    angle::Result begin(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result end(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result queryCounter(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result getResult(const gl::Context *, GLint *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLuint *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLint64 *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLuint64 *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result isResultAvailable(const gl::Context *, bool *) override
+    {
+        return angle::Result::Continue;
+    }
+
+    angle::Result pause(const gl::Context *) override
+    {
+        mPauseCount++;
+        return angle::Result::Continue;
+    }
+    angle::Result resume(const gl::Context *) override
+    {
+        mResumeCount++;
+        return mResumeResult;
+    }
+
+    void setResumeResult(angle::Result result) { mResumeResult = result; }
+    int pauseCount() const { return mPauseCount; }
+    int resumeCount() const { return mResumeCount; }
+
+  private:
+    angle::Result mResumeResult = angle::Result::Continue;
+    int mPauseCount             = 0;
+    int mResumeCount            = 0;
+};
+
+gl::Caps MakeMinimalCaps()
+{
+    gl::Caps caps;
+    caps.maxDrawBuffers          = 1;
+    caps.maxVertexAttributes     = 1;
+    caps.maxVertexAttribBindings = 1;
+    return caps;
+}
+
+class StateManagerGLTest : public ::testing::Test
+{
+  protected:
+    StateManagerGLTest()
+        : mCaps(MakeMinimalCaps()), mStateManager(&mFunctions, mCaps, mExtensions, mFeatures)
+    {}
+
+    StateManagerGL &stateManager() { return mStateManager; }
+
+  private:
+    TestFunctionsGL mFunctions;
+    gl::Caps mCaps;
+    gl::Extensions mExtensions;
+    angle::FeaturesGL mFeatures;
+    StateManagerGL mStateManager;
+};
+
+// When resume() reports an error for a paused query, StateManagerGL must still
+// drop its reference so that a later resumeAllQueries() call does not touch the
+// query again after it may have been destroyed.
+TEST_F(StateManagerGLTest, ResumeAllQueriesClearsPausedQueryOnError)
+{
+    TestQueryGL query(gl::QueryType::AnySamples);
+
+    stateManager().beginQuery(gl::QueryType::AnySamples, &query, 1);
+    EXPECT_EQ(angle::Result::Continue, stateManager().pauseAllQueries(nullptr));
+    EXPECT_EQ(1, query.pauseCount());
+
+    query.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(1, query.resumeCount());
+
+    // The query is no longer tracked; a subsequent resume must not reach it.
+    query.setResumeResult(angle::Result::Continue);
+    EXPECT_EQ(angle::Result::Continue, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(1, query.resumeCount());
+}
+
+// If one paused query fails to resume, StateManagerGL must still drop its
+// references to every other paused query rather than leaving them behind.
+TEST_F(StateManagerGLTest, ResumeAllQueriesClearsAllPausedQueriesOnError)
+{
+    TestQueryGL failingQuery(gl::QueryType::AnySamples);
+    TestQueryGL otherQuery(gl::QueryType::TransformFeedbackPrimitivesWritten);
+
+    stateManager().beginQuery(failingQuery.getType(), &failingQuery, 1);
+    stateManager().beginQuery(otherQuery.getType(), &otherQuery, 2);
+    EXPECT_EQ(angle::Result::Continue, stateManager().pauseAllQueries(nullptr));
+
+    failingQuery.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeAllQueries(nullptr));
+
+    // Neither query should be reached by a subsequent resume.
+    int failingResumeCount = failingQuery.resumeCount();
+    int otherResumeCount   = otherQuery.resumeCount();
+    EXPECT_EQ(angle::Result::Continue, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(failingResumeCount, failingQuery.resumeCount());
+    EXPECT_EQ(otherResumeCount, otherQuery.resumeCount());
+}
+
+// resumeQuery() must clear its paused-query reference even when resume() fails.
+TEST_F(StateManagerGLTest, ResumeQueryClearsPausedQueryOnError)
+{
+    TestQueryGL query(gl::QueryType::AnySamples);
+
+    stateManager().beginQuery(gl::QueryType::AnySamples, &query, 1);
+    EXPECT_EQ(angle::Result::Continue,
+              stateManager().pauseQuery(nullptr, gl::QueryType::AnySamples));
+
+    query.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeQuery(nullptr, gl::QueryType::AnySamples));
+    EXPECT_EQ(1, query.resumeCount());
+
+    query.setResumeResult(angle::Result::Continue);
+    EXPECT_EQ(angle::Result::Continue,
+              stateManager().resumeQuery(nullptr, gl::QueryType::AnySamples));
+    EXPECT_EQ(1, query.resumeCount());
+}
+
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp b/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
new file mode 100644
index 0000000..27ea7a3
--- /dev/null
+++ b/src/libANGLE/renderer/gl/StateManagerGL_unittest.cpp
@@ -0,0 +1,201 @@
+//
+// Copyright 2026 The ANGLE Project Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+
+#include "gtest/gtest.h"
+
+#include "common/unsafe_buffers.h"
+#include "libANGLE/Caps.h"
+#include "libANGLE/renderer/gl/FunctionsGL.h"
+#include "libANGLE/renderer/gl/QueryGL.h"
+#include "libANGLE/renderer/gl/StateManagerGL.h"
+
+namespace rx
+{
+namespace
+{
+
+void GL_APIENTRY StubBeginQuery(GLenum, GLuint) {}
+void GL_APIENTRY StubEndQuery(GLenum) {}
+GLenum GL_APIENTRY StubGetError()
+{
+    return GL_NO_ERROR;
+}
+void GL_APIENTRY StubGetIntegerv(GLenum pname, GLint *data)
+{
+    if (pname == GL_VIEWPORT || pname == GL_SCISSOR_BOX)
+    {
+        ANGLE_UNSAFE_BUFFERS({
+            data[0] = 0;
+            data[1] = 0;
+            data[2] = 0;
+            data[3] = 0;
+        });
+    }
+}
+
+// Minimal FunctionsGL that never calls into a real driver. Only the entry
+// points touched by StateManagerGL::beginQuery/endQuery are populated.
+class TestFunctionsGL : public FunctionsGL
+{
+  public:
+    TestFunctionsGL()
+    {
+        standard = STANDARD_GL_ES;
+        version  = gl::Version(3, 0);
+        profile  = 0;
+
+        beginQuery  = StubBeginQuery;
+        endQuery    = StubEndQuery;
+        getError    = StubGetError;
+        getIntegerv = StubGetIntegerv;
+    }
+
+  private:
+    void *loadProcAddress(const std::string &function) const override { return nullptr; }
+};
+
+// QueryGL implementation that records pause/resume calls and can be forced to
+// fail resume() so that error-propagation paths in StateManagerGL are covered.
+class TestQueryGL : public QueryGL
+{
+  public:
+    explicit TestQueryGL(gl::QueryType type) : QueryGL(type) {}
+
+    angle::Result begin(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result end(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result queryCounter(const gl::Context *) override { return angle::Result::Continue; }
+    angle::Result getResult(const gl::Context *, GLint *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLuint *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLint64 *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result getResult(const gl::Context *, GLuint64 *) override
+    {
+        return angle::Result::Continue;
+    }
+    angle::Result isResultAvailable(const gl::Context *, bool *) override
+    {
+        return angle::Result::Continue;
+    }
+
+    angle::Result pause(const gl::Context *) override
+    {
+        mPauseCount++;
+        return angle::Result::Continue;
+    }
+    angle::Result resume(const gl::Context *) override
+    {
+        mResumeCount++;
+        return mResumeResult;
+    }
+
+    void setResumeResult(angle::Result result) { mResumeResult = result; }
+    int pauseCount() const { return mPauseCount; }
+    int resumeCount() const { return mResumeCount; }
+
+  private:
+    angle::Result mResumeResult = angle::Result::Continue;
+    int mPauseCount             = 0;
+    int mResumeCount            = 0;
+};
+
+gl::Caps MakeMinimalCaps()
+{
+    gl::Caps caps;
+    caps.maxDrawBuffers          = 1;
+    caps.maxVertexAttributes     = 1;
+    caps.maxVertexAttribBindings = 1;
+    return caps;
+}
+
+class StateManagerGLTest : public ::testing::Test
+{
+  protected:
+    StateManagerGLTest()
+        : mCaps(MakeMinimalCaps()), mStateManager(&mFunctions, mCaps, mExtensions, mFeatures)
+    {}
+
+    StateManagerGL &stateManager() { return mStateManager; }
+
+  private:
+    TestFunctionsGL mFunctions;
+    gl::Caps mCaps;
+    gl::Extensions mExtensions;
+    angle::FeaturesGL mFeatures;
+    StateManagerGL mStateManager;
+};
+
+// When resume() reports an error for a paused query, StateManagerGL must still
+// drop its reference so that a later resumeAllQueries() call does not touch the
+// query again after it may have been destroyed.
+TEST_F(StateManagerGLTest, ResumeAllQueriesClearsPausedQueryOnError)
+{
+    TestQueryGL query(gl::QueryType::AnySamples);
+
+    stateManager().beginQuery(gl::QueryType::AnySamples, &query, 1);
+    EXPECT_EQ(angle::Result::Continue, stateManager().pauseAllQueries(nullptr));
+    EXPECT_EQ(1, query.pauseCount());
+
+    query.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(1, query.resumeCount());
+
+    // The query is no longer tracked; a subsequent resume must not reach it.
+    query.setResumeResult(angle::Result::Continue);
+    EXPECT_EQ(angle::Result::Continue, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(1, query.resumeCount());
+}
+
+// If one paused query fails to resume, StateManagerGL must still drop its
+// references to every other paused query rather than leaving them behind.
+TEST_F(StateManagerGLTest, ResumeAllQueriesClearsAllPausedQueriesOnError)
+{
+    TestQueryGL failingQuery(gl::QueryType::AnySamples);
+    TestQueryGL otherQuery(gl::QueryType::TransformFeedbackPrimitivesWritten);
+
+    stateManager().beginQuery(failingQuery.getType(), &failingQuery, 1);
+    stateManager().beginQuery(otherQuery.getType(), &otherQuery, 2);
+    EXPECT_EQ(angle::Result::Continue, stateManager().pauseAllQueries(nullptr));
+
+    failingQuery.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeAllQueries(nullptr));
+
+    // Neither query should be reached by a subsequent resume.
+    int failingResumeCount = failingQuery.resumeCount();
+    int otherResumeCount   = otherQuery.resumeCount();
+    EXPECT_EQ(angle::Result::Continue, stateManager().resumeAllQueries(nullptr));
+    EXPECT_EQ(failingResumeCount, failingQuery.resumeCount());
+    EXPECT_EQ(otherResumeCount, otherQuery.resumeCount());
+}
+
+// resumeQuery() must clear its paused-query reference even when resume() fails.
+TEST_F(StateManagerGLTest, ResumeQueryClearsPausedQueryOnError)
+{
+    TestQueryGL query(gl::QueryType::AnySamples);
+
+    stateManager().beginQuery(gl::QueryType::AnySamples, &query, 1);
+    EXPECT_EQ(angle::Result::Continue,
+              stateManager().pauseQuery(nullptr, gl::QueryType::AnySamples));
+
+    query.setResumeResult(angle::Result::Stop);
+    EXPECT_EQ(angle::Result::Stop, stateManager().resumeQuery(nullptr, gl::QueryType::AnySamples));
+    EXPECT_EQ(1, query.resumeCount());
+
+    query.setResumeResult(angle::Result::Continue);
+    EXPECT_EQ(angle::Result::Continue,
+              stateManager().resumeQuery(nullptr, gl::QueryType::AnySamples));
+    EXPECT_EQ(1, query.resumeCount());
+}
+
+}  // namespace
+}  // namespace rx
diff --git a/src/tests/angle_unittests.gni b/src/tests/angle_unittests.gni
index 4a1d52f..c834278 100644
--- a/src/tests/angle_unittests.gni
+++ b/src/tests/angle_unittests.gni
@@ -130,6 +130,7 @@
 
 angle_unittests_gl_sources = [
   "../libANGLE/renderer/gl/DisplayGL_unittest.cpp",
+  "../libANGLE/renderer/gl/StateManagerGL_unittest.cpp",
   "../libANGLE/renderer/gl/renderergl_utils_unittest.cpp",
 ]
Loading diff…

Original Bug Report

reported by [email protected]

Potential UAF in ANGLE StateManagerGL via ScopedGLState early exit during generateMipmap

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A potential Use-After-Free (UAF) vulnerability exists in ANGLE’s GL backend because an early return on driver errors in BlitGL::generateMipmap skips ScopedGLState cleanup. This can leave a dangling QueryGL pointer in StateManagerGL::mTemporaryPausedQueries, leading to a UAF virtual call when the state is later resumed.

Affected files:

  • third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.cpp
  • third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.h
  • third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp
  • third_party/angle/src/libANGLE/renderer/gl/QueryGL.cpp

Estimated timestamp from git blame: 2018-02-05

1. Summary of the Issue (Meant for Human Triage)

A potential Use-After-Free (UAF) vulnerability exists in the ANGLE OpenGL ES backend (which ships as a default or fallback on Android) within StateManagerGL::mTemporaryPausedQueries. During complex operations like intermediate mipmap generation in BlitGL::generateMipmap, currently running WebGL queries are temporarily paused using a ScopedGLState helper. This helper stashes raw pointers to the queries in mTemporaryPausedQueries via pauseAllQueries().

If a driver error (such as GL_OUT_OF_MEMORY or GL_INVALID_VALUE) occurs mid-scope, an early return via the ANGLE_GL_TRY_ALWAYS_CHECK macro bypasses the ScopedGLState::exit() call. Because the ~ScopedGLState destructor only contains a debug-only ASSERT(mExited) and performs no cleanup in release builds, the raw pointers remain dangling in the state manager’s registry.

If the frontend WebGL query is subsequently deleted by the application, its backend destructor (~StandardQueryGL) fails to unregister itself from StateManagerGL because its active state was cleared to zero during the pause phase. A subsequent successful mipmap generation or state resume operation will call resumeAllQueries(), which retrieves the dangling pointer and invokes a virtual method (->resume()) on the deleted QueryGL object. In an unsandboxed GPU process environment (like Android), this memory corruption could potentially allow for a sandbox escape if the freed memory is reclaimed by an attacker.


2. Proof-of-Concept & Detailed Execution Flow

Note: The following steps describe a potential theoretical trigger path based on code analysis. Our tooling agent does not have the ability to run code or provide functional exploit primitives (like heap grooming).

Step 1: Query Creation and Registration

A WebGL2 application begins a query:

const q = gl.createQuery();
gl.beginQuery(gl.ANY_SAMPLES_PASSED, q);
  • In the backend, StandardQueryGL::begin registers the query: mQueries[AnySamples] points to the StandardQueryGL instance (qGL), and qGL->mActiveQuery holds the native GL query ID.

Step 2: Query Pausing and Stashing

The application triggers a mipmap generation on a texture configured to elicit a driver error during intermediate allocation:

gl.generateMipmap(gl.TEXTURE_2D);
  • Execution enters BlitGL::generateMipmap (third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp:1231).
  • A ScopedGLState scopedState; is instantiated, and scopedState.enter(...) is called.
  • This invokes StateManagerGL::pauseAllQueries(context) (StateManagerGL.cpp:810).
  • For the active query, previousQuery->pause(context) is called. Inside StandardQueryGL::pause (QueryGL.cpp:159), qGL->mActiveQuery is set to 0.
  • The raw pointer is stashed: mTemporaryPausedQueries[type] = previousQuery; (StateManagerGL.cpp:819).
  • The main registry is cleared: mQueries[type] = nullptr;.

Step 3: Mid-Scope Early Exit

  • During the mipmap generation loop (BlitGL.cpp:1280), ANGLE_GL_TRY_ALWAYS_CHECK executes texImage2D (line 1287).
  • If the driver returns an error (e.g., GL_OUT_OF_MEMORY), CheckError returns angle::Result::Stop (renderergl_utils.cpp:3236).
  • The macro triggers an early return from BlitGL::generateMipmap, completely skipping scopedState.exit(context) at line 1316.
  • ~ScopedGLState (BlitGL.cpp:76) runs but only executes ASSERT(mExited), doing nothing in release builds. The pointer qGL is left in mTemporaryPausedQueries.

Step 4: Query Deletion and Dangling Pointer Creation

The application deletes the query:

gl.endQuery(gl.ANY_SAMPLES_PASSED);
gl.deleteQuery(q);
  • endQuery() is a no-op on the backend because mActiveQuery is 0.
  • The query is deleted, invoking StandardQueryGL::~StandardQueryGL() (QueryGL.cpp:70).
  • This calls clearInternalQueries(). Because mActiveQuery was set to 0 in Step 2, the condition if (mActiveQuery != 0) is false.
  • The code completely skips calling mStateManager->endQuery(...). The object is freed, but mTemporaryPausedQueries[AnySamples] still points to its memory address.

Step 5: Triggering the Use-After-Free

The application could potentially reclaim the freed memory (e.g., via texture uploads) and then trigger another successful generateMipmap call:

  • scopedState.enter() runs. Since mQueries[AnySamples] is now nullptr, the stash is not overwritten.
  • The mipmap generation succeeds, and scopedState.exit(context) is called.
  • StateManagerGL::resumeAllQueries (StateManagerGL.cpp:841) retrieves the dangling pointer: QueryGL *pausedQuery = mTemporaryPausedQueries[type];.
  • It executes ANGLE_TRY(pausedQuery->resume(context));. This is a virtual call on a deleted object, leading to a Use-After-Free. (Note: This cross-context variant works even if the original OOM caused a context loss, because StateManagerGL is owned by RendererEGL which is shared across all contexts in the same virtual group).

Suggested Fix

  1. Modify ~ScopedGLState to properly clean up in release builds if !mExited. It should notify the StateManagerGL to clear mTemporaryPausedQueries if an early exit occurred.
  2. Alternatively, ensure StateManagerGL::onMakeCurrent clears mTemporaryPausedQueries unconditionally, not just in debug builds.
  3. Change mTemporaryPausedQueries to hold safe references or IDs rather than bare raw pointers, or integrate it with MiraclePtr (raw_ptr<T>) if feasible within the ANGLE architecture.

3. Technical Verification Details (Automated Audit Logs)

Critic Verdict Verbatim

> The report describes a UAF in StateManagerGL::mTemporaryPausedQueries within ANGLE’s GL backend (third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.cpp). When a query is paused by ScopedGLState::enter (e.g., during BlitGL::generateMipmap), a raw pointer to it is stashed in mTemporaryPausedQueries. If a subsequent driver call like glTexImage2D fails (which can be forced via GL_OUT_OF_MEMORY), ANGLE_GL_TRY_ALWAYS_CHECK returns Stop, skipping ScopedGLState::exit and leaving the raw pointer dangling in the registry. Upon query deletion, ~StandardQueryGL does not unregister itself because its mActiveQuery was cleared to 0. A subsequent successful generateMipmap call from a surviving context (since StateManagerGL is shared per virtualization group) will hit resumeAllQueries and invoke a virtual call (pausedQuery->resume) on the freed object. This is a GPU-process memory corruption (UAF with virtual dispatch, giving execution control) reachable by web content alone (A-SERVER) via standard WebGL2 API calls. As the ANGLE GL backend ships as a fallback/default on Android where the GPU process is unsandboxed, this is a sandbox escape. Furthermore, the mTemporaryPausedQueries array holds bare pointers and is not MiraclePtr-protected. Per the severity guidelines, an A-SERVER-reachable GPU-process UAF-write on Android is Critical (S0).

Verified Code Locations and State Flows

  • Registry Definition: third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.h:576 defines mTemporaryPausedQueries as an angle::PackedEnumMap<gl::QueryType, QueryGL *> (bare pointer).
  • Destructor Missing Release Cleanup: third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp:76 shows ~ScopedGLState() { ASSERT(mExited); }.
  • Early Exit Mechanism: third_party/angle/src/libANGLE/renderer/gl/renderergl_utils.h:100-102 defines ANGLE_GL_TRY_ALWAYS_CHECK which calls CheckError. renderergl_utils.cpp:3211-3240 shows HandleError returning angle::Result::Stop on error != GL_NO_ERROR.
  • Skipped Exit: third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp:1287-1316 demonstrates ANGLE_GL_TRY_ALWAYS_CHECK inside the mipmap loop, followed by ANGLE_TRY(scopedState.exit(context)); at the end, confirming the early return skips the exit.
  • Query Zeroing: third_party/angle/src/libANGLE/renderer/gl/QueryGL.cpp:159-167 shows mActiveQuery = 0; during pause().
  • Query Deletion Bypass: third_party/angle/src/libANGLE/renderer/gl/QueryGL.cpp:75-83 shows clearInternalQueries() only calls mStateManager->endQuery(...) if mActiveQuery != 0.
  • UAF Sink: third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.cpp:841-853 shows resumeAllQueries dereferencing the pointer and calling ANGLE_TRY(pausedQuery->resume(context));.
  • Cross-Context Sharing: third_party/angle/src/libANGLE/renderer/gl/egl/DisplayEGL.cpp:534-558 and ui/gl/gl_context_egl.cc:348 verify RendererEGL (and thus StateManagerGL) is shared per EGL_CONTEXT_VIRTUALIZATION_GROUP_ANGLE.
  • Android Unsandboxed GPU: As per docs/security/process-sandboxes-by-platform.md and the severity guidelines, the GPU process is unsandboxed on Android.

Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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