High watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
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.
Tracker496284494
Fix commit3ba420fa0598 (angle/angle) +195/-10
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-04-21 (public git)
Disclosed2026-04-28
Patch gap7 days — fix public in source before the advisory

Background

ANGLE resource manager
the per-Context bookkeeping (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 bindGeneratesResources is disabled, deleted IDs are normally returned to the free pool via recycleHandle so 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.

Key insight
The core mistake was recycling a handle ID at application-delete time rather than at true object destruction, allowing the same ID to be reissued while the old object still lived; the fix defers recycleHandle to the object’s onDestroy (guarded by retainIdUntilObjectDestroyed and the mDestroyedManagers flag).

Attack Path

  1. Disable implicit generation The client uses a context with bindGeneratesResources disabled, taking the strict ID-management path.
  2. 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.
  3. Delete while still referenced Call glDeleteBuffers(idA); pre-patch, idA is recycled into the free pool immediately even though the buffer object survives via the VAO reference.
  4. Reissue the stale ID Call glGenBuffers, which (pre-patch) can return idA again, so the reused ID now aliases a still-live object.
  5. 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

A client issuing GL/WebGL calls against an ANGLE context with 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.cpp
  • src/libANGLE/Context.cpp
  • src/libANGLE/Context.h
  • src/libANGLE/Fence.cpp

Audit Directions

  • Delete-time vs. destroy-time recycling
    Audit every resource manager’s delete path for recycleHandle/free-pool insertion that runs before the object’s refcount actually reaches zero.
  • Reference-holding containers
    Check objects (VAOs, PPOs, framebuffers) that retain references to other GL objects, where an application delete does not immediately destroy the referent.
  • Teardown-ordering guards
    Verify that any deferred-destruction callback (on*Destroy) is guarded against running after its manager is released, as done with mDestroyedManagers.
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 @@
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • gpu/command_buffer/service/gles2_cmd_decoder_passthrough_doers.cc
  • gpu/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:

  1. 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, receiving service_A. The decoder maps client_buf_A <-> service_A in resources_->buffer_id_map.
  2. The renderer binds VAO 1 and then binds Buffer A to the GL_ELEMENT_ARRAY_BUFFER target. VAO 1 now holds a strong reference to Buffer A in ANGLE.
  3. The renderer unbinds VAO 1 (it is no longer the active VAO) and deletes Buffer A (glDeleteBuffers(1, &client_buf_A)).
  4. In DoDeleteBuffers, the decoder removes the mapping for client_buf_A and calls ANGLE’s glDeleteBuffers(1, &service_A). Because Buffer A is not bound to the current context, ANGLE’s Context::deleteBuffer immediately releases the ID service_A back 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.”
  5. Force Collision: The renderer immediately creates a new Buffer B (glGenBuffers(1, &client_buf_B)). ANGLE recycles and returns the ID service_A. The decoder maps the new client_buf_B <-> service_A.
  6. Poison Mapping State: The renderer re-binds VAO 1, setting bound_element_array_buffer_dirty_ = true in the decoder.
  7. The renderer sends a glMapBufferRange(GL_ELEMENT_ARRAY_BUFFER, ...) command. DoMapBufferRange executes and successfully maps the actual underlying buffer (the orphaned Buffer A) via ANGLE, returning mapped_ptr.
  8. DoMapBufferRange then calls LazilyUpdateCurrentlyBoundElementArrayBuffer(). This queries ANGLE for GL_ELEMENT_ARRAY_BUFFER_BINDING. ANGLE inspects the bound VAO 1 and returns the original service ID of its attached buffer: service_A.
  9. The decoder performs a reverse-lookup: GetClientID(&resources_->buffer_id_map, service_A, ...). Because service_A was 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.
  10. DoMapBufferRange inserts mapped_ptr into resources_->mapped_buffer_map under the key client_buf_B. The decoder now believes Buffer B is mapped, but the pointer actually points to the driver memory of the orphan Buffer A.
  11. 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. The mapped_ptr stored under client_buf_B is now a dangling pointer.
  12. The renderer binds Buffer B to GL_ARRAY_BUFFER and invokes glFlushMappedBufferRange(GL_ARRAY_BUFFER, offset, size).
  13. DoFlushMappedBufferRange retrieves the poisoned map_info for client_buf_B and executes an unconstrained memcpy from an attacker-controlled shared memory segment into the dangling map_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:

  1. Preventing ID Recycling in ANGLE: Modify ANGLE’s HandleAllocator to prevent the immediate reuse of a service ID if the underlying RefCountObject (like a Buffer) still has a non-zero reference count (e.g., from an unbound VAO).
  2. 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).
  3. Validating Client IDs: In LazilyUpdateCurrentlyBoundElementArrayBuffer(), add a verification step to ensure that the resolved client_element_array_buffer actually 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.

View on issue tracker