CVE-2026-87621
Overview
Background
- ANGLE
- Chrome’s translation layer that implements OpenGL ES on top of native backends such as Direct3D 11, running inside the GPU process.
- Mip level completeness
- a texture level is “complete” when its dimensions, format and backing storage match what the mip chain expects; ANGLE uses this to decide whether a copy can write straight into GPU storage.
- `creationLevels`
- a
TextureD3Dhelper that returns the number of mip levels a texture of a given width/height/depth would legitimately have (roughlyfloor(log2(max(w,h,d))) + 1), i.e. the size of the storage mip chain. - `glCopyTextureCHROMIUM`
- the
CHROMIUM_copy_textureentry point (reachable from WebGL/the command decoder) that copies a source texture level into a destination texture level, choosing either a direct GPU-subresource copy or a slower image-staging fallback.
Root Cause Analysis
The pre-patch TextureD3D_2D::isLevelComplete, TextureD3D_3D::isLevelComplete, TextureD3D_2DArray::isLevelComplete and TextureD3D_Cube::isFaceLevelComplete decided completeness from the base-level dimensions and per-image checks but never verified that the requested level actually fell within the backing storage’s mip chain; for immutable textures they simply return true for any level. When glCopyTextureCHROMIUM targets a destination level that lies beyond the storage’s getLevelCount() (for example level 1 of a texture whose 1x1 base yields only a single storage level), these routines wrongly reported the level as complete, so the copy took the fast path (updateStorageLevel/updateStorageFaceLevel followed by mRenderer->copyTexture/copyImageCube) and indexed a D3D subresource that was never allocated — an out-of-bounds write into GPU storage.
The fix hoists the logic into a shared TextureD3D::isLevelComplete, which now returns false when level >= creationLevels(width, height, depth) and returns isValidLevel(level) (a getLevelCount() bounds check) for immutable textures, so out-of-storage levels are treated as incomplete and the copy is instead routed to the staging fallback that allocates the level’s image. getLevelZeroDepth is also corrected to return 1 so the shared completeness/creationLevels computation uses the true level-zero depth for 2D and cube targets. This works because the invariant “a level treated as complete must be backed by an in-range storage subresource” is now enforced before any direct GPU copy is issued.
level >= creationLevels(...) (and immutable isValidLevel) guard so such levels are declared incomplete and diverted to the safe staging path.Attack Path
- Create an undersized texture
Bind a 2D or cube-map texture and define only a 1x1 level-0 image, so the storage mip chain is a single level (
creationLevels(1,1,1) == 1). - Copy into a level past storage
Call
glCopyTextureCHROMIUMwithdestLevel = 1, which lies beyond the one-level mip chain; the oldisLevelComplete/isFaceLevelCompletefails to reject it (and unconditionally accepts it for immutable textures). - Take the direct-copy fast path
Because the level is judged complete,
updateStorageLevel/updateStorageFaceLevelandmRenderer->copyTexture/copyImageCuberun and address a D3D subresource index that the storage never allocated, producing the out-of-bounds write in ANGLE (advisory: “Out of bounds write in ANGLE”). - (inferred) Shape the write for corruption Repeating the copy with attacker-controlled source contents and dimensions could write attacker data past the intended storage allocation; the specific target object, offset and any resulting control-flow hijack are not shown by the diff.
Impact Assessment
(inferred) and not demonstrated by the patch. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6.Files Changed
src/tests/gl_tests/CopyTextureTest.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/libANGLE/renderer/d3d/TextureD3D.cppsrc/libANGLE/renderer/d3d/TextureD3D.h
Audit Directions
- Completeness predicates missing a storage-range checkAudit any
is*Complete/is*Validroutine that decides a fast/direct-GPU path to confirm it bounds the level (or layer/face) against the backing storage’s real extent, not just base-level dimensions. - Unconditional immutable shortcutsFlag
if (isImmutable()) return true;patterns; immutability guarantees fixed storage but not that an arbitrary caller-supplied level index is in range. - Copy/upload paths keyed on completenessReview
copyTexture,copySubTexture,updateStorageLevelandcopyImageCubecallers across all backends (D3D, GL, Metal, Vulkan) to ensure a level judged complete cannot address an unallocated subresource, and that the staging fallback is chosen for out-of-range levels.
Patch
From c477cf31f7a97abee4ce05a16b68ac6f1a852707 Mon Sep 17 00:00:00 2001
From: wangra <wangra@google.com>
Date: Tue, 18 Aug 2026 15:46:46 -0400
Subject: [PATCH] D3D: Reject out-of-storage mip levels in completeness check
Check creationLevels in TextureD3D isLevelComplete to prevent
out-of-storage mip levels from being treated as complete, which routes
copyTexture to the staging fallback and avoids invalid GPU subresource
access.
Test: angle_end2end_tests --gtest_filter="*DestLevelPastStorageMips*"
Bug: b/536664909
Change-Id: I8e4bbbbd4cfd234df06b9fcc81c7eb689006da2e
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8263283
Commit-Queue: Ran Wang <wangra@google.com>
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
---
diff --git a/src/libANGLE/renderer/d3d/TextureD3D.cpp b/src/libANGLE/renderer/d3d/TextureD3D.cpp
index ffc3a84..4b82538 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.cpp
+++ b/src/libANGLE/renderer/d3d/TextureD3D.cpp
@@ -209,7 +209,7 @@
GLint TextureD3D::getLevelZeroDepth() const
{
- return getBaseLevelDepth();
+ return 1;
}
GLint TextureD3D::getBaseLevelWidth() const
@@ -508,6 +508,45 @@
}
}
+bool TextureD3D::isValidLevel(int level) const
+{
+ return mTexStorage && level >= 0 && level < mTexStorage->getLevelCount();
+}
+
+bool TextureD3D::isLevelComplete(int level) const
+{
+ if (isImmutable())
+ {
+ return isValidLevel(level);
+ }
+
+ GLsizei width = getLevelZeroWidth();
+ GLsizei height = getLevelZeroHeight();
+ GLsizei depth = getLevelZeroDepth();
+
+ if (width <= 0 || height <= 0 || depth <= 0)
+ {
+ return false;
+ }
+
+ if (level >= creationLevels(width, height, depth))
+ {
+ return false;
+ }
+
+ if (level == static_cast<int>(getBaseLevel()))
+ {
+ return true;
+ }
+
+ return isNonBaseLevelComplete(level);
+}
+
+bool TextureD3D::isNonBaseLevelComplete(int level) const
+{
+ return false;
+}
+
TextureStorage *TextureD3D::getStorage()
{
ASSERT(mTexStorage);
@@ -1558,32 +1597,8 @@
return mTexStorage->getRenderTarget(context, index, outRT);
}
-bool TextureD3D_2D::isValidLevel(int level) const
+bool TextureD3D_2D::isNonBaseLevelComplete(int level) const
{
- return (mTexStorage ? (level >= 0 && level < mTexStorage->getLevelCount()) : false);
-}
-
-bool TextureD3D_2D::isLevelComplete(int level) const
-{
- if (isImmutable())
- {
- return true;
- }
-
- GLsizei width = getLevelZeroWidth();
- GLsizei height = getLevelZeroHeight();
-
- if (width <= 0 || height <= 0)
- {
- return false;
- }
-
- // The base image level is complete if the width and height are positive
- if (level == static_cast<int>(getBaseLevel()))
- {
- return true;
- }
-
ASSERT(level >= 0 && level <= static_cast<int>(mImageArray.size()) &&
mImageArray[level] != nullptr);
ImageD3D *image = mImageArray[level].get();
@@ -1593,12 +1608,12 @@
return false;
}
- if (image->getWidth() != std::max(1, width >> level))
+ if (image->getWidth() != std::max(1, getLevelZeroWidth() >> level))
{
return false;
}
- if (image->getHeight() != std::max(1, height >> level))
+ if (image->getHeight() != std::max(1, getLevelZeroHeight() >> level))
{
return false;
}
@@ -2004,7 +2019,7 @@
ASSERT(size.width == size.height);
- if (size.width > 0 && isValidFaceLevel(faceIndex, index.getLevelIndex()))
+ if (size.width > 0 && isValidLevel(index.getLevelIndex()))
{
ANGLE_TRY(updateStorageFaceLevel(context, faceIndex, index.getLevelIndex()));
ANGLE_TRY(mRenderer->copyImageCube(context, source, clippedArea, internalFormat,
@@ -2043,7 +2058,7 @@
else
{
ANGLE_TRY(ensureRenderTarget(context));
- if (isValidFaceLevel(faceIndex, index.getLevelIndex()))
+ if (isValidLevel(index.getLevelIndex()))
{
ANGLE_TRY(updateStorageFaceLevel(context, faceIndex, index.getLevelIndex()));
ANGLE_TRY(mRenderer->copyImageCube(
@@ -2084,7 +2099,7 @@
{
ANGLE_TRY(ensureRenderTarget(context));
- ASSERT(isValidFaceLevel(faceIndex, index.getLevelIndex()));
+ ASSERT(isValidLevel(index.getLevelIndex()));
ANGLE_TRY(updateStorageFaceLevel(context, faceIndex, index.getLevelIndex()));
ANGLE_TRY(mRenderer->copyTexture(context, source, sourceLevel.get(), gl::TextureTarget::_2D,
@@ -2132,7 +2147,7 @@
if (!isSRGB(index.getLevelIndex(), faceIndex) && canCreateRenderTargetForImage(index))
{
ANGLE_TRY(ensureRenderTarget(context));
- ASSERT(isValidFaceLevel(faceIndex, index.getLevelIndex()));
+ ASSERT(isValidLevel(index.getLevelIndex()));
ANGLE_TRY(updateStorageFaceLevel(context, faceIndex, index.getLevelIndex()));
const gl::InternalFormat &internalFormatInfo =
@@ -2387,11 +2402,6 @@
return angle::Result::Continue;
}
-bool TextureD3D_Cube::isValidFaceLevel(int faceIndex, int level) const
-{
- return (mTexStorage ? (level >= 0 && level < mTexStorage->getLevelCount()) : 0);
-}
-
bool TextureD3D_Cube::isFaceLevelComplete(int faceIndex, int level) const
{
if (getBaseLevel() >= gl::IMPLEMENTATION_MAX_TEXTURE_LEVELS)
@@ -2404,12 +2414,11 @@
if (isImmutable())
{
- return true;
+ return isValidLevel(level);
}
int levelZeroSize = getLevelZeroWidth();
-
- if (levelZeroSize <= 0)
+ if (levelZeroSize <= 0 || level >= creationLevels(levelZeroSize, levelZeroSize, 1))
{
return false;
}
@@ -3067,35 +3076,11 @@
return angle::Result::Continue;
}
-bool TextureD3D_3D::isValidLevel(int level) const
-{
- return (mTexStorage ? (level >= 0 && level < mTexStorage->getLevelCount()) : 0);
-}
-
-bool TextureD3D_3D::isLevelComplete(int level) const
+bool TextureD3D_3D::isNonBaseLevelComplete(int level) const
{
ASSERT(level >= 0 && level < static_cast<int>(mImageArray.size()) &&
mImageArray[level] != nullptr);
- if (isImmutable())
- {
- return true;
- }
-
- GLsizei width = getLevelZeroWidth();
- GLsizei height = getLevelZeroHeight();
- GLsizei depth = getLevelZeroDepth();
-
- if (width <= 0 || height <= 0 || depth <= 0)
- {
- return false;
- }
-
- if (level == static_cast<int>(getBaseLevel()))
- {
- return true;
- }
-
ImageD3D *levelImage = mImageArray[level].get();
if (levelImage->getInternalFormat() != getBaseLevelInternalFormat())
@@ -3103,17 +3088,17 @@
return false;
}
- if (levelImage->getWidth() != std::max(1, width >> level))
+ if (levelImage->getWidth() != std::max(1, getLevelZeroWidth() >> level))
{
return false;
}
- if (levelImage->getHeight() != std::max(1, height >> level))
+ if (levelImage->getHeight() != std::max(1, getLevelZeroHeight() >> level))
{
return false;
}
- if (levelImage->getDepth() != std::max(1, depth >> level))
+ if (levelImage->getDepth() != std::max(1, getLevelZeroDepth() >> level))
{
return false;
}
@@ -3819,30 +3804,12 @@
return angle::Result::Continue;
}
-bool TextureD3D_2DArray::isValidLevel(int level) const
-{
- return (mTexStorage ? (level >= 0 && level < mTexStorage->getLevelCount()) : 0);
-}
-
-bool TextureD3D_2DArray::isLevelComplete(int level) const
+bool TextureD3D_2DArray::isNonBaseLevelComplete(int level) const
{
ASSERT(level >= 0 && level < (int)ArraySize(mImageArray));
- if (isImmutable())
- {
- return true;
- }
-
- GLsizei width = getLevelZeroWidth();
- GLsizei height = getLevelZeroHeight();
-
- if (width <= 0 || height <= 0)
- {
- return false;
- }
-
- // Layers check needs to happen after the above checks, otherwise out-of-range base level may be
- // queried.
+ // Layers check needs to happen after the base checks in TextureD3D::isLevelComplete, otherwise
+ // an out-of-range base level may be queried.
GLsizei layers = getLayerCount(getBaseLevel());
if (layers <= 0)
@@ -3850,22 +3817,17 @@
return false;
}
- if (level == static_cast<int>(getBaseLevel()))
- {
- return true;
- }
-
if (getInternalFormat(level) != getInternalFormat(getBaseLevel()))
{
return false;
}
- if (getWidth(level) != std::max(1, width >> level))
+ if (getWidth(level) != std::max(1, getLevelZeroWidth() >> level))
{
return false;
}
- if (getHeight(level) != std::max(1, height >> level))
+ if (getHeight(level) != std::max(1, getLevelZeroHeight() >> level))
{
return false;
}
diff --git a/src/libANGLE/renderer/d3d/TextureD3D.h b/src/libANGLE/renderer/d3d/TextureD3D.h
index 0bbef39..c3ce67b 100644
--- a/src/libANGLE/renderer/d3d/TextureD3D.h
+++ b/src/libANGLE/renderer/d3d/TextureD3D.h
@@ -174,6 +174,9 @@
virtual GLint getLevelZeroDepth() const;
GLint creationLevels(GLsizei width, GLsizei height, GLsizei depth) const;
+ bool isLevelComplete(int level) const;
+ bool isValidLevel(int level) const;
+ virtual bool isNonBaseLevelComplete(int level) const;
virtual angle::Result initMipmapImages(const gl::Context *context) = 0;
bool isBaseImageZeroSize() const;
virtual bool isImageComplete(const gl::ImageIndex &index) const = 0;
@@ -353,8 +356,7 @@
angle::Result updateStorage(const gl::Context *context) override;
angle::Result initMipmapImages(const gl::Context *context) override;
- bool isValidLevel(int level) const;
- bool isLevelComplete(int level) const;
+ bool isNonBaseLevelComplete(int level) const override;
bool isImageComplete(const gl::ImageIndex &index) const override;
angle::Result updateStorageLevel(const gl::Context *context, int level);
@@ -484,7 +486,6 @@
angle::Result updateStorage(const gl::Context *context) override;
angle::Result initMipmapImages(const gl::Context *context) override;
- bool isValidFaceLevel(int faceIndex, int level) const;
bool isFaceLevelComplete(int faceIndex, int level) const;
bool isCubeComplete() const;
bool isImageComplete(const gl::ImageIndex &index) const override;
@@ -619,8 +620,7 @@
angle::Result updateStorage(const gl::Context *context) override;
angle::Result initMipmapImages(const gl::Context *context) override;
- bool isValidLevel(int level) const;
- bool isLevelComplete(int level) const;
+ bool isNonBaseLevelComplete(int level) const override;
bool isImageComplete(const gl::ImageIndex &index) const override;
angle::Result updateStorageLevel(const gl::Context *context, int level);
@@ -749,8 +749,7 @@
angle::Result updateStorage(const gl::Context *context) override;
angle::Result initMipmapImages(const gl::Context *context) override;
- bool isValidLevel(int level) const;
- bool isLevelComplete(int level) const;
+ bool isNonBaseLevelComplete(int level) const override;
bool isImageComplete(const gl::ImageIndex &index) const override;
bool isSRGB(GLint level) const;
angle::Result updateStorageLevel(const gl::Context *context, int level);
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 5bdd2f5..d45ae91 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -41,6 +41,12 @@
532617619 OPENGL : MipmapRobustInitTestES3.GenerateMipmapRobustInitOptimization*/* = SKIP
532617619 GLES : MipmapRobustInitTestES3.GenerateMipmapRobustInitOptimization*/* = SKIP
+// Redefining base level on native OpenGL/GLES drivers drops existing higher mip levels.
+480069042 OPENGL : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+480069042 OPENGL : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
+480069042 GLES : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+480069042 GLES : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
+
381742474 : ShaderStorageBufferTest31.ExceedMaxShaderStorageBlockSize/* = SKIP
// Vulkan color attachment incorrectly configured when GL_RASTERIZER_DISCARD is enabled and no glClear is issued.
@@ -2605,6 +2611,8 @@
438268609 WGPU : CopyTextureTest.InternalFormat/* = SKIP
// WGPU does not currently resize textures
438268609 WGPU : CopyTextureTest.CopyOutsideMipmap/* = SKIP
+438268609 WGPU : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+438268609 WGPU : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
// Unimplemented format conversions for texture copies
438268609 WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__AToRGB* = SKIP
438268609 WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__LToRGB* = SKIP
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index d8293b4..a4bf8a7 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -1376,6 +1376,110 @@
EXPECT_GL_NO_ERROR();
}
+// Test copying to a cube map mip level beyond the current storage mip chain, then redefining base
+// level to promote the staged image.
+TEST_P(CopyTextureTest, CubeMapDestLevelPastStorageMips)
+{
+ if (!checkExtensions())
+ {
+ return;
+ }
... (file diff truncated)
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 5bdd2f5..d45ae91 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -41,6 +41,12 @@
532617619 OPENGL : MipmapRobustInitTestES3.GenerateMipmapRobustInitOptimization*/* = SKIP
532617619 GLES : MipmapRobustInitTestES3.GenerateMipmapRobustInitOptimization*/* = SKIP
+// Redefining base level on native OpenGL/GLES drivers drops existing higher mip levels.
+480069042 OPENGL : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+480069042 OPENGL : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
+480069042 GLES : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+480069042 GLES : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
+
381742474 : ShaderStorageBufferTest31.ExceedMaxShaderStorageBlockSize/* = SKIP
// Vulkan color attachment incorrectly configured when GL_RASTERIZER_DISCARD is enabled and no glClear is issued.
@@ -2605,6 +2611,8 @@
438268609 WGPU : CopyTextureTest.InternalFormat/* = SKIP
// WGPU does not currently resize textures
438268609 WGPU : CopyTextureTest.CopyOutsideMipmap/* = SKIP
+438268609 WGPU : CopyTextureTest.CubeMapDestLevelPastStorageMips/* = SKIP
+438268609 WGPU : CopyTextureTest.Texture2DDestLevelPastStorageMips/* = SKIP
// Unimplemented format conversions for texture copies
438268609 WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__AToRGB* = SKIP
438268609 WGPU : CopyTextureVariationsTest.Copy*Texture/ES2_WebGPU__LToRGB* = SKIP
diff --git a/src/tests/gl_tests/CopyTextureTest.cpp b/src/tests/gl_tests/CopyTextureTest.cpp
index d8293b4..a4bf8a7 100644
--- a/src/tests/gl_tests/CopyTextureTest.cpp
+++ b/src/tests/gl_tests/CopyTextureTest.cpp
@@ -1376,6 +1376,110 @@
EXPECT_GL_NO_ERROR();
}
+// Test copying to a cube map mip level beyond the current storage mip chain, then redefining base
+// level to promote the staged image.
+TEST_P(CopyTextureTest, CubeMapDestLevelPastStorageMips)
+{
+ if (!checkExtensions())
+ {
+ return;
+ }
+
+ ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3 &&
+ !IsGLExtensionEnabled("GL_OES_fbo_render_mipmap"));
+
+ GLTexture src;
+ glBindTexture(GL_TEXTURE_2D, src);
+ GLColor srcPix = GLColor::red;
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, &srcPix);
+ ASSERT_GL_NO_ERROR();
+
+ GLTexture cube;
+ glBindTexture(GL_TEXTURE_CUBE_MAP, cube);
+ std::vector<GLubyte> base(1 * 1 * 4, 0x11);
+ for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z;
+ ++face)
+ {
+ glTexImage2D(face, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, base.data());
+ }
+ ASSERT_GL_NO_ERROR();
+
+ // Copy into level 1 of NEGATIVE_Z face (1x1), which lies beyond the 1-level mip chain of 1x1
+ // base.
+ glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, cube,
+ /*destLevel=*/1, GL_RGBA, GL_UNSIGNED_BYTE,
+ /*unpackFlipY=*/GL_FALSE, /*premultiply=*/GL_FALSE,
+ /*unmultiply=*/GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+
+ // Also define level 1 for the remaining 5 faces so the cube map is mipmap complete.
+ for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face < GL_TEXTURE_CUBE_MAP_NEGATIVE_Z;
+ ++face)
+ {
+ glTexImage2D(face, 1, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, base.data());
+ }
+ EXPECT_GL_NO_ERROR();
+
+ // Redefine base level to 2x2 so level 1 (1x1) becomes part of the complete mip chain.
+ std::vector<GLubyte> bigBase(2 * 2 * 4, 0x22);
+ for (GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X; face <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z;
+ ++face)
+ {
+ glTexImage2D(face, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, bigBase.data());
+ }
+ EXPECT_GL_NO_ERROR();
+
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_NEGATIVE_Z,
+ cube, 1);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+}
+
+// Test copying to a 2D texture mip level beyond the current storage mip chain, then redefining base
+// level to promote the staged image.
+TEST_P(CopyTextureTest, Texture2DDestLevelPastStorageMips)
+{
+ if (!checkExtensions())
+ {
+ return;
+ }
+
+ ANGLE_SKIP_TEST_IF(getClientMajorVersion() < 3 &&
+ !IsGLExtensionEnabled("GL_OES_fbo_render_mipmap"));
+
+ GLTexture src;
+ glBindTexture(GL_TEXTURE_2D, src);
+ GLColor srcPix = GLColor::blue;
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, &srcPix);
+ ASSERT_GL_NO_ERROR();
+
+ GLTexture tex;
+ glBindTexture(GL_TEXTURE_2D, tex);
+ std::vector<GLubyte> base(1 * 1 * 4, 0x11);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, base.data());
+ ASSERT_GL_NO_ERROR();
+
+ // Copy into level 1 (1x1), which lies beyond the 1-level mip chain of 1x1 base.
+ glCopyTextureCHROMIUM(src, 0, GL_TEXTURE_2D, tex,
+ /*destLevel=*/1, GL_RGBA, GL_UNSIGNED_BYTE,
+ /*unpackFlipY=*/GL_FALSE, /*premultiply=*/GL_FALSE,
+ /*unmultiply=*/GL_FALSE);
+ EXPECT_GL_NO_ERROR();
+
+ // Redefine base level to 2x2 so level 1 (1x1) becomes part of the complete mip chain.
+ std::vector<GLubyte> bigBase(2 * 2 * 4, 0x22);
+ glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, bigBase.data());
+ EXPECT_GL_NO_ERROR();
+
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 1);
+ EXPECT_GL_NO_ERROR();
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::blue);
+}
+
// Test BGRA to RGBA cube map copy
TEST_P(CopyTextureTest, CubeMapTargetBGRA)
{
@@ -1581,9 +1685,6 @@
return;
}
- // http://anglebug.com/42263316
- ANGLE_SKIP_TEST_IF(IsD3D());
-
// http://anglebug.com/42263799
ANGLE_SKIP_TEST_IF(IsWindows() && IsNVIDIA() && IsOpenGL());
Original Bug Report
Potential out-of-bounds GPU memory write via ASSERT-only gate in TextureD3D::copyTexture
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: In ANGLE’s D3D11 backend, destination mipmap level boundaries during texture copies are guarded against the underlying D3D11 storage levels solely by debug-only ASSERTs. In release builds, this allows an out-of-bounds destination level to calculate a spec-invalid subresource index, which is passed directly to ID3D11DeviceContext::CopySubresourceRegion. This results in undefined driver behavior and potentially out-of-bounds GPU memory writes within the GPU process.
Affected files:
third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/Renderer11.cppthird_party/angle/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp
Estimated timestamp from git blame: 2018-04-12
- Summary of the Issue (Meant for Human Triage)
In ANGLE’s D3D11 backend, the TextureD3D_Cube and TextureD3D_2D texture copy methods (copyTexture and copySubTexture) rely on a debug-only ASSERT to ensure that the user-supplied destination mipmap level (destLevel) fits within the limits of the allocated D3D11 texture storage (mTexStorage).
During front-end API validation (ValidateCopyTextureCHROMIUM), destLevel is only bounded against implementation-defined global caps (e.g., caps.maxCubeMapTextureSize), rather than the destination texture’s actual base dimensions. As a result, a validated API call can specify a destLevel that vastly exceeds the texture’s actual allocated mipmap storage.
In release builds, the ASSERT macro expands to a no-op. On the D3D11 fast-path (when texture formats match and no flip is required), the out-of-bounds destLevel proceeds to Renderer11::copyTexture. There, it calculates a subresource index via the D3D11CalcSubresource macro. This produces a subresource index far beyond the allocated bounds. This index is passed directly to ID3D11DeviceContext::CopySubresourceRegion. Per the DirectX 11 specification, the runtime does not validate destination subresource indices on this call for performance reasons. This passes a spec-invalid index directly to the User-Mode Driver (UMD), leading to undefined behavior and potential out-of-bounds memory writes into driver-managed GPU memory allocations.
(Note: The steps and exploitation scenarios described below are potential and theoretical, as our automated tooling agent does not have the ability to execute live code or driver-level PoCs.)
- Proof-of-Concept & Detailed Execution Flow
Theoretical Attacker Steps to Trigger the Vulnerability
The attacker can initiate this flow either via a compromised renderer process sending forged IPCs, or potentially via WebGL2 texImage2D manipulating an accelerated canvas source (A-SERVER).
- Destination Texture Creation: The attacker creates a cube map texture with a small base size. For example,
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, data). This defines a 4x4 RGBA8 image. - Source Texture Creation: The attacker initializes a 1x1 source texture. For example,
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data). - Trigger Command: The attacker issues a
CopyTextureCHROMIUMcommand targeting an out-of-boundsdest_level. Parameters:source_id=src,source_level=0,dest_target=TEXTURE_CUBE_MAP_NEGATIVE_Z,dest_id=cube,dest_level=10,unpack_flip_y=0.
Step-by-Step Execution Flow
-
Front-End Validation Gap: The command reaches
third_party/angle/src/libANGLE/validationES2.cpp:ValidateCopyTextureCHROMIUM. It callsIsValidCopyTextureDestinationLevel, which checks thedestLevelagainst global capabilities:// validationES2.cpp:268-270 case TextureType::CubeMap: return width <= (caps.maxCubeMapTextureSize >> level) && height <= (caps.maxCubeMapTextureSize >> level);Since
caps.maxCubeMapTextureSizeis 16384 on D3D11,16384 >> 10 = 16. The source width (1) is<= 16, so validation incorrectly succeeds despite the destination texture’s base size only being 4x4. -
Backend Processing & Redefinition: Execution proceeds to
TextureD3D_Cube::copyTexture(third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:2123). It callsredefineImagewithlevel=10. This successfully definesmImageArray[5][10]on the host without overflowing, asmImageArrayis a statically sizedstd::arraycontainingIMPLEMENTATION_MAX_TEXTURE_LEVELS(16) slots. -
Storage Allocation: The render-target fast path evaluates
canCreateRenderTargetForImagetotrueand callsensureRenderTarget(context). SincemTexStorageis initially null, this triggersinitializeStorage->createCompleteStorage. Storage size is computed based on the base-level image (4x4), resulting inmMipLevels = log2(4) + 1 = 3. TheTextureStorage11_Cubeis instantiated with 6 faces and 3 mips (18 subresources total). -
The Flawed Release-Build Guard: Execution returns to
TextureD3D_Cube::copyTexture:// TextureD3D.cpp:2154-2156 ANGLE_TRY(ensureRenderTarget(context)); ASSERT(isValidFaceLevel(faceIndex, index.getLevelIndex())); ANGLE_TRY(updateStorageFaceLevel(context, faceIndex, index.getLevelIndex()));The only bounds check ensuring
destLevel < mTexStorage->getLevelCount()is theASSERT(isValidFaceLevel(faceIndex, index.getLevelIndex())). In production release builds, this expands to a no-op, allowing the out-of-boundsdestLevel = 10to bypass safety checks. -
Subresource Calculation Sink: The out-of-bounds level is passed to
Renderer11::copyTexture(third_party/angle/src/libANGLE/renderer/d3d/d3d11/Renderer11.cpp). The fast-path conditions are met (!unpackFlipYand matching formats). It calculates the subresource index viadestStorage11->getSubresourceIndex:// TextureStorage11.cpp:213-225 UINT mipSlice = static_cast<UINT>(index.getLevelIndex() + mTopLevel); // 10 UINT arraySlice = static_cast<UINT>(...); // 5 (Negative Z) UINT subresource = D3D11CalcSubresource(mipSlice, arraySlice, mMipLevels); // 10 + 5 * 3 = 25The computed subresource
25greatly exceeds the18subresources actually allocated. -
Undefined Behavior Execution:
// Renderer11.cpp:2696 mDeviceContext->CopySubresourceRegion( destResource->get(), destSubresource, destOffset.x, destOffset.y, destOffset.z, sourceResource->get(), sourceSubresource, &d3dBox);The
DstSubresource = 25is passed directly to the D3D11 driver. This induces undefined driver behavior and potential out-of-bounds GPU memory writes.
Potential Fix
Replace the debug-only ASSERT in TextureD3D_Cube::copyTexture, TextureD3D_Cube::copySubTexture, TextureD3D_2D::copyTexture, and TextureD3D_2D::copySubTexture with actual runtime checks.
if (!isValidFaceLevel(faceIndex, index.getLevelIndex()))
{
return angle::Result::Stop; // Or emit GL_INVALID_VALUE / internal error
}
Alternatively, ValidateCopyTextureCHROMIUM should strictly evaluate destLevel against the initialized bounds of the target texture if it is already allocated, rather than relying exclusively on global caps.maxCubeMapTextureSize.
- Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
> The report accurately identifies a validating-layer gap in ANGLE’s D3D11 backend. Front-end validation (ValidateCopyTextureCHROMIUM / ValidateCopySubTextureCHROMIUM) bounds destLevel against global caps rather than the destination texture’s actual base-level dimensions. In TextureD3D_Cube::copyTexture and TextureD3D_2D, the destination level’s bound against the allocated D3D11 storage levels is only guarded by a debug-only ASSERT(isValidFaceLevel(...)). In release builds, this is a no-op, allowing an out-of-bounds destLevel to flow into D3D11CalcSubresource, generating an out-of-bounds destSubresource index.
>
> Applying the KB ‘GPU/media driver-sink triage’ checklist:
> (a) Validation function ValidateCopyTextureCHROMIUM misses the parameter bound check against the texture’s true mip levels.
> (b) The backend call site is mDeviceContext->CopySubresourceRegion in Renderer11.cpp (fast-path).
> (c) The DstSubresource parameter is explicitly listed as a bounds-bearing parameter in the KB triage table.
> Since (a), (b), and (c) hold from source, this qualifies for S1 per the GPU validating-layer gap row.
>
> Severity calculation:
> - The vulnerability is reachable via A-SERVER (WebGL texImage2D with an accelerated canvas source) or A-RENDERER.
> - The D3D11 backend is Windows-only, so it is rated on Windows.
> - The GPU process is sandboxed on Windows, so memory corruption ceilings at High (S1).
> - The previous critic downgraded to S2 because ‘driver behavior varies’, but the guidelines explicitly state that ‘requires a specific driver version/vendor’ or ‘majority of drivers tolerate it’ is NOT a downgrade factor. If the (a)/(b)/(c) test passes and it ships on >=1 platform, we rate at that platform’s tier.
>
> Probability of being real and exploitable: 100%. The code trace is completely accurate, and the missing validation allows a spec-invalid index to trigger undefined driver behavior.
Codebase Investigator Verification Logs:
ValidateCopyTextureCHROMIUMFlow Verified: Extensively checkedthird_party/angle/src/libANGLE/validationES2.cpp(lines 3533-3662) andIsValidCopyTextureDestinationLevel(lines 251-274). The validator confirmeddestLevelis exclusively verified againstcaps.maxCubeMapTextureSize >> levelwithout factoring in actualdestdimensions, precisely aligning with the vulnerability claim.- Storage and ASSERT Interaction Verified: Analyzed
TextureD3D.cpp(TextureD3D_Cube::copyTexture).redefineImagereleases storage iflevel >= storageLevels, but thencreateCompleteStoragerebuilds it matching the base-level size, meaning the storage level count remains strictly 3 for a 4x4 image. Confirmed the only boundary gate protecting the D3D call fromdestLevel=10isASSERT(isValidFaceLevel(...)). D3D11CalcSubresourceContext Verified: TracedTextureStorage11::getSubresourceIndexusage ofmMipLevels.mMipLevelsis set viaTextureStorage11_Cubeconstructor asmTopLevel + levels(e.g., 3). Passing adestLevelof 10 deterministically produces an OOBdestSubresourceparameter calculation.- Architectural Bounds: Confirmed host bounds are safe because
mImageArraysizes dynamically pad toIMPLEMENTATION_MAX_TEXTURE_LEVELS = 16, ensuring no host heap overflow occurs despite the backend driver overflow.
Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.