Released June 29, 2026

CVE-2026-43709

CVSS 6.5 severity medium CWE-416 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected process crash

Researchers: An anonymous researcher

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.

Patch Details

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());
-

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-43709
Bugzilla 314398
Component WebKit
Bug Class UAF
Severity medium
Commit 9d2cc8c9895d51eb…
Advisory Apple Advisory

Terminology

  • 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.

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.

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.

Changed Functions

Function File Change Note
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

Source Files

  • 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

Test Files

  • LayoutTests/platform/mac-wk2/TestExpectations
  • Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp

Regression Test

The fix ships this proof-of-concept / regression test:

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 ]