[1] ANGLE Metal: stale texture views survive storage reallocation
The Metal validator turned an attacker-sized upload into a crash — where it runs.
High. A cache of derived per-level views outlived the storage they described, so a texture resize left the translation layer able to hand the driver a region descriptor larger than the destination. The ceiling is set by whether the running build's Metal validation is in force: with it, a deterministic abort; without it, an OOB pixel write sized by the attacker's chosen dimensions.
A translation layer between two graphics APIs has to keep its own bookkeeping of the target API's objects, and that bookkeeping can drift out of sync with what it describes. ANGLE is the OpenGL ES implementation WebKit uses to service WebGL; on Apple platforms it lowers GLES calls onto Metal, and each GLES texture is backed by a TextureMtl object. That object holds two parallel descriptions of the same texture: mNativeTextureStorage, the live Metal texture holding the whole mipmap chain, and mTexImageDefs[face][level], a per-level cache of lightweight views used to service uploads before the chain is consolidated. The contract is that every cached view describes a level of the current storage.
The angle: a WebGL page can resize a texture's base level, regenerate mipmaps, then upload to a sub-level and have the upload dispatched with dimensions belonging to the previous allocation — a renderer/GPU-process abort on validating builds, and a bounded out-of-bounds pixel write where validation is off.
The commit message reports the concrete symptom:
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
Two functions change. generateMipmap() gains a sweep immediately after ensureNativeStorageCreated: it walks every cube face and every mip level of the freshly created mNativeTextureStorage, zeroing the matching mTexImageDefs[face][level] slot, then calls contextMtl->invalidateCurrentTextures() so bound sampler/texture slots are rebound before the next draw.
redefineImage() is restructured rather than patched. The imageDef slot is now resolved up front from cubeFaceOrZero/glLevel. The format/size comparison is inverted into an early return angle::Result::Continue when the existing storage already matches, so the only remaining path through the isGLLevelSupported branch is the mismatch case, which calls deallocateNativeStorage(/*keepImages=*/true) and then falls through to an unconditional imageDef = {}. Downstream, the if (mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels) optimistic-reuse branch is deleted entirely along with the imageWithinNativeStorageLevels flag and the internal GetTextureImageType helper; a fresh mtl::TextureRef is always built and assigned. A leftover imageToTransfer = nullptr inside ensureNativeStorageCreated becomes mTexImageDefs[face][imageMipLevel] = {}. Two regression tests exercising 128→256→128 transitions and post-resize mipmap regeneration are added to MipmapTest.cpp.
Cached derived view metadata not invalidated when its backing storage is reallocated, allowing operations to dispatch with stale dimensions against the new storage.
Background
Where this lives. ANGLE is the OpenGL ES translator WebKit uses to implement WebGL; on Apple platforms it targets a Metal backend, so every GLES call a page makes is lowered onto Metal objects and commands.
Texture state in the Metal backend.
TextureMtl is the ANGLE object backing a single GLES texture. It holds mNativeTextureStorage, one Metal texture representing the full mipmap chain once the texture is complete, and mTexImageDefs[face][level], a cache of ImageDefinitionMtl values — each a {mtl::TextureRef view, formatID} pair — used to service GLES image operations before and while the chain is being built. mtl::TextureRef is a reference-counted wrapper over id<MTLTexture>; assigning imageDef = {} drops the reference.
GLES entry points that reshape storage.
glTexImage2D(level=0, w, h) may resize the base level, which forces recreation of mNativeTextureStorage so subsequent levels can host the new chain. glGenerateMipmap asks ANGLE to allocate and populate the full chain. redefineImage is the internal entry point glTexImage* funnels into to (re)allocate a per-level image. ContextMtl::invalidateCurrentTextures() tells the context to rebind sampler and texture slots before the next draw.
Metal region validation.
replaceRegion:mipmapLevel:withBytes:bytesPerRow: requires that origin + size fit inside that mip level's actual dimensions. Metal's validation layer checks this and raises an error naming both numbers when it does not hold.
Analysis
The bug is a stale-cache / dimension-mismatch class: two parallel descriptions of the same texture state, one of which was updated while the other was not.
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)"
Following the diagram: the resize at level 0 reallocates mNativeTextureStorage so the new chain can be hosted, but nothing walks mTexImageDefs to discard the per-level views derived from the previous allocation. Those entries still hold live mtl::TextureRefs whose dimensions belong to the old chain. The deleted imageDef.image && imageWithinNativeStorageLevels branch is the most direct consumer that trusted them: it saw a non-null cached image whose level index fell within the new storage's range and skipped rebuilding, dispatching the upload from the stale view. It is not the only path that reads mTexImageDefs, so the removal restores the invariant for every consumer rather than closing one branch — which is also why the fix zeroes the cache instead of tightening the predicate, since that predicate was computed from the cached copy rather than from the authoritative backing store.
The added MipmapTest case is the minimized trigger, and it maps directly onto what a page can drive:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 128, 128, ...)— base level at 128, storage A allocated with L1=64.glGenerateMipmap(GL_TEXTURE_2D)— populates levels 1..N of storage A, seedingmTexImageDefsentries.glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 256, 256, ...)— base level resize forces recreation of the native storage; the level-1 cache entry survives.glGenerateMipmap(GL_TEXTURE_2D)— new chain built on storage B.glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 128, 128, ..., blueData.data())— upload to a non-base level, sized to match the pre-resize geometry rather than the current one.
Every step is an ordinary WebGL call available to untrusted content: allocate a WebGLTexture, texImage2D, generateMipmap, texImage2D, texImage2D. There is no exotic extension, no timing requirement, and no heap grooming needed to reach the mismatch. The test names emphasize color-sampling correctness, but the in-tree evidence of the failure is the driver-level validation abort quoted in the commit message.
Exploitability splits on whether Metal validation is in force on the running build. Where it is, the outcome is deterministic: the abort quoted above, i.e. a reliably reachable renderer/GPU-process crash from any WebGL-capable page. Where the driver does not validate the region — release shipping configurations on some hardware paths — the same command would be issued without the guard, and the upload region would exceed the destination level's true dimensions, writing attacker-supplied pixel data past the legitimate extents of the Metal heap allocation backing that mip level. Both the overrun size (via the chosen pre- and post-resize dimensions) and the written content (via the texImage2D source buffer) are attacker-selected. That latent write is a projected direction rather than a demonstrated one; the confirmed, in-tree evidence is the validation-driven abort.
This vulnerability weakens the memory-safety boundary between WebGL content and the GPU process / Metal driver. The HTML/WebGL trust model assumes GLES texture state transitions cannot produce malformed driver-level commands; here a sequence of texImage2D + generateMipmap + texImage2D produces an upload whose declared region exceeds the destination level's true bounds. A successful OOB write would still leave the attacker inside the GPU/WebContent sandbox and would require a separate escape to reach the kernel or another process.
Insight: the class is cached derived metadata vs. authoritative backing storage. ANGLE deliberately keeps two descriptions of texture state, and any path that mutates one without invalidating the other invites this bug. The fix's strategy — iterate every (face, level) of the new storage and unconditionally zero the matching cache slot — is invariant-restoring rather than case-patching, and the deleted optimistic-reuse branch is exactly the kind of micro-optimization that historically introduces these mismatches. Worth noting separately: GPU validation layers act as last-line invariant enforcement that release configurations may not run with, which is why a "crash bug" found under validation deserves a second look at what happens without it.
Audit directions
- Parallel-cache invalidation gaps where a "fast" per-element descriptor cache outlives its authoritative backing store. Audit every site in
Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mmthat mutatesmNativeTextureStorage(allocate/deallocate/recreate) and verify each is paired with a full sweep ofmTexImageDefs. Start withdeallocateNativeStorage,ensureNativeStorageCreated,setImageImpl,copyImage*,setStorage*, and any path that callsTexture::Make2DTexture/Make3DTexture/Make2DArrayTexture. Grep for assignments tomNativeTextureStorageand confirmmTexImageDefsis reset on every reachable branch. In code review, any assignment to a storage handle that is not immediately followed by a cache sweep in the same function deserves a comment naming which cache it invalidates. - GPU-driver validation as the only barrier between a logic bug in a translation layer and an OOB region operation. Audit other ANGLE backends (
Source/ThirdParty/ANGLE/src/libANGLE/renderer/vulkan/,.../d3d/,.../gl/) for analogous level-cache invalidation aroundredefineImage/generateMipmap. The Metal validation error is what surfaced this; equivalent Vulkan layers (VK_LAYER_KHRONOS_validation) and D3D debug layers would catch the cousins — but only when enabled. Search forreplaceRegion-equivalent calls (vkCmdCopyBufferToImage,ID3D11DeviceContext::UpdateSubresource) issued with cached extents. - Optimistic reuse of cached views/handles guarded by a stale "shape matches" predicate. The deleted
imageDef.image && imageWithinNativeStorageLevelsbranch is exactly this pattern. Grep ANGLE for similarif (cached.handle && shapeMatches) { ASSERT(...); /* skip rebuild */ }constructs and verify theshapeMatchespredicate is computed from the live backing store, not a stored copy. Start withTextureMtl::ensureImageCreated,TextureMtl::getImageDefinition, and any callers ofkeepImages=truedeallocation. The review tell is a boolean flag computed in one branch and consumed several dozen lines later — its truth may no longer hold at the consumption point. - GLES state transitions that re-allocate backing storage without sweeping every dependent cache. Audit
glTexStorage*,glCopyTexImage*,glCopyTexSubImage*,glTexImage*withlevel == 0resize, and EGLImage/IOSurface rebinding paths for the same class of stale-view bug. Check whetherTextureMtl::releaseTexImage,TextureMtl::bindTexImage, and surface-attachment paths perform an equivalent fullmTexImageDefssweep.