[6] ANGLE Metal dangling mOcclusionQuery on failed begin
dangling QueryMtl pointer가 발생하려면 query begin 시점에 visibility-buffer allocation이 실패해야 하고, 추가된 테스트 코드의 주석에는 OOM을 수동으로 주입해야 한다고 명시되어 있어, WebGL2 콘텐츠에서 attacker가 쉽게 도달할 수 있는 trigger는 아닙니다. Dereference 자체는 GPU process 내에서 발생하지만, 확장 경로가 실패 조건에 의해 gate되어 있고 그 조건이 attacker-controllable하다는 근거는 diff에서 드러나지 않기 때문에 Medium으로 평가됩니다.
ContextMtl은 visibility-buffer allocation 이전에 QueryMtl *ContextMtl::mOcclusionQuery를 채우고 있었습니다.
mOcclusionQuery = query;
ANGLE_TRY(startOcclusionQueryInRenderPass(...)); // if this fails...
Allocation이 실패하면 frontend는 해당 Query 인스턴스를 계속 살려두지 않았고, 그 결과 dangling pointer가 남게 되었습니다. Fix는 ContextMtl이 GL 객체인 QueryMtl *를 가리키는 방식을 중단하고, 대신 QueryMtl이 소유한 Metal buffer를 가리키도록 변경합니다. Render-pass state는 QueryMtl 밖으로 옮겨져, query 구현의 render-pass state 표현체인 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은 더 이상 raw QueryMtl *mOcclusionQuery를 갖지 않습니다. ContextMtl.h에서 해당 멤버는 reference-counted mtl::BufferRef mOcclusionQueryResultBuffer와 bool mOcclusionQueryIsEnabledInRenderPass로 대체되었습니다. onOcclusionQueryBegin은 이제 query의 getVisibilityResultBuffer()를 확보하고, render-pass slot allocation을 OcclusionQueryPool::beginQuery/continueQuery에 위임합니다. 이전처럼 allocation 이전에 mOcclusionQuery = query를 먼저 설정하는 방식은 제거되었습니다. Query별 render-pass state (VisibilityBufferOffsetsMtl, allocated-offsets 목록)는 QueryMtl 밖으로 옮겨져 mtl_occlusion_query_pool.h로 이동했고, 여기서 mAllocatedQueries는 raw QueryMtl * 대신 BufferRef를 값으로 소유하는 std::vector<VisibilityBufferOffsetsMtl>로 바뀌었습니다. combineVisibilityResult도 (startOffset, numOffsets)를 받도록 시그니처가 변경되었습니다.
실패 가능한 연산 이전에 외부 소유 객체에 대한 raw pointer를 장기 상태로 노출시키고, 에러 경로에서 rollback을 수행하지 않아, 소유자가 객체를 해제하는 순간 dangling pointer가 남는 패턴입니다.
Background
WebGL2에서는 glBeginQuery(GL_ANY_SAMPLES_PASSED)와 glEndQuery 사이에 depth/stencil test를 통과한 sample이 하나라도 있는지 콘텐츠가 질의할 수 있으며, ANGLE의 Metal backend는 이를 Metal의 per-draw visibility-result write를 GPU buffer에 기록하는 방식으로 구현합니다. QueryMtl은 GL query에 대응하는 ANGLE Metal 구현 객체로, 최종 결합 결과를 담는 mtl::BufferRef mVisibilityResultBuffer를 소유합니다. OcclusionQueryPool은 render-pass별로 공유되는 visibility-results buffer에서 offset을 나눠주는 allocator입니다. Metal은 한 render pass의 모든 query를 하나의 buffer에 기록한 뒤 나중에 결합하는 방식을 쓰기 때문입니다. mtl::BufferRef는 Metal buffer에 대한 reference-counted handle이므로, 복사해도 buffer가 유지됩니다. ANGLE_TRY는 angle::Result 에러를 조기 반환시키는 매크로로, 예를 들어 buffer allocation에서 OOM이 발생했을 때 쓰입니다. GL query는 GL frontend가 소유하며, backend QueryMtl은 앱이 query를 삭제할 때 onDestroy를 통해 파괴됩니다. ANGLE은 WebKit의 GPU process 안에서 동작하며, WebContent에서 발생한 WebGL command stream을 처리합니다.
Analysis
이 취약점은 lifetime과 error-path 순서가 어긋나면서 발생한 use-after-free에 해당합니다. Fix 이전에는 onOcclusionQueryBegin이 render-pass visibility slot을 할당하기도 전에, GL이 소유한 QueryMtl에 대한 raw pointer를 mOcclusionQuery에 먼저 노출시켰습니다. mOcclusionQuery = query; ANGLE_TRY(startOcclusionQueryInRenderPass(query, true));가 그 코드입니다. startOcclusionQueryInRenderPass는 OcclusionQueryPool::allocateQueryOffset을 호출하는데, 이 과정에서 pool buffer가 커질 수 있어 실패(OOM)할 가능성이 있고, 이 실패는 begin의 에러로 그대로 전파됩니다.
Begin이 실패하면 GL frontend는 해당 query를 active 상태로 간주하지 않으며, QueryMtl이 계속 살아있음을 보장하지도 않습니다. 그 결과 애플리케이션은 glDeleteQueries로 이를 삭제할 수 있습니다. QueryMtl::onDestroy는 getAllocatedVisibilityOffsets()가 비어 있지 않을 때만 context에 통지하도록 되어 있었는데, begin이 실패한 경로에서는 offset이 하나도 할당되지 않은 상태였기 때문에 이 통지가 건너뛰어졌고, mOcclusionQuery는 끝내 clear되지 않았습니다. 결과적으로 ContextMtl 안에 dangling raw pointer가 남게 되며, 마찬가지로 mAllocatedQueries 안의 raw QueryMtl * 항목들도 가리키는 대상보다 더 오래 살아남게 됩니다.
이 취약점에 도달하려면, WebGL2 콘텐츠가 query begin 시점에 visibility-buffer allocation을 강제로 실패시킨 뒤 해당 query를 삭제해야 합니다. 이후 이어지는 draw나 render-pass teardown 과정에서 이미 해제된 QueryMtl을 dereference하게 됩니다. 다만 추가된 테스트 주석에는 OOM을 수동으로 주입해야 한다고 명시되어 있어, allocation 실패 자체가 attacker에 의해 손쉽게 유발되는 조건은 아니라는 점이 severity를 제한하는 구체적인 근거가 됩니다. 만약 attacker가 해제된 QueryMtl slot을 grooming해서 GPU visibility-buffer offset에 영향력을 행사할 수 있다면 더 강력한 primitive로 이어질 가능성이 존재하지만, 이 경로는 diff만으로는 확인되지 않습니다.
이 취약점은 GPU process 내부의 메모리 안전성을 약화시킵니다. Security model은 ANGLE backend state (mOcclusionQuery, mAllocatedQueries)가 참조하는 GL 객체보다 더 오래 살아남지 않는다는 전제를 두고 있는데, fix 이전의 begin/error 순서는 이 전제를 위반했습니다. 결과적으로 발생하는 compromise는 GPU-process sandbox 내부에 머무르며, 별도의 escape가 있어야 그 밖으로 확장됩니다.
근본적인 해결책은 구조적인 성격을 띱니다. Backend가 fallible한 경계를 넘나들며 frontend 소유 GL 객체에 대한 raw pointer를 캐싱하고 있었던 것이 문제였고, fix는 아예 GL 객체를 참조하지 않는 방향으로 전환합니다. 대신 그것이 감싸고 있는 reference-counted Metal 자원(BufferRef)을 보유하고, render-pass tracking은 값으로 소유되는 VisibilityBufferOffsetsMtl 항목으로 옮겨졌습니다. Renderer backend가 실패 가능한 allocation 경계를 넘어 raw QueryImpl*/TextureImpl*을 캐싱하는 지점이 있다면 모두 의심 대상입니다.
Note: attacker가 begin-time allocation 실패를 안정적으로 유발할 수 있다는 점, 그리고 해제된 slot을 grooming하면 visibility-buffer offset에 영향력을 얻을 수 있다는 점은 추론에 해당합니다. 테스트에서 OOM을 수동으로 주입한다는 사실과 diff에서 도달 가능한 use site가 확인된다는 사실은 메커니즘 자체를 뒷받침하지만, 완전한 attacker control을 뒷받침하지는 않습니다. Refactor가 제거한 dangling-pointer 순서 문제는 diff에서 직접 확인됩니다.
Audit directions
- Backend state가 frontend 소유 GL 객체에 대한 raw pointer를 캐싱하면서, 실패 가능한 연산 이전에 이를 설정하고 에러 경로에서 rollback을 수행하지 않는 패턴입니다. ANGLE의 Metal (및 Vulkan) backend에서
ContextMtl::mOcclusionQuery처럼ANGLE_TRY(...)allocation 이전에Query*/Texture*/Buffer*가 대입되는 멤버가 있는지 점검할 필요가 있습니다.ContextMtl.mm/ContextVk.cpp에서 member-pointer 대입 직후 같은 함수 내에ANGLE_TRY가 뒤따르는 패턴을 검색하고, 해당 멤버가 에러 경로에서 clear되는지 확인해야 합니다. - "할당되었는가" 여부에 따라 게이트되는 destroy/cleanup 통지 패턴으로, 부분 초기화 이후 실패한 객체가 컨테이너에 통지하지 못하는 경우입니다.
QueryImpl::onDestroy및 이에 대응하는 코드들에서 notify-context 호출이if (!...empty())/if (allocated)같은 guard로 감싸져 있는지 살펴봐야 합니다. Begin이 context에 등록한 이후 실패하더라도 여전히 deregistration이 트리거되는지 확인해야 하며, 다른 renderer에 있는QueryMtl::onDestroy의 유사 코드부터 시작하는 것이 좋습니다. - 다른 곳에서 lifetime이 관리되는 raw backend-object pointer들의 컨테이너(
std::vector<QueryMtl*>)입니다. Metal backend에서std::vector<.*Mtl \*>/std::vector<.*Impl \*>가 per-render-pass 또는 per-frame 캐시로 쓰이는 곳을 검색하고, 어떤 항목이든 참조되는 도중 해제될 수 있는지 점검해야 합니다. 이번 fix처럼 value-owned reference-counted handle을 우선하는 편이 안전합니다. - 여기서 새로 도입된 OOM/error 경로 자체가 안전한지 검증할 필요가 있습니다.
OcclusionQueryPool::beginQuery/continueQuery는 이제VisibilityBufferOffsetsMtl을 emplace하고ensurePoolCapacity를 호출합니다.mAllocatedQueries.emplace_back이후ensurePoolCapacity내부에서 실패가 발생하더라도 buffer를 참조하는 stale entry가 남지 않는지, 그리고prepareRenderPassVisibilityPoolBuffer의mAllocatedQueries.back()이 빈 vector 상태에서 호출되는 일이 없는지 확인해야 합니다.