← All issues

[1] ANGLE Metal: stale texture views during size transitions

Severity: High | Component: ANGLE Metal backend | baeadcd

Rated High because the diff fixes a deterministic, web-reachable Metal validation abort in the GPU/renderer process and removes the optimistic stale-view reuse branch in TextureMtl::redefineImage. On Metal driver paths where validation is disabled, the same sequence would dispatch an upload with a stale region descriptor against the resized storage, yielding a bounded OOB pixel-data write into the Metal heap allocation backing the destination mip level.

When a texture base level size changes (e.g., 128x128 to 256x256), native storage is recreated but old mipmap views with wrong dimensions can remain in mTexImageDefs. Uploading to these stale views causes a Metal validation failure: (origin.x + size.width)(128) must be <= width(64). The fix clears mTexImageDefs entries in generateMipmap() and redefineImage() to ensure views are always recreated with correct dimensions from current storage.

Source/ThirdParty/ANGLE/src/libANGLE/renderer/metal/TextureMtl.mm

ANGLE_TRY(ensureNativeStorageCreated(context, false));
+ int numCubeFaces = static_cast<int>(mNativeTextureStorage->cubeFaces());
+ for (int face = 0; face < numCubeFaces; ++face)
+ {
+ const GLuint mips = mNativeTextureStorage->mipmapLevels();
+ for (mtl::MipmapNativeLevel mip = mtl::kZeroNativeMipLevel; mip.get() < mips; ++mip)
+ {
+ GLuint level = mNativeTextureStorage->getGLLevel(mip);
+ mTexImageDefs[face][level] = {};
+ }
+ }
ContextMtl *contextMtl = mtl::GetImpl(context);
+ contextMtl->invalidateCurrentTextures();
...
- bool imageWithinNativeStorageLevels = false;
- if (mNativeTextureStorage && mNativeTextureStorage->isGLLevelSupported(index.getLevelIndex()))
+ GLuint cubeFaceOrZero = GetImageCubeFaceIndexOrZeroFrom(index);
+ GLuint glLevel = index.getLevelIndex();
+ ImageDefinitionMtl &imageDef = mTexImageDefs[cubeFaceOrZero][glLevel];
+ if (mNativeTextureStorage && mNativeTextureStorage->isGLLevelSupported(glLevel))
{
- imageWithinNativeStorageLevels = true;
- GLuint glLevel = index.getLevelIndex();
- if (mNativeTextureStorage->getFormat() != mtlFormat ||
- size != mNativeTextureStorage->size(glLevel))
+ if (mNativeTextureStorage->getFormat() == mtlFormat && size == mNativeTextureStorage->size(glLevel))
{
- deallocateNativeStorage(/*keepImages=*/true);
+ return angle::Result::Continue;
}
+ deallocateNativeStorage(/*keepImages=*/true);
}
+ imageDef = {};
...
- if (mNativeTextureStorage && imageDef.image && imageWithinNativeStorageLevels)
- {
- ASSERT(...);
- }
- else
- {
- imageDef.formatID = mtlFormat.intendedFormatId;
- ...
- }
+ mtl::TextureRef image;
+ imageDef = {image, mtlFormat.intendedFormatId};

Source/ThirdParty/ANGLE/src/tests/gl_tests/MipmapTest.cpp

+TEST_P(MipmapTest, UploadAfterSizeTransition)
+{
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 128, 128, ...);
+ glGenerateMipmap(GL_TEXTURE_2D);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 256, 256, ...);
+ glGenerateMipmap(GL_TEXTURE_2D);
+ glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA, 128, 128, ..., blueData.data());
+}

generateMipmap is restructured to iterate every face and level of the freshly allocated mNativeTextureStorage and zero the corresponding mTexImageDefs[face][level] slot, followed by contextMtl->invalidateCurrentTextures() so subsequent draws rebind sampler slots. redefineImage is rewritten with a different control flow: it computes the imageDef slot up front, takes an early-return when the existing storage already matches both format and size, and otherwise calls deallocateNativeStorage(/*keepImages=*/true) followed by an unconditional imageDef = {} reset before building a fresh mtl::TextureRef. The internal GetTextureImageType helper and the optimistic imageDef.image && imageWithinNativeStorageLevels reuse branch are deleted; an obsolete imageToTransfer = nullptr line inside ensureNativeStorageCreated is replaced with mTexImageDefs[face][imageMipLevel] = {}. Two regression tests exercise 128 to 256 to 128 size transitions and post-resize mipmap regeneration.

Cached derived view metadata not invalidated when its backing storage is reallocated, allowing operations to dispatch with stale dimensions against the new storage.

ANGLE is the OpenGL ES translator WebKit uses to implement WebGL; on Apple platforms it targets a Metal backend. TextureMtl is the ANGLE Metal object that backs a GLES texture and holds two parallel descriptions of texture state. mNativeTextureStorage is a single Metal MTLTexture representing the full mipmap chain once the texture becomes complete; mTexImageDefs[face][level] is a cache of per-image ImageDefinitionMtl values (a {mtl::TextureRef view, formatID} pair) used to service GLES image operations before or while the chain is being built.

glTexImage2D(level=0, w, h) may resize the base level and forces recreation of mNativeTextureStorage so subsequent levels can host the new chain. glGenerateMipmap requests that ANGLE allocate and populate the full chain. redefineImage is the ANGLE entry point invoked by glTexImage* to (re)allocate a per-level image. mtl::TextureRef is a reference-counted wrapper over id<MTLTexture>; assigning imageDef = {} drops the held reference. Metal's replaceRegion:mipmapLevel:withBytes:bytesPerRow: enforces that origin + size fit within that mip level's actual dimensions and raises a validation error on mismatch when the validation layer is active.

The bug class is stale cached metadata. TextureMtl keeps two parallel descriptions of the same texture, and the invariant that mutating mNativeTextureStorage must also invalidate every dependent mTexImageDefs entry was not enforced on every path. Specifically, when a glTexImage2D at level 0 with a new size triggered storage recreation, the per-level views in mTexImageDefs for non-base levels carried over from the old storage; their stored dimensions described the prior chain. A subsequent operation that consulted these cached views — most directly the optimistic reuse branch in redefineImage guarded by imageDef.image && imageWithinNativeStorageLevels — would dispatch a Metal upload with the old view's dimensions against the new per-level storage.

The reproduction added in MipmapTest.cpp traces the exact path. The test allocates a 128x128 base level and calls generateMipmap, producing levels 1..N with sizes 64, 32, 16 and so on. It then calls glTexImage2D at level 0 with size 256, forcing mNativeTextureStorage recreation; the new chain's level 1 is now 128x128, but the pre-existing mTexImageDefs[0][1] entry still describes a 64x64 view from the prior storage. A glTexImage2D at level 1 with size 128 then enters redefineImage, hits the optimistic reuse branch, and dispatches replaceRegion with the stale 64-width view metadata against the new 128-width level. Metal validation refuses the call with the quoted error.

The visible consequence in validation-enabled builds is a deterministic renderer or GPU-process abort reachable from any WebGL-capable page — a straightforward DoS surface for web content. On Metal driver paths where validation is disabled, the same command would be issued without the guard. The bounds-check that turned this bug into a crash is a validation-layer property, not a driver-level invariant; on non-validating paths the upload region would exceed the destination level's actual dimensions and could write attacker-supplied pixel data past the legitimate destination extents in the Metal heap allocation backing that mip level. The attacker controls both the source buffer contents (via texImage2D data) and the region descriptor's overshoot (via chosen pre/post resize dimensions), so the latent primitive is a sized OOB pixel-data write within the GPU/WebContent process.

This vulnerability weakens the memory-safety boundary between WebGL content and the GPU process. The HTML/WebGL trust model assumes that GLES texture state transitions cannot produce malformed driver-level commands; this assumption is violated whenever cached derived metadata outlives its authoritative backing store. 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.

Note: Some implementation details (e.g., precise behavior of Metal driver paths where validation is disabled) are inferred from the diff and the quoted validation error rather than directly visible in the source. The core execution flow and crash trigger are consistently supported by the patch and the added regression tests.