[6] ANGLE Metal dangling mOcclusionQuery on failed begin
Rated Medium because the dangling QueryMtl pointer requires the visibility-buffer allocation to fail at query begin, and the added test comment states that OOM must be injected by hand, so the trigger is not readily attacker-reachable from WebGL2 content; the resulting dereference lives in the GPU process but the escalation path is gated by a failure condition the diff does not show to be attacker-controllable.
ContextMtl populated QueryMtl *ContextMtl::mOcclusionQuery before visibility-buffer allocation:
mOcclusionQuery = query;
ANGLE_TRY(startOcclusionQueryInRenderPass(...)); // if this fails...
If the allocation failed, the frontend would not keep the Query instance alive, leaving a dangling pointer. The fix stops pointing at the GL object QueryMtl * in ContextMtl and instead points at the Metal buffer of QueryMtl; render-pass state moves out of QueryMtl into the render-pass state representation of the query implementation, the OcclusionQueryPool.
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.mm
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/ContextMtl.h
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/mtl_occlusion_query_pool.h
Source/ThirdParty/ANGLE/src/tests/gl_tests/OcclusionQueriesTest.cpp
Patch Details
ContextMtl no longer holds a raw QueryMtl *mOcclusionQuery. In ContextMtl.h that member is replaced with a reference-counted mtl::BufferRef mOcclusionQueryResultBuffer plus a bool mOcclusionQueryIsEnabledInRenderPass. onOcclusionQueryBegin now captures the query's getVisibilityResultBuffer() and delegates render-pass slot allocation to OcclusionQueryPool::beginQuery/continueQuery, rather than setting mOcclusionQuery = query before allocating. Per-query render-pass state (VisibilityBufferOffsetsMtl, the allocated-offsets list) moves out of QueryMtl into mtl_occlusion_query_pool.h, where mAllocatedQueries becomes a std::vector<VisibilityBufferOffsetsMtl> that holds a BufferRef by value instead of raw QueryMtl *. combineVisibilityResult is re-signatured to take (startOffset, numOffsets).
Publishing a raw pointer to an externally-owned object into long-lived state before a fallible operation, with no rollback on the error path, leaving a dangling pointer when the owner frees the object.
Background
WebGL2 lets content ask whether any samples passed the depth/stencil test between glBeginQuery(GL_ANY_SAMPLES_PASSED) and glEndQuery; ANGLE's Metal backend implements this via Metal's per-draw visibility-result writes into a GPU buffer. QueryMtl is the ANGLE Metal implementation object for a GL query; it owns a mtl::BufferRef mVisibilityResultBuffer holding the final combined result. OcclusionQueryPool is a per-render-pass allocator that hands out offsets in a shared visibility-results buffer, since Metal writes all queries of a render pass into one buffer and they are combined afterward. mtl::BufferRef is a reference-counted handle to a Metal buffer, so copying it retains the buffer. ANGLE_TRY is a macro that early-returns an angle::Result error, for example on OOM from buffer allocation. A GL query is owned by the GL frontend; the backend QueryMtl is destroyed via onDestroy when the app deletes the query. ANGLE runs inside WebKit's GPU process, servicing WebGL command streams issued from WebContent.
Analysis
This is a use-after-free from a lifetime-versus-error-path ordering mistake. Before the fix, onOcclusionQueryBegin published a raw pointer to the GL-owned QueryMtl into mOcclusionQuery before the render-pass visibility slot was allocated: mOcclusionQuery = query; ANGLE_TRY(startOcclusionQueryInRenderPass(query, true));. startOcclusionQueryInRenderPass calls OcclusionQueryPool::allocateQueryOffset, which can grow the pool buffer and therefore fail (OOM), propagating an error out of begin. When begin fails, the GL frontend does not consider the query active and does not guarantee the QueryMtl stays alive, so the application can glDeleteQueries it. QueryMtl::onDestroy only notified the context when getAllocatedVisibilityOffsets() was non-empty; on the failed-begin path no offsets were allocated, so the notification was skipped and mOcclusionQuery was never cleared. The result is a dangling raw pointer in ContextMtl (and, symmetrically, raw QueryMtl * entries in mAllocatedQueries) that outlives the pointed-to object.
To reach it, WebGL2 content forces the visibility-buffer allocation to fail at query begin and then deletes the query; a subsequent draw or render-pass teardown would dereference the freed QueryMtl. The added test comment states the OOM must be injected by hand, so the allocation failure is not readily attacker-triggerable without manual fault injection, which is the specific evidence that caps the practical severity. If an attacker who grooms the freed QueryMtl slot could gain influence over GPU visibility-buffer offsets, that would be a stepping stone toward a stronger primitive, but that path is not demonstrable from the diff.
This vulnerability weakens memory safety inside the GPU process. The security model assumes ANGLE backend state (mOcclusionQuery, mAllocatedQueries) never outlives the GL objects it references; the pre-fix begin/error ordering violated that. Any resulting compromise stays inside the GPU-process sandbox and still requires a separate escape.
The durable remedy is architectural: the backend was caching a raw pointer to a frontend-owned GL object across a fallible boundary, and the fix stops referencing the GL object at all, instead retaining the reference-counted Metal resource (BufferRef) it wraps and moving render-pass tracking into value-owned VisibilityBufferOffsetsMtl entries. Any place where a renderer backend caches a raw QueryImpl*/TextureImpl* across an allocation that can fail is suspect.
Note: That an attacker can reliably force the begin-time allocation failure, and that grooming the freed slot yields influence over visibility-buffer offsets, are inferred; the test's hand-injected OOM and the diff's reachable use site support the mechanism but not full attacker control. The dangling-pointer ordering the refactor removes is directly visible.
Audit directions
- Backend state that caches a raw pointer to a frontend-owned GL object is set before a fallible operation, with no rollback if that operation returns an error. Audit ANGLE's Metal (and Vulkan) backends for members like
ContextMtl::mOcclusionQueryassigned aQuery*/Texture*/Buffer*before anANGLE_TRY(...)allocation. GrepContextMtl.mm/ContextVk.cppfor member-pointer assignments immediately followed byANGLE_TRYin the same function, and verify the member is cleared on the error path. - A destroy/cleanup notification gated on a "was it allocated" predicate, so partially-initialized-then-failed objects never notify their container. Examine
QueryImpl::onDestroyand equivalents where the notify-context call is wrapped in anif (!...empty())/if (allocated)guard; confirm a begin that failed after registering with the context still triggers deregistration. Start fromQueryMtl::onDestroysiblings in other renderers. - Containers of raw backend-object pointers (
std::vector<QueryMtl*>) whose elements' lifetimes are managed elsewhere. Grep the Metal backend forstd::vector<.*Mtl \*>/std::vector<.*Impl \*>used as per-render-pass or per-frame caches and check whether any entry can be freed while still referenced; prefer value-owned reference-counted handles as this fix did. - Verify the new OOM/error paths introduced here are themselves safe:
OcclusionQueryPool::beginQuery/continueQuerynow emplaceVisibilityBufferOffsetsMtland callensurePoolCapacity; confirm a failure insideensurePoolCapacityaftermAllocatedQueries.emplace_backcannot leave a stale entry referencing a buffer, and thatprepareRenderPassVisibilityPoolBuffer'smAllocatedQueries.back()is never reached with an empty vector.