CVE-2026-7359
Overview
Background
- ANGLE resource manager
- the per-
Contextbookkeeping (BufferManager,TextureManager, etc.) that allocates and recycles numeric GL handle IDs for objects such as buffers and textures. - `GL_CHROMIUM_bind_generates_resource`
- an extension controlling whether binding an unused name implicitly creates an object; when
bindGeneratesResourcesis disabled, deleted IDs are normally returned to the free pool viarecycleHandleso they can be reissued. - Handle recycling vs. object lifetime
- an ID can be freed (recycled) at
glDelete*time even though the C++ object (e.g.Buffer) is still alive because another container (such as a bound Vertex Array Object) holds a reference. - `onDestroy`
- the method called when a resource’s refcount actually drops and the object is torn down, distinct from the application-level delete call.
Root Cause Analysis
With bindGeneratesResources disabled, deleting a GL object recycled its handle ID back into the free pool immediately at delete time, even if the underlying object was still referenced elsewhere (for example a buffer still bound into a VAO). A subsequent glGen* could then hand out the same ID while the old object was still alive, so two distinct live objects shared one ID, and operations on the “new” ID could resolve to, or prematurely tear down, the stale object — an ID-reuse / use-after-free condition. The invariant violated is that a handle ID must not be reissued while any object bearing it is still alive.
The fix defers recycling to real object destruction: Context::retainIdUntilObjectDestroyed returns true when managers are still alive and bindGeneratesResources is disabled, and the object’s destroy path then calls the matching Context::on*Destroy (e.g. onBufferDestroy), which performs recycleHandle(id()) only once onDestroy runs. To avoid recycling into already-torn-down managers during context teardown, releaseSharedObjects now sets mDestroyedManagers = true and nulls each manager pointer, and retainIdUntilObjectDestroyed consults that flag. The regression test BindRecyclesResourceTest.BufferRecycling deletes a buffer still referenced by a VAO and asserts the next generated ID differs.
recycleHandle to the object’s onDestroy (guarded by retainIdUntilObjectDestroyed and the mDestroyedManagers flag).Attack Path
- Disable implicit generation
The client uses a context with
bindGeneratesResourcesdisabled, taking the strict ID-management path. - Keep a reference alive
Generate buffer
idA, bind it into a VAO, then unbind the VAO so the VAO retains a reference to the buffer object. - Delete while still referenced
Call
glDeleteBuffers(idA); pre-patch,idAis recycled into the free pool immediately even though the buffer object survives via the VAO reference. - Reissue the stale ID
Call
glGenBuffers, which (pre-patch) can returnidAagain, so the reused ID now aliases a still-live object. - Trigger inconsistency Operations through the reused ID or teardown of the aliased objects produce stale-object use/ID-confusion in the GPU process; promotion to a concrete read/write primitive is inferred, as the diff only demonstrates the ID-reuse condition the test rejects.
Impact Assessment
bindGeneratesResources disabled can force handle-ID reuse while an object is still live, yielding a use-after-free / object-aliasing condition in the GPU process. The vendor advisory rates this high severity (use after free in ANGLE) and does not report exploited-in-the-wild status; escalation beyond the demonstrated ID-reuse is inferred. The main precondition is a context configured with bindGeneratesResources disabled plus a lingering reference (e.g. a VAO binding) at delete time.Files Changed
src/libANGLE/Buffer.cppsrc/libANGLE/Context.cppsrc/libANGLE/Context.hsrc/libANGLE/Fence.cpp
Audit Directions
- Delete-time vs. destroy-time recyclingAudit every resource manager’s delete path for
recycleHandle/free-pool insertion that runs before the object’s refcount actually reaches zero. - Reference-holding containersCheck objects (VAOs, PPOs, framebuffers) that retain references to other GL objects, where an application delete does not immediately destroy the referent.
- Teardown-ordering guardsVerify that any deferred-destruction callback (
on*Destroy) is guarded against running after its manager is released, as done withmDestroyedManagers.
Patch
From 3ba420fa0598eac541a516a7661482eb96e5e7ad Mon Sep 17 00:00:00 2001
From: dan sinclair <dsinclair@chromium.org>
Date: Mon, 20 Apr 2026 13:31:54 -0400
Subject: [PATCH] Defer the recycling of handles
This CL changes the resource manager to defer the recycling of IDs if
bindGeneratesResources is not enabled. This will hold onto a resource ID
until the resource has its `onDestroy` method called.
Bug: chromium:496284494
Change-Id: Id45d09738d906d66ca545861a31e445fc36a1267
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7757179
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Auto-Submit: dan sinclair <dsinclair@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: dan sinclair <dsinclair@chromium.org>
---
diff --git a/src/libANGLE/Buffer.cpp b/src/libANGLE/Buffer.cpp
index 525ae59..82eed7d 100644
--- a/src/libANGLE/Buffer.cpp
+++ b/src/libANGLE/Buffer.cpp
@@ -18,7 +18,7 @@
{
namespace
{
-constexpr size_t kInvalidContentsObserverIndex = std::numeric_limits<size_t>::max();
+constexpr size_t kInvalidContentsObserverIndex = std::numeric_limits<size_t>::max();
} // anonymous namespace
// VertexArrayBufferBindingMaskAndContext implementation
@@ -95,8 +95,7 @@
Buffer::Buffer(rx::GLImplFactory *factory, BufferID id)
: RefCountObject(factory->generateSerial(), id), mImpl(factory->createBuffer(mState))
-{
-}
+{}
Buffer::~Buffer()
{
@@ -107,9 +106,16 @@
{
mContentsObservers.clear();
+ if (context && context->retainIdUntilObjectDestroyed())
+ {
+ context->onBufferDestroy(this);
+ }
+
// In tests, mImpl might be null.
if (mImpl)
+ {
mImpl->destroy(context);
+ }
}
void Buffer::onBind(const Context *context, BufferBinding target)
diff --git a/src/libANGLE/Context.cpp b/src/libANGLE/Context.cpp
index 9614024..36cd567 100644
--- a/src/libANGLE/Context.cpp
+++ b/src/libANGLE/Context.cpp
@@ -732,7 +732,8 @@
mFrameCapture(new angle::FrameCapture),
mRefCount(0),
mOverlay(mImplementation.get()),
- mIsDestroyed(false)
+ mIsDestroyed(false),
+ mDestroyedManagers(false)
{
for (angle::SubjectIndex uboIndex = kUniformBuffer0SubjectIndex;
uboIndex < kUniformBufferMaxSubjectIndex; ++uboIndex)
@@ -1031,19 +1032,40 @@
void Context::releaseSharedObjects()
{
+ mDestroyedManagers = true;
+
mState.mBufferManager->release(this);
+ mState.mBufferManager = nullptr;
+
// mProgramPipelineManager must be before mShaderProgramManager to give each
// PPO the chance to release any references they have to the Programs that
// are bound to them before the Programs are released()'ed.
mState.mProgramPipelineManager->release(this);
+ mState.mProgramPipelineManager = nullptr;
+
mState.mShaderProgramManager->release(this);
+ mState.mShaderProgramManager = nullptr;
+
mState.mTextureManager->release(this);
+ mState.mTextureManager = nullptr;
+
mState.mRenderbufferManager->release(this);
+ mState.mRenderbufferManager = nullptr;
+
mState.mSamplerManager->release(this);
+ mState.mSamplerManager = nullptr;
+
mState.mSyncManager->release(this);
+ mState.mSyncManager = nullptr;
+
mState.mFramebufferManager->release(this);
+ mState.mFramebufferManager = nullptr;
+
mState.mMemoryObjectManager->release(this);
+ mState.mMemoryObjectManager = nullptr;
+
mState.mSemaphoreManager->release(this);
+ mState.mSemaphoreManager = nullptr;
}
Context::~Context() {}
@@ -10115,6 +10137,51 @@
mPrivateStateCache.invalidateCachedBasicDrawElementsError();
}
+bool Context::retainIdUntilObjectDestroyed() const
+{
+ // If BindGeneratesResource is disabled, then we can defer recycling the handle ID until the
+ // object has had the `onDestroy` method called, preventing ID reuse bugs.
+ //
+ // If the context is being destroyed however, we don't want to try to recycle as the handle
+ // manager may be gone.
+ return !mDestroyedManagers && !mState.isBindGeneratesResourceEnabled();
+}
+
+void Context::onBufferDestroy(const Buffer *buffer) const
+{
+ mState.mBufferManager->recycleHandle(buffer->id());
+}
+
+void Context::onTextureDestroy(const Texture *texture) const
+{
+ mState.mTextureManager->recycleHandle(texture->id());
+}
+
+void Context::onRenderbufferDestroy(const Renderbuffer *renderBuffer) const
+{
+ mState.mRenderbufferManager->recycleHandle(renderBuffer->id());
+}
+
+void Context::onSamplerDestroy(const Sampler *sampler) const
+{
+ mState.mSamplerManager->recycleHandle(sampler->id());
+}
+
+void Context::onSyncDestroy(const Sync *sync) const
+{
+ mState.mSyncManager->recycleHandle(sync->id());
+}
+
+void Context::onFramebufferDestroy(const Framebuffer *framebuffer) const
+{
+ mState.mFramebufferManager->recycleHandle(framebuffer->id());
+}
+
+void Context::onProgramPipelineDestroy(const ProgramPipeline *programPipeline) const
+{
+ mState.mProgramPipelineManager->recycleHandle(programPipeline->id());
+}
+
// ErrorSet implementation.
ErrorSet::ErrorSet(Debug *debug,
const angle::FrontendFeatures &frontendFeatures,
diff --git a/src/libANGLE/Context.h b/src/libANGLE/Context.h
index 9bd6b60..0d0b473 100644
--- a/src/libANGLE/Context.h
+++ b/src/libANGLE/Context.h
@@ -979,6 +979,16 @@
GLint64 getInstancedVertexElementLimit() const;
void onActiveTransformFeedbackChange();
+ bool retainIdUntilObjectDestroyed() const;
+
+ void onBufferDestroy(const Buffer *buffer) const;
+ void onTextureDestroy(const Texture *texture) const;
+ void onRenderbufferDestroy(const Renderbuffer *renderBuffer) const;
+ void onSamplerDestroy(const Sampler *sampler) const;
+ void onSyncDestroy(const Sync *sync) const;
+ void onFramebufferDestroy(const Framebuffer *framebuffer) const;
+ void onProgramPipelineDestroy(const ProgramPipeline *programPipeline) const;
+
private:
void initializeDefaultResources();
void releaseSharedObjects();
@@ -1146,6 +1156,7 @@
OverlayType mOverlay;
bool mIsDestroyed;
+ bool mDestroyedManagers;
std::unique_ptr<Framebuffer> mDefaultFramebuffer;
};
diff --git a/src/libANGLE/Fence.cpp b/src/libANGLE/Fence.cpp
index adf0999..07115c9 100644
--- a/src/libANGLE/Fence.cpp
+++ b/src/libANGLE/Fence.cpp
@@ -11,6 +11,7 @@
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests.gni b/src/tests/angle_end2end_tests.gni
index a551075..43ed001 100644
--- a/src/tests/angle_end2end_tests.gni
+++ b/src/tests/angle_end2end_tests.gni
@@ -41,6 +41,7 @@
"gl_tests/BPTCCompressedTextureTest.cpp",
"gl_tests/BaseInstanceOverflowTest.cpp",
"gl_tests/BindGeneratesResourceTest.cpp",
+ "gl_tests/BindRecyclesResourceTest.cpp",
"gl_tests/BindUniformLocationTest.cpp",
"gl_tests/BlendFuncExtendedTest.cpp",
"gl_tests/BlendIntegerTest.cpp",
diff --git a/src/tests/gl_tests/BindRecyclesResourceTest.cpp b/src/tests/gl_tests/BindRecyclesResourceTest.cpp
new file mode 100644
index 0000000..d259a56
--- /dev/null
+++ b/src/tests/gl_tests/BindRecyclesResourceTest.cpp
@@ -0,0 +1,48 @@
+//
+// Copyright 2015 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.
+//
+
+// BindRecyclesResourceTest.cpp : Tests of the GL_CHROMIUM_bind_generates_resource extension.
+
+#include "test_utils/ANGLETest.h"
+
+namespace angle
+{
+
+class BindRecyclesResourceTest : public ANGLETest<>
+{
+ protected:
+ BindRecyclesResourceTest() { setBindGeneratesResource(false); }
+};
+
+// crbug.com/496284494
+TEST_P(BindRecyclesResourceTest, BufferRecycling)
+{
+ GLuint vao;
+ glGenVertexArrays(1, &vao);
+ glBindVertexArray(vao);
+
+ GLuint idA;
+ glGenBuffers(1, &idA);
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, idA);
+
+ // Unbind the VAO
+ glBindVertexArray(0);
+
+ glDeleteBuffers(1, &idA);
+
+ GLuint idB;
+ glGenBuffers(1, &idB);
+ EXPECT_NE(idA, idB);
+
+ glDeleteVertexArrays(1, &vao);
+ glDeleteBuffers(1, &idB);
+}
+
+// Use this to select which configurations (e.g. which renderer, which GLES major version) these
+// tests should be run against.
+ANGLE_INSTANTIATE_TEST_ES3_AND_ES31_AND_ES32(BindRecyclesResourceTest);
+
+} // namespace angle
Original Bug Report
GPU Process UAF Write via Service ID Collision in VAO-Orphaned Buffers
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A Use-After-Free (UAF) Write vulnerability potentially exists in the GPU process passthrough command decoder due to service ID recycling in ANGLE and incorrect client ID resolution for orphaned buffers. A compromised renderer can force a service ID collision between an orphaned buffer and a new buffer, poisoning the passthrough decoder’s mapping state to write arbitrary data into freed GPU memory.
Affected files:
gpu/command_buffer/service/gles2_cmd_decoder_passthrough.ccgpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.ccgpu/command_buffer/service/gles2_cmd_decoder_passthrough_handlers.cc
Estimated timestamp from git blame: 2025-11-28
Summary
A potential Use-After-Free (UAF) Write vulnerability exists in GLES2DecoderPassthroughImpl. The issue occurs because of a service ID collision when a buffer is orphaned by a Vertex Array Object (VAO). When a buffer is deleted while bound to a non-current VAO, ANGLE immediately recycles its service ID handle even though the buffer object itself remains alive (orphaned). This allows a newly generated buffer to inherit the same service ID. If the orphaned VAO is subsequently used, the passthrough decoder incorrectly attributes the orphan’s mapped memory to the new buffer’s client ID. This results in a poisoned entry in mapped_buffer_map containing a dangling pointer that can be used to perform arbitrary writes to freed GPU memory when the new buffer is flushed.
Theoretical Exploit Steps
(Note: This is a potential vulnerability identified via static analysis by an AI agent; a working proof-of-concept exploit has not been executed).
A compromised renderer process can potentially trigger this vulnerability using the following sequence of command buffer operations:
- Establish the Orphan: The renderer creates VAO 1 (
client_vao_1) and Buffer A (client_buf_A). The decoder queries ANGLE for a service ID, receivingservice_A. The decoder mapsclient_buf_A<->service_Ainresources_->buffer_id_map. - The renderer binds VAO 1 and then binds Buffer A to the
GL_ELEMENT_ARRAY_BUFFERtarget. VAO 1 now holds a strong reference to Buffer A in ANGLE. - The renderer unbinds VAO 1 (it is no longer the active VAO) and deletes Buffer A (
glDeleteBuffers(1, &client_buf_A)). - In
DoDeleteBuffers, the decoder removes the mapping forclient_buf_Aand calls ANGLE’sglDeleteBuffers(1, &service_A). Because Buffer A is not bound to the current context, ANGLE’sContext::deleteBufferimmediately releases the IDservice_Aback to the allocator. However, because VAO 1 still references Buffer A, its reference count is > 0, so the underlying driver memory is not destroyed. Buffer A is now an “orphan.” - Force Collision: The renderer immediately creates a new Buffer B (
glGenBuffers(1, &client_buf_B)). ANGLE recycles and returns the IDservice_A. The decoder maps the newclient_buf_B<->service_A. - Poison Mapping State: The renderer re-binds VAO 1, setting
bound_element_array_buffer_dirty_ = truein the decoder. - The renderer sends a
glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, ...)command.DoMapBufferRangeexecutes and successfully maps the actual underlying buffer (the orphaned Buffer A) via ANGLE, returningmapped_ptr. DoMapBufferRangethen callsLazilyUpdateCurrentlyBoundElementArrayBuffer(). This queries ANGLE forGL_ELEMENT_ARRAY_BUFFER_BINDING. ANGLE inspects the bound VAO 1 and returns the original service ID of its attached buffer:service_A.- The decoder performs a reverse-lookup:
GetClientID(&resources_->buffer_id_map, service_A, ...). Becauseservice_Awas recycled, this incorrectly resolves to the client ID of the new buffer:client_buf_B. The decoder updates its tracking:bound_buffers_[GL_ELEMENT_ARRAY_BUFFER] = client_buf_B. DoMapBufferRangeinsertsmapped_ptrintoresources_->mapped_buffer_mapunder the keyclient_buf_B. The decoder now believes Buffer B is mapped, but the pointer actually points to the driver memory of the orphan Buffer A.- Trigger UAF Write: The renderer deletes VAO 1 (
glDeleteVertexArraysOES). VAO 1 drops its reference to the orphan Buffer A. Buffer A is destroyed by ANGLE, and the graphics driver frees the backing memory. Themapped_ptrstored underclient_buf_Bis now a dangling pointer. - The renderer binds Buffer B to
GL_ARRAY_BUFFERand invokesglFlushMappedBufferRange(GL_ARRAY_BUFFER, offset, size). DoFlushMappedBufferRangeretrieves the poisonedmap_infoforclient_buf_Band executes an unconstrainedmemcpyfrom an attacker-controlled shared memory segment into the danglingmap_ptr.
Impact
This vulnerability provides a robust write-what-where primitive in the GPU process. Because the memory is allocated and managed by the underlying graphics driver (e.g., Vulkan/D3D11) rather than Chromium’s PartitionAlloc, MiraclePtr (BackupRefPtr) does not protect this pointer. An attacker can leverage this primitive to overwrite reused GPU structures (such as command buffers, shaders, or descriptors) to achieve Code Execution in the GPU process, resulting in a renderer-to-GPU sandbox escape.
Suggested Fix
The core issue is that the passthrough decoder relies on a reverse-lookup (GetClientID) using a service ID that may have been recycled by ANGLE while the underlying buffer object remains alive as an orphan.
Potential fixes include:
- Preventing ID Recycling in ANGLE: Modify ANGLE’s
HandleAllocatorto prevent the immediate reuse of a service ID if the underlyingRefCountObject(like aBuffer) still has a non-zero reference count (e.g., from an unbound VAO). - Tracking Orphans in the Decoder: The passthrough decoder could track orphaned buffers (buffers that have been deleted but might still be referenced by VAOs). If
glGetIntegervFn(GL_ELEMENT_ARRAY_BUFFER_BINDING)returns an ID that belongs to an orphaned buffer, the decoder should handle it safely (e.g., by returning a pseudo-client ID or rejecting the mapping operation). - Validating Client IDs: In
LazilyUpdateCurrentlyBoundElementArrayBuffer(), add a verification step to ensure that the resolvedclient_element_array_bufferactually corresponds to the expected state of the currently bound VAO, rather than blindly trusting the reverse lookup of a potentially recycled service ID.
Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b
Results 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. Please feel free to reach out to me if you have concerns or feedback.