Medium CVSS 6.5 webkit UAF 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentThirdParty ANGLE
Bug ClassUAF
Tracker314398
Fix commit9d2cc8c9895d (WebKit/WebKit) +49/-56
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedAn anonymous researcher
Disclosed2026-06-29

Background

ANGLE
The translation layer WebKit uses to run WebGL/WebGPU on top of native GPU APIs; its Metal backend (ContextMtl/QueryMtl) runs inside the GPU process.
Occlusion query
A GPU feature that counts whether fragments passed the depth test; WebGL/WebGPU expose it, and the result is written into a Metal visibility-result buffer.
Render pass
A unit of Metal GPU work with a defined begin/end. A query can start in one pass and continue in another, so its lifetime is not naturally tied to a single pass.

Root Cause Analysis

This fixes a use-after-free in the ANGLE Metal backend’s occlusion-query handling (ContextMtl), used by WebGL/WebGPU occlusion queries.

Before the fix, ContextMtl stored a raw pointer to the active query (QueryMtl* mOcclusionQuery) and dereferenced it across render-pass boundaries in begin/end/destroy/restart (e.g. disableActiveOcclusionQueryInRenderPass, restartActiveOcclusionQueryInRenderPass, startOcclusionQueryInRenderPass all read mOcclusionQuery->getAllocatedVisibilityOffsets()). The query object’s lifetime is not tied to the render pass, so with adversarial timing of EndQuery/DeleteQueries versus render-pass start/end, ContextMtl could dereference a QueryMtl that had already been destroyed (or whose visibility offsets were freed), causing a use-after-free / crash in the GPU process.

The fix removes the raw query pointer entirely: onOcclusionQueryBegin now captures the query’s ref-counted visibility result buffer (mtl::BufferRef mOcclusionQueryResultBuffer) and a flag (mOcclusionQueryIsEnabledInRenderPass), and all continue/disable operations work off that buffer plus pool offsets (beginQuery/continueQuery/discardQuery) rather than the QueryMtl object. onOcclusionQueryDestroy takes the query only to discard its buffer via the pool (valid whether or not end was called), QueryMtl::onDestroy guards on mVisibilityResultBuffer, and RenderCommandEncoder checks hasPendingVisibilityResults().

The restored invariant is that ContextMtl never dereferences a QueryMtl that may have been freed — it holds a strong reference to the result buffer for the query’s active duration instead. INFERENCE: this is a broad refactor of the ANGLE Metal occlusion path; the precise freed-pointer dereference is one of the mOcclusionQuery-> uses removed here, and ANGLE-internal details beyond the shown hunks are not all included.

Key insight
ContextMtl kept a raw QueryMtl* whose lifetime was not tied to the render pass that dereferenced it. The fix removes the object pointer entirely and instead holds a strong reference to the query’s ref-counted visibility-result buffer for the duration it is needed — turning a lifetime assumption into an enforced ownership guarantee.

Attack Path

  1. Use occlusion queries from WebGL/WebGPU Web content issues BeginQuery/EndQuery (ANY_SAMPLES(_CONSERVATIVE)) on the Metal-backed ANGLE context.
  2. Span render-pass boundaries The query is kept active while a render pass ends and a new one begins, exercising ContextMtl’s continue/disable paths that read the raw mOcclusionQuery pointer.
  3. Destroy the query with adversarial timing deleteQueries / context teardown frees the QueryMtl (or its visibility offsets) while ContextMtl still holds the raw pointer.
  4. Use-after-free in the GPU backend ContextMtl dereferences the freed QueryMtl when continuing or disabling the query in a render pass, crashing the process (now avoided by holding the result buffer by reference).

Impact Assessment

A use-after-free in the GPU process, reachable from ordinary WebGL/WebGPU content in any tab. The GPU process is a valuable target because it is shared across origins and handles attacker-influenced graphics data; corrupting its heap is a strong primitive toward sandbox-constrained code execution. No special privileges are required — just script that issues occlusion queries with adversarial destroy timing.

Changed Functions

FunctionChangeNotes
ContextMtl::onOcclusionQueryBegin
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
modified Stops storing the raw QueryMtl*; captures the query's ref-counted visibility result buffer and an in-render-pass flag, and begins the query via the pool (beginQuery / buffer fill) instead of the query object.
ContextMtl::onOcclusionQueryEnd / onOcclusionQueryDestroy
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
modified End clears mOcclusionQueryResultBuffer; Destroy discards the query's buffer through the pool (valid with or without a prior End) rather than dereferencing the QueryMtl.
ContextMtl::disableOcclusionQueryInRenderPass / enableOcclusionQueryInRenderPass
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
modified Renamed from the *Active*/start* variants; operate on mOcclusionQueryResultBuffer + pool offsets (continueQuery) and the in-render-pass flag instead of the raw query pointer/allocated offsets.
ContextMtl::setupDrawImpl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
modified Continues an active query in a new render pass based on mOcclusionQueryResultBuffer/!isEnabledInRenderPass instead of the raw query and pool query count.
QueryMtl::onDestroy / begin / end
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
modified Guard on mVisibilityResultBuffer and call the new ContextMtl entry points by reference; removes resetVisibilityResult (its buffer-zeroing moved into onOcclusionQueryBegin).
RenderCommandEncoder::endEncodingImpl
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
modified Uses mOcclusionQueryPool.hasPendingVisibilityResults() to decide whether to attach the visibility result buffer.

Files Changed

  • LayoutTests/platform/mac-wk2/TestExpectations
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.mm
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.h
  • Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.mm
  • Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp

Audit Directions

  • Other raw GL-object pointers across pass boundaries
    Audit the ANGLE Metal backend for other renderer objects cached as raw pointers (not RefPtr/BufferRef) and dereferenced after a render-pass boundary or across begin/end/destroy transitions.
  • Query/encoder lifetime coupling
    Check every place that assumes a query, buffer, or encoder outlives the operation using it; prefer capturing the ref-counted resource over the owning object.
diff --git a/LayoutTests/platform/mac-wk2/TestExpectations b/LayoutTests/platform/mac-wk2/TestExpectations
index 5661950f388b..5066d4b54544 100644
--- a/LayoutTests/platform/mac-wk2/TestExpectations
+++ b/LayoutTests/platform/mac-wk2/TestExpectations
@@ -2309,6 +2309,8 @@ webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/m
 webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/webgl_canvas/tex-3d-r11f_g11f_b10f-rgb-float.html [ Pass Timeout ]
 webkit.org/b/315877 [ Sequoia Debug x86_64 ] webgl/2.0.y/conformance2/textures/image_bitmap_from_video/tex-2d-rgba32f-rgba-float.html [ Pass Failure ]
 
+webkit.org/b/318751 [ Debug arm64 ] webgl/2.0.y/conformance2/textures/misc/tex-unpack-params.html [ Pass Failure ]
+
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-class.html [ Pass ImageOnlyFailure ]
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-default-icon.html [ Pass ImageOnlyFailure ]
 webkit.org/b/315881 [ Sequoia Release x86_64 ] fast/attachment/cocoa/wide-attachment-folder-icon.html [ Pass ImageOnlyFailure ]
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
index 41553c65283a..a85c8ecffa9f 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
@@ -31,6 +31,7 @@ namespace rx
 {
 class DisplayMtl;
 class FramebufferMtl;
+class QueryMtl;
 class VertexArrayMtl;
 class ProgramMtl;
 class ProgramExecutableMtl;
@@ -301,20 +302,22 @@ class ContextMtl : public ContextImpl, public mtl::Context
                                        bool renderPassChanged);
     void onBackbufferResized(const gl::Context *context, WindowSurfaceMtl *backbuffer);
 
-    // Invoke by QueryMtl
-    angle::Result onOcclusionQueryBegin(const gl::Context *context, QueryMtl *query);
-    void onOcclusionQueryEnd(const gl::Context *context, QueryMtl *query);
-    void onOcclusionQueryDestroy(const gl::Context *context, QueryMtl *query);
+    angle::Result onOcclusionQueryBegin(QueryMtl &query);
+    void onOcclusionQueryEnd();
+    void onOcclusionQueryDestroy(QueryMtl &query);
 
     // Useful for temporarily pause then restart occlusion query during clear/blit with draw.
-    bool hasActiveOcclusionQuery() const { return mOcclusionQuery; }
+    bool isOcclusionQueryEnabledInRenderPass() const
+    {
+        return mOcclusionQueryIsEnabledInRenderPass;
+    }
     // Disable the occlusion query in the current render pass.
     // The render pass must already started.
-    void disableActiveOcclusionQueryInRenderPass();
+    void disableOcclusionQueryInRenderPass();
     // Re-enable the occlusion query in the current render pass.
     // The render pass must already started.
     // NOTE: the old query's result will be retained and combined with the new result.
-    angle::Result restartActiveOcclusionQueryInRenderPass();
+    angle::Result enableOcclusionQueryInRenderPass();
 
     // Invoke by TransformFeedbackMtl
     void onTransformFeedbackActive(const gl::Context *context, TransformFeedbackMtl *xfb);
@@ -536,8 +539,6 @@ class ContextMtl : public ContextImpl, public mtl::Context
                                          bool xfbPass,
                                          bool *pipelineDescChanged);
 
-    angle::Result startOcclusionQueryInRenderPass(QueryMtl *query, bool clearOldValue);
-
     angle::Result checkCommandBufferError();
 
     // Dirty bits.
@@ -607,7 +608,6 @@ class ContextMtl : public ContextImpl, public mtl::Context
     FramebufferMtl *mDrawFramebuffer  = nullptr;
     VertexArrayMtl *mVertexArray      = nullptr;
     ProgramExecutableMtl *mExecutable = nullptr;
-    QueryMtl *mOcclusionQuery         = nullptr;
 
     using DirtyBits = angle::BitSet<DIRTY_BIT_MAX>;
 
@@ -658,6 +658,9 @@ class ContextMtl : public ContextImpl, public mtl::Context
     id<MTLTexture> mRasterizationRateMapTexture;
 
     mtl::ContextDevice mContextDevice;
+
+    mtl::BufferRef mOcclusionQueryResultBuffer;
+    bool mOcclusionQueryIsEnabledInRenderPass{false};
 };
 
 }  // namespace rx
diff --git a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
index 644b213beee9..8703f6c2d578 100644
--- a/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
+++ b/Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
@@ -1823,7 +1823,7 @@ GLint GetOwnershipIdentity(const egl::AttributeMap &attribs)
             mRenderEncoder.setStoreAction(MTLStoreActionStore);
         }
 
-        disableActiveOcclusionQueryInRenderPass();
+        disableOcclusionQueryInRenderPass();
 
         mOcclusionQueryPool.prepareRenderPassVisibilityPoolBuffer(this);
 
@@ -2281,84 +2281,67 @@ GLint GetOwnershipIdentity(const egl::AttributeMap &attribs)
     onDrawFrameBufferChangedState(context, framebuffer, true);
 }
 
-angle::Result ContextMtl::onOcclusionQueryBegin(const gl::Context *context, QueryMtl *query)
+angle::Result ContextMtl::onOcclusionQueryBegin(QueryMtl &query)
 {
-    ASSERT(mOcclusionQuery == nullptr);
-    mOcclusionQuery = query;
-
+    ASSERT(!mOcclusionQueryResultBuffer);  // Frontend guarantees none active at the time.
+    const mtl::BufferRef &resultBuffer = query.getVisibilityResultBuffer();
+    bool isEnabledInRenderPass;
     if (mRenderEncoder.valid())
     {
-        // if render pass has started, start the query in the encoder
-        return startOcclusionQueryInRenderPass(query, true);
+        size_t resultOffset;
+        ANGLE_TRY(mOcclusionQueryPool.beginQuery(this, resultBuffer, &resultOffset));
+        mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeBoolean, resultOffset);
+        // Result is available after flush.
+        mCmdBuffer.setWriteDependency(resultBuffer, /*isRenderCommand=*/true);
+        isEnabledInRenderPass = true;
     }
     else
     {
-        query->resetVisibilityResult(this);
+        // Reset the occlusion query result stored in buffer to zero.
+        // Later draws will use continueQuery() to enable the visibility buffer writes.
+        auto blitEncoder = getBlitCommandEncoder();
+        blitEncoder->fillBuffer(resultBuffer, NSMakeRange(0, mtl::kOcclusionQueryResultSize), 0);
+        resultBuffer->syncContent(this, blitEncoder);
+        isEnabledInRenderPass = false;
     }
-
+    mOcclusionQueryResultBuffer          = resultBuffer;
+    mOcclusionQueryIsEnabledInRenderPass = isEnabledInRenderPass;
     return angle::Result::Continue;
 }
-void ContextMtl::onOcclusionQueryEnd(const gl::Context *context, QueryMtl *query)
-{
-    ASSERT(mOcclusionQuery == query);
-
-    if (mRenderEncoder.valid())
-    {
-        // if render pass has started, end the query in the encoder
-        disableActiveOcclusionQueryInRenderPass();
-    }
 
-    mOcclusionQuery = nullptr;
-}
-void ContextMtl::onOcclusionQueryDestroy(const gl::Context *context, QueryMtl *query)
+void ContextMtl::onOcclusionQueryEnd()
 {
-    if (query->getAllocatedVisibilityOffsets().empty())
-    {
-        return;
-    }
-    if (mOcclusionQuery == query)
-    {
-        onOcclusionQueryEnd(context, query);
-    }
-    mOcclusionQueryPool.deallocateQueryOffset(this, query);
+    ASSERT(mOcclusionQueryResultBuffer);  // Frontend guarantees one active at the time.
+    disableOcclusionQueryInRenderPass();
+    mOcclusionQueryResultBuffer = nullptr;
 }
 
-void ContextMtl::disableActiveOcclusionQueryInRenderPass()
+void ContextMtl::onOcclusionQueryDestroy(QueryMtl &query)
 {
-    if (!mOcclusionQuery || mOcclusionQuery->getAllocatedVisibilityOffsets().empty())
-    {
-        return;
-    }
-
-    ASSERT(mRenderEncoder.valid());
-    mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeDisabled,
-                                           mOcclusionQuery->getAllocatedVisibilityOffsets().back());
+    // On normal operation frontend guaraantees that end is called before destroy.
+    // On context destruction, active query is destroyed without end.
+    // Discard is valid for both.
+    mOcclusionQueryPool.discardQuery(query.getVisibilityResultBuffer());
 }
 
-angle::Result ContextMtl::restartActiveOcclusionQueryInRenderPass()
+void ContextMtl::disableOcclusionQueryInRenderPass()
 {
-    if (!mOcclusionQuery || mOcclusionQuery->getAllocatedVisibilityOffsets().empty())
+    if (mOcclusionQueryResultBuffer && mOcclusionQueryIsEnabledInRenderPass)
     {
-        return angle::Result::Continue;
+        ASSERT(mRenderEncoder.valid());
+        mRenderEncoder.setVisibilityResultMode(MTLVisibilityResultModeDisabled, 0);
+        mOcclusionQueryIsEnabledInRenderPass = false;
     }
-
-    return startOcclusionQueryInRenderPass(mOcclusionQuery, false);
 }
 
-angle::Result ContextMtl::startOcclusionQueryInRenderPass(QueryMtl *query, bool clearOldValue)
+angle::Result ContextMtl::enableOcclusionQueryInRenderPass()
 {
     ASSERT(mRenderEncoder.valid());
-
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.