CVE-2026-95325
Overview
Background
- ANGLE
- Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends; the
glbackend insrc/libANGLE/renderer/gl/drives a host OpenGL driver. - `ScopedGLState`
- an RAII helper in
BlitGL.cppused by blit paths that saves/restores GL state around a blit; itsenter()pauses queries andexit()is meant to resume them. - `StateManagerGL`
- tracks GL state including active queries, exposing
pauseAllQueries/resumeAllQueriesand per-targetpauseQuery/resumeQuery. - `gl::Query`
- a query object; while paused it is held in a temporarily-paused list of raw pointers inside
StateManagerGL.
Root Cause Analysis
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.ANGLE_TRY return between pause and resume leaked a paused query and left StateManagerGL holding a dangling pointer.Attack Path
- Set up active query
The attacker (via WebGL/GLES) begins a query so it is active in
StateManagerGLwhen a blit is triggered. - Trigger a blit
A WebGL operation that internally invokes a
BlitGLpath constructsScopedGLStateand callsenter(), whichpauseAllQueriesand stores the query as a raw pointer. - Force an early error
The attacker arranges for one of the blit’s
ANGLE_TRYsteps to fail, returning beforeexit(context)runs and (pre-patch) skipping query resumption. - Dangle the pointer
ScopedGLStateis destroyed via its assert-only destructor, leaving the query in the temporarily-paused list. - Free and reuse
The attacker destroys the underlying
gl::Query; the stale pointer inStateManagerGLis later dereferenced, yielding a use-after-free that can be groomed for control.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ScopedGLStatesrc/libANGLE/renderer/gl/BlitGL.cpp |
modified | |
TestFunctionsGLsrc/libANGLE/renderer/gl/StateManagerGL_unittest.cpp |
modified | |
TestQueryGLsrc/libANGLE/renderer/gl/StateManagerGL_unittest.cpp |
modified | |
StateManagerGLTestsrc/libANGLE/renderer/gl/StateManagerGL_unittest.cpp |
modified |
Files Changed
src/libANGLE/renderer/gl/StateManagerGL.cppsrc/libANGLE/renderer/gl/StateManagerGL_unittest.cppsrc/libANGLE/renderer/gl/BlitGL.cppsrc/tests/angle_unittests.gni
Audit Directions
- RAII destructors that only assertSearch for destructors whose body is a bare
ASSERT(...)guarding an invariant likemExited; on error paths the invariant is unmet and no cleanup happens, so convert them to perform idempotent cleanup asexit()now does. - Pause/resume bracketed by `ANGLE_TRY`Audit any
pauseAllQueries/pauseQuery(or similar save-state) call followed byANGLE_TRYsequences before the matching resume; an early return leaves raw-pointer paused lists dangling. - Cleanup conditional on successReview
resume/restore/releasehelpers 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.
Patch
From 9e9bff3b169c02f7b28faa6f5b857434e4ec26ae Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
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 <zork@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---
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)
Regression Test / PoC
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",
]
Original Bug Report
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.cppthird_party/angle/src/libANGLE/renderer/gl/StateManagerGL.hthird_party/angle/src/libANGLE/renderer/gl/BlitGL.cppthird_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::beginregisters the query:mQueries[AnySamples]points to theStandardQueryGLinstance (qGL), andqGL->mActiveQueryholds 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, andscopedState.enter(...)is called. - This invokes
StateManagerGL::pauseAllQueries(context)(StateManagerGL.cpp:810). - For the active query,
previousQuery->pause(context)is called. InsideStandardQueryGL::pause(QueryGL.cpp:159),qGL->mActiveQueryis set to0. - 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_CHECKexecutestexImage2D(line 1287). - If the driver returns an error (e.g.,
GL_OUT_OF_MEMORY),CheckErrorreturnsangle::Result::Stop(renderergl_utils.cpp:3236). - The macro triggers an early return from
BlitGL::generateMipmap, completely skippingscopedState.exit(context)at line 1316. ~ScopedGLState(BlitGL.cpp:76) runs but only executesASSERT(mExited), doing nothing in release builds. The pointerqGLis left inmTemporaryPausedQueries.
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 becausemActiveQueryis0.- The query is deleted, invoking
StandardQueryGL::~StandardQueryGL()(QueryGL.cpp:70). - This calls
clearInternalQueries(). BecausemActiveQuerywas set to0in Step 2, the conditionif (mActiveQuery != 0)is false. - The code completely skips calling
mStateManager->endQuery(...). The object is freed, butmTemporaryPausedQueries[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. SincemQueries[AnySamples]is nownullptr, 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, becauseStateManagerGLis owned byRendererEGLwhich is shared across all contexts in the same virtual group).
Suggested Fix
- Modify
~ScopedGLStateto properly clean up in release builds if!mExited. It should notify theStateManagerGLto clearmTemporaryPausedQueriesif an early exit occurred. - Alternatively, ensure
StateManagerGL::onMakeCurrentclearsmTemporaryPausedQueriesunconditionally, not just in debug builds. - Change
mTemporaryPausedQueriesto 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:576definesmTemporaryPausedQueriesas anangle::PackedEnumMap<gl::QueryType, QueryGL *>(bare pointer). - Destructor Missing Release Cleanup:
third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp:76shows~ScopedGLState() { ASSERT(mExited); }. - Early Exit Mechanism:
third_party/angle/src/libANGLE/renderer/gl/renderergl_utils.h:100-102definesANGLE_GL_TRY_ALWAYS_CHECKwhich callsCheckError.renderergl_utils.cpp:3211-3240showsHandleErrorreturningangle::Result::Stoponerror != GL_NO_ERROR. - Skipped Exit:
third_party/angle/src/libANGLE/renderer/gl/BlitGL.cpp:1287-1316demonstratesANGLE_GL_TRY_ALWAYS_CHECKinside the mipmap loop, followed byANGLE_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-167showsmActiveQuery = 0;duringpause(). - Query Deletion Bypass:
third_party/angle/src/libANGLE/renderer/gl/QueryGL.cpp:75-83showsclearInternalQueries()only callsmStateManager->endQuery(...)ifmActiveQuery != 0. - UAF Sink:
third_party/angle/src/libANGLE/renderer/gl/StateManagerGL.cpp:841-853showsresumeAllQueriesdereferencing the pointer and callingANGLE_TRY(pausedQuery->resume(context));. - Cross-Context Sharing:
third_party/angle/src/libANGLE/renderer/gl/egl/DisplayEGL.cpp:534-558andui/gl/gl_context_egl.cc:348verifyRendererEGL(and thusStateManagerGL) is shared perEGL_CONTEXT_VIRTUALIZATION_GROUP_ANGLE. - Android Unsandboxed GPU: As per
docs/security/process-sandboxes-by-platform.mdand 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.