CVE-2026-43709
Overview
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.
Attack Path
- Use occlusion queries from WebGL/WebGPU Web content issues BeginQuery/EndQuery (ANY_SAMPLES(_CONSERVATIVE)) on the Metal-backed ANGLE context.
- 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.
- Destroy the query with adversarial timing deleteQueries / context teardown frees the QueryMtl (or its visibility offsets) while ContextMtl still holds the raw pointer.
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ContextMtl::onOcclusionQueryBeginSource/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 / onOcclusionQueryDestroySource/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 / enableOcclusionQueryInRenderPassSource/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::setupDrawImplSource/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 / endSource/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::endEncodingImplSource/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/TestExpectationsSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.hSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mmSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.hSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/QueryMtl.mmSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_command_buffer.mmSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.hSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.mmSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.hSource/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_render_utils.mmSource/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp
Audit Directions
- Other raw GL-object pointers across pass boundariesAudit 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 couplingCheck every place that assumes a query, buffer, or encoder outlives the operation using it; prefer capturing the ref-counted resource over the owning object.
Patch
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());
-