[1] ANGLE Metal: stale texture views survive storage reallocation
The Metal validator turned an attacker-sized upload into a crash — where it runs.
High. 파생된 레벨별 view를 캐싱하는 구조가, 그 view가 가리키는 storage보다 더 오래 살아남는 문제입니다. 그 결과 texture resize 이후에도 translation layer가 destination보다 더 큰 region descriptor를 driver에 그대로 전달할 수 있게 됩니다. 실제 위험 수위는 실행 중인 build의 Metal validation 여부에 따라 갈립니다. Validation이 켜져 있으면 항상 동일하게 abort가 발생하고, 꺼져 있으면 attacker가 지정한 크기만큼의 OOB pixel write로 이어집니다.
두 그래픽 API 사이의 translation layer는 대상 API의 객체 상태를 스스로 기록해두어야 하는데, 이 기록은 실제 상태와 어긋날 위험을 항상 안고 있습니다. ANGLE은 WebKit이 WebGL을 처리하기 위해 사용하는 OpenGL ES 구현체이며, Apple 플랫폼에서는 GLES 호출을 Metal로 lowering합니다. 이때 각 GLES texture는 TextureMtl 객체가 담당합니다. 이 객체는 같은 texture를 가리키는 두 가지 병렬 표현을 가지고 있습니다. 하나는 전체 mipmap chain을 담는 live Metal texture인 mNativeTextureStorage이고, 다른 하나는 chain이 완성되기 전 업로드를 처리하기 위한 경량 view 캐시인 mTexImageDefs[face][level]입니다. 여기서 지켜야 할 계약은, 캐싱된 모든 view가 현재 storage의 한 레벨을 정확히 기술해야 한다는 점입니다.
관전 포인트: WebGL 페이지가 texture의 base level을 resize하고 mipmap을 재생성한 뒤 sub-level에 업로드를 시도하면, 이전 allocation에 속한 dimension으로 업로드가 실행될 수 있습니다. Validation이 켜진 build에서는 renderer/GPU-process abort로 이어지고, validation이 꺼진 환경에서는 제한된 범위의 out-of-bounds pixel write로 이어집니다.
Commit 메시지는 실제 증상을 다음과 같이 기록하고 있습니다.
When texture base level size changes (e.g., 128x128 → 256x256), native storage is recreated but old mipmap views with wrong dimensions can remain in
mTexImageDefs. Uploading to these stale views causes Metal validation failure:(origin.x + size.width)(128) must be <= width(64).Clear
mTexImageDefsentries ingenerateMipmap()andredefineImage()to ensure views are always recreated with correct dimensions from current storage.
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm
Source/ThirdParty/ANGLE/src/tests/gl_tests/MipmapTest.cpp
Patch Details
변경되는 함수는 두 곳입니다. generateMipmap()은 ensureNativeStorageCreated 직후에 sweep 로직을 새로 얻습니다. 새로 생성된 mNativeTextureStorage의 모든 cube face와 모든 mip level을 순회하면서, 대응되는 mTexImageDefs[face][level] 슬롯을 비웁니다. 이후 contextMtl->invalidateCurrentTextures()를 호출해, 다음 draw 전에 바인딩된 sampler/texture 슬롯이 다시 바인딩되도록 합니다.
redefineImage()는 단순 패치가 아니라 구조 자체가 재정리되었습니다. imageDef 슬롯은 이제 cubeFaceOrZero/glLevel로부터 함수 초반에 곧바로 확정됩니다. format/size 비교 로직은 반대로 뒤집혀서, 기존 storage가 이미 요구사항과 일치하는 경우 조기에 return angle::Result::Continue로 빠지도록 바뀌었습니다. 그 결과 isGLLevelSupported 분기 안에 남는 경로는 mismatch 케이스 하나뿐이며, 이 경로는 deallocateNativeStorage(/*keepImages=*/true)를 호출한 뒤 조건 없이 imageDef = {}로 이어집니다. 이후 단계에서는 기존에 있던 if (mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels) 형태의 optimistic-reuse 분기가 imageWithinNativeStorageLevels 플래그, 내부 GetTextureImageType helper와 함께 완전히 삭제되었습니다. 대신 항상 새로운 mtl::TextureRef를 생성하고 할당하도록 바뀌었습니다. ensureNativeStorageCreated 안에 남아 있던 imageToTransfer = nullptr 코드도 mTexImageDefs[face][imageMipLevel] = {}로 대체되었습니다. MipmapTest.cpp에는 128→256→128 전환과 resize 이후 mipmap 재생성을 검증하는 회귀 테스트 두 개가 추가되었습니다.
캐싱된 파생 view 메타데이터가, 그 backing storage가 재할당될 때 함께 invalidate되지 않아, 새 storage를 기준으로 stale한 dimension을 가진 채로 연산이 실행될 수 있는 패턴입니다.
Background
Where this lives. ANGLE은 WebKit이 WebGL을 구현하기 위해 사용하는 OpenGL ES translator입니다. Apple 플랫폼에서는 Metal backend를 대상으로 하기 때문에, 페이지가 호출하는 모든 GLES call은 Metal 객체와 command로 lowering됩니다.
Texture state in the Metal backend.
TextureMtl은 하나의 GLES texture를 담당하는 ANGLE 객체입니다. 이 객체는 texture가 완성되었을 때 전체 mipmap chain을 나타내는 단일 Metal texture인 mNativeTextureStorage를 가지며, 동시에 chain이 구성되기 전이나 구성되는 도중에 GLES image 연산을 처리하기 위한 ImageDefinitionMtl 값 캐시인 mTexImageDefs[face][level]도 가집니다. 각 ImageDefinitionMtl은 {mtl::TextureRef view, formatID} 쌍입니다. mtl::TextureRef는 id<MTLTexture>를 감싸는 reference-counted wrapper이며, imageDef = {}를 대입하면 해당 reference가 해제됩니다.
GLES entry points that reshape storage.
glTexImage2D(level=0, w, h)는 base level을 resize할 수 있는데, 이 경우 이후 레벨들이 새 chain을 담을 수 있도록 mNativeTextureStorage가 강제로 재생성됩니다. glGenerateMipmap은 ANGLE에게 전체 chain을 할당하고 채우도록 요청합니다. redefineImage는 glTexImage* 계열 호출이 레벨별 image를 (재)할당하기 위해 내부적으로 거치는 진입점입니다. ContextMtl::invalidateCurrentTextures()는 다음 draw 전에 sampler/texture 슬롯을 다시 바인딩하도록 context에 지시합니다.
Metal region validation.
replaceRegion:mipmapLevel:withBytes:bytesPerRow:는 origin + size가 해당 mip level의 실제 dimension 안에 들어와야 한다는 요구사항을 갖습니다. Metal의 validation layer가 이를 검사하며, 조건을 만족하지 못하면 두 수치를 명시한 에러를 발생시킵니다.
Analysis
이 버그는 stale-cache / dimension-mismatch 계열에 속합니다. 같은 texture 상태를 기술하는 두 병렬 표현 중 하나만 갱신되고 다른 하나는 갱신되지 않은 상황입니다.
glTexImage2D(0, 128x128) glGenerateMipmap glTexImage2D(0, 256x256)
────────────────────────── ───────────────── ────────────────────────
storage A: L0=128 L1=64 levels populated storage B: L0=256 L1=128
mTexImageDefs[0][1] = view mTexImageDefs[0][1] = view
^ still describes A's L1 (64)
but B's L1 is a different level
glTexImage2D(1, 128x128, px)
└─► stale view consulted (optimistic-reuse path is the clearest such consumer)
└─► replaceRegion(origin 0, size 128) against a level whose width is 64
└─► "(origin.x + size.width)(128) must be <= width(64)"
위 다이어그램을 따라가 보면, level 0의 resize는 새 chain을 수용하기 위해 mNativeTextureStorage를 재할당하지만, 이전 allocation에서 파생된 mTexImageDefs의 레벨별 view들을 폐기하는 코드는 어디에도 없습니다. 이 항목들은 여전히 live한 mtl::TextureRef를 들고 있는데, 그 dimension은 이전 chain의 것입니다. 삭제된 imageDef.image && imageWithinNativeStorageLevels 분기는 이 stale 데이터를 그대로 신뢰하던 가장 직접적인 소비 지점이었습니다. 이 분기는 캐싱된 image가 non-null이고 그 레벨 인덱스가 새 storage의 범위 안에 들어온다는 사실만 확인한 뒤 재구성을 건너뛰고, stale view로부터 업로드를 그대로 dispatch했습니다. 다만 mTexImageDefs를 읽는 경로가 이 분기 하나만은 아니기 때문에, 이번 제거는 특정 분기 하나를 닫는 수준이 아니라 모든 소비 지점에 대해 invariant를 복원하는 조치에 해당합니다. Fix가 조건문을 더 세밀하게 다듬는 대신 캐시 자체를 비우는 방식을 택한 이유도 여기에 있습니다. 애초에 그 조건문은 authoritative한 backing store가 아니라 캐싱된 사본을 근거로 계산되고 있었기 때문입니다.
추가된 MipmapTest 케이스는 최소화된 trigger이며, 페이지가 실제로 구동할 수 있는 시나리오와 그대로 대응됩니다.
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 128, 128, ...)— base level을 128로 설정, storage A가 L1=64로 할당됩니다.glGenerateMipmap(GL_TEXTURE_2D)— storage A의 level 1..N을 채우면서mTexImageDefs항목들이 채워집니다.glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 256, 256, ...)— base level resize로 인해 native storage 재생성이 강제되지만, level 1의 캐시 항목은 그대로 남습니다.glGenerateMipmap(GL_TEXTURE_2D)— storage B 위에 새 chain이 구성됩니다.glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 128, 128, ..., blueData.data())— non-base level에 업로드하는데, 현재 geometry가 아니라 resize 이전 geometry에 맞춰진 크기로 처리됩니다.
각 단계는 신뢰되지 않은 콘텐츠도 자유롭게 호출할 수 있는 일반적인 WebGL API입니다. WebGLTexture 할당, texImage2D, generateMipmap, texImage2D, texImage2D 순서일 뿐, 특별한 extension이나 timing 조건, heap grooming이 전혀 필요하지 않습니다. 테스트 이름 자체는 색상 샘플링의 정확성에 초점을 맞추고 있지만, 코드베이스 안에 남아 있는 실질적인 실패 증거는 commit 메시지에 인용된 driver-level validation abort입니다.
Exploitability는 실행 중인 build에서 Metal validation이 켜져 있는지 여부에 따라 갈립니다. Validation이 켜져 있는 경우 결과는 항상 동일합니다. 위에 인용된 abort가 그대로 발생하며, WebGL을 다룰 수 있는 어떤 페이지에서든 renderer/GPU-process crash를 안정적으로 유발할 수 있습니다. Driver가 해당 region을 검증하지 않는 환경, 즉 일부 하드웨어 경로의 release shipping 구성에서는 같은 command가 guard 없이 그대로 전달됩니다. 이 경우 업로드 region이 destination level의 실제 dimension을 초과한 채로, attacker가 지정한 pixel 데이터가 그 mip level을 담당하는 Metal heap allocation의 정당한 범위를 넘어 기록될 수 있습니다. Overrun 크기(resize 전후로 선택한 dimension을 통해)와 기록되는 내용(texImage2D의 source buffer를 통해) 모두 attacker가 결정할 수 있는 값입니다. 다만 이 latent write는 실제로 입증된 시나리오라기보다는 예상되는 방향에 가깝습니다. 코드베이스에서 확인 가능한 확정적 증거는 validation에 의한 abort 쪽입니다.
이 vulnerability는 WebGL 콘텐츠와 GPU process/Metal driver 사이의 memory-safety 경계를 약화시킵니다. HTML/WebGL trust model은 GLES texture 상태 전환이 malformed driver-level command를 만들어낼 수 없다는 전제를 갖고 있는데, 여기서는 texImage2D + generateMipmap + texImage2D 순서가 destination level의 실제 범위를 초과하는 선언된 region을 가진 업로드를 만들어냅니다. 성공적인 OOB write가 이루어지더라도 attacker는 여전히 GPU/WebContent sandbox 내부에 머무르며, kernel이나 다른 process에 도달하려면 별도의 escape가 필요합니다.
Insight: 이 버그가 속한 클래스는 캐싱된 파생 메타데이터와 authoritative backing storage 사이의 불일치입니다. ANGLE은 의도적으로 texture 상태를 두 가지 방식으로 기록해두는데, 한쪽만 변경하고 다른 쪽을 invalidate하지 않는 경로가 있다면 언제든 이런 종류의 버그를 초래할 수 있습니다. Fix가 택한 전략, 즉 새 storage의 모든 (face, level) 조합을 순회하며 대응 캐시 슬롯을 조건 없이 비우는 방식은 특정 케이스를 patch하는 접근이 아니라 invariant 자체를 복원하는 접근입니다. 삭제된 optimistic-reuse 분기는 역사적으로 이런 mismatch를 만들어내는 전형적인 micro-optimization 형태이기도 합니다. 별도로 짚어둘 점은, GPU validation layer가 release 구성에서는 동작하지 않을 수도 있는 마지막 방어선 역할을 한다는 사실입니다. 그래서 validation 상태에서 발견된 "crash bug"라면, validation이 없을 때 어떤 일이 벌어지는지도 한 번 더 살펴볼 필요가 있습니다.
Audit directions
-
Parallel-cache invalidation gaps, 즉 "빠른" per-element descriptor 캐시가 authoritative backing store보다 더 오래 살아남는 패턴을 점검해야 합니다.
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm안에서mNativeTextureStorage를 변경하는(allocate/deallocate/recreate) 모든 지점을 확인하고, 각각이mTexImageDefs의 전체 sweep과 짝을 이루는지 검증해야 합니다.deallocateNativeStorage,ensureNativeStorageCreated,setImageImpl,copyImage*,setStorage*, 그리고Texture::Make2DTexture/Make3DTexture/Make2DArrayTexture를 호출하는 모든 경로부터 시작하는 것이 좋습니다.mNativeTextureStorage에 대한 대입문을 검색해서, 도달 가능한 모든 분기에서mTexImageDefs가 리셋되는지 확인해야 합니다. 코드 리뷰 단계에서는, storage handle에 대한 대입이 있는데 같은 함수 안에서 즉시 캐시 sweep이 뒤따르지 않는다면 어떤 캐시를 invalidate하는지 명시하는 주석이 필요합니다. -
GPU-driver validation이 유일한 방어선으로 작동하는 지점, 즉 translation layer의 logic bug와 OOB region 연산 사이를 validation만이 막고 있는 지점을 점검해야 합니다. 다른 ANGLE backend(
Source/ThirdParty/ANGLE/src/libANGLE/renderer/vulkan/,.../d3d/,.../gl/)에서도redefineImage/generateMipmap주변의 level-cache invalidation이 유사한 방식으로 처리되는지 확인해야 합니다. 이번 사례에서는 Metal validation error가 문제를 드러냈지만, 동등한 Vulkan layer(VK_LAYER_KHRONOS_validation)나 D3D debug layer도 유사한 문제를 잡아낼 수 있습니다. 다만 이는 해당 layer가 활성화되어 있을 때에 한합니다. 캐싱된 extent를 그대로 사용해 호출되는replaceRegion대응 함수(vkCmdCopyBufferToImage,ID3D11DeviceContext::UpdateSubresource)를 검색해볼 필요가 있습니다. -
Cached view/handle을 재사용할 때, 이미 낡은 "shape matches" predicate에 의존하는 낙관적 재사용 패턴. 삭제된
imageDef.image && imageWithinNativeStorageLevels분기가 정확히 이 패턴에 해당합니다. ANGLE 전반에서if (cached.handle && shapeMatches) { ASSERT(...); /* skip rebuild */ }형태의 유사 구문을 검색하고,shapeMatchespredicate가 저장된 복사본이 아니라 실제 backing store로부터 계산되는지 확인할 필요가 있습니다.TextureMtl::ensureImageCreated,TextureMtl::getImageDefinition, 그리고keepImages=true해제를 호출하는 지점들부터 살펴보는 것이 좋습니다. 이런 코드를 리뷰할 때의 실마리는, 한 분기에서 계산된 boolean flag가 수십 줄 뒤에서 소비된다는 점입니다. 소비되는 시점에는 그 값이 더 이상 참이 아닐 가능성이 있습니다. -
backing storage를 재할당하면서, 이에 의존하는 모든 cache를 훑지 않는 GLES state transition.
glTexStorage*,glCopyTexImage*,glCopyTexSubImage*,level == 0으로 resize하는glTexImage*, 그리고 EGLImage/IOSurface rebinding 경로를 대상으로 같은 부류의 stale-view 버그가 있는지 점검할 필요가 있습니다.TextureMtl::releaseTexImage,TextureMtl::bindTexImage, 그리고 surface-attachment 경로가mTexImageDefs에 대해 동등한 수준의 전체 sweep을 수행하는지도 함께 확인해야 합니다.