High patch-now ✔︎ verified CVSS 9.6 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Sub-componentD3D
Bug ClassOOB
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Completeness check now rejects level>=creationLevels and immutable isValidLevel, routing copies to staging -- matches claim. D3D-backend (Windows) specific; OOB write into unallocated GPU subresource.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker536664909
Fix commitc477cf31f7a9 (angle/angle) +176/-106
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-20 (per vendor advisory)
Fix landed2026-08-20 (public git)
Disclosed2026-09-08
Patch gap19 days — fix public in source before the advisory

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 TextureD3D helper that returns the number of mip levels a texture of a given width/height/depth would legitimately have (roughly floor(log2(max(w,h,d))) + 1), i.e. the size of the storage mip chain.
`glCopyTextureCHROMIUM`
the CHROMIUM_copy_texture entry 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.

Key insight
The completeness predicate omitted a bounds check tying the destination level to the actual storage mip count, letting a copy write to a nonexistent D3D subresource; the fix adds the level >= creationLevels(...) (and immutable isValidLevel) guard so such levels are declared incomplete and diverted to the safe staging path.

Attack Path

  1. 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).
  2. Copy into a level past storage Call glCopyTextureCHROMIUM with destLevel = 1, which lies beyond the one-level mip chain; the old isLevelComplete/isFaceLevelComplete fails to reject it (and unconditionally accepts it for immutable textures).
  3. Take the direct-copy fast path Because the level is judged complete, updateStorageLevel/updateStorageFaceLevel and mRenderer->copyTexture/copyImageCube run 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”).
  4. (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

An attacker who can drive WebGL / the copy-texture command path from a compromised or malicious renderer gains an out-of-bounds write inside the GPU process on the Direct3D backend, reachable without special privileges beyond issuing normal texture and copy commands with a mismatched destination mip level. Beyond the memory corruption, escalation to GPU-process code execution or a sandbox escape is (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.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/libANGLE/renderer/d3d/TextureD3D.cpp
  • src/libANGLE/renderer/d3d/TextureD3D.h

Audit Directions

  • Completeness predicates missing a storage-range check
    Audit any is*Complete/is*Valid routine 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 shortcuts
    Flag 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 completeness
    Review copyTexture, copySubTexture, updateStorageLevel and copyImageCube callers 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.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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());
Loading diff…

Original Bug Report

reported by aw...@chromium.org

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.cpp
  • third_party/angle/src/libANGLE/renderer/d3d/d3d11/Renderer11.cpp
  • third_party/angle/src/libANGLE/renderer/d3d/d3d11/TextureStorage11.cpp

Estimated timestamp from git blame: 2018-04-12

  1. 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.)


  1. 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).

  1. 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.
  2. 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).
  3. Trigger Command: The attacker issues a CopyTextureCHROMIUM command targeting an out-of-bounds dest_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

  1. Front-End Validation Gap: The command reaches third_party/angle/src/libANGLE/validationES2.cpp:ValidateCopyTextureCHROMIUM. It calls IsValidCopyTextureDestinationLevel, which checks the destLevel against global capabilities:

    // validationES2.cpp:268-270
    case TextureType::CubeMap:
        return width <= (caps.maxCubeMapTextureSize >> level) &&
               height <= (caps.maxCubeMapTextureSize >> level);
    

    Since caps.maxCubeMapTextureSize is 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.

  2. Backend Processing & Redefinition: Execution proceeds to TextureD3D_Cube::copyTexture (third_party/angle/src/libANGLE/renderer/d3d/TextureD3D.cpp:2123). It calls redefineImage with level=10. This successfully defines mImageArray[5][10] on the host without overflowing, as mImageArray is a statically sized std::array containing IMPLEMENTATION_MAX_TEXTURE_LEVELS (16) slots.

  3. Storage Allocation: The render-target fast path evaluates canCreateRenderTargetForImage to true and calls ensureRenderTarget(context). Since mTexStorage is initially null, this triggers initializeStorage -> createCompleteStorage. Storage size is computed based on the base-level image (4x4), resulting in mMipLevels = log2(4) + 1 = 3. The TextureStorage11_Cube is instantiated with 6 faces and 3 mips (18 subresources total).

  4. 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 the ASSERT(isValidFaceLevel(faceIndex, index.getLevelIndex())). In production release builds, this expands to a no-op, allowing the out-of-bounds destLevel = 10 to bypass safety checks.

  5. 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 (!unpackFlipY and matching formats). It calculates the subresource index via destStorage11->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 = 25
    

    The computed subresource 25 greatly exceeds the 18 subresources actually allocated.

  6. Undefined Behavior Execution:

    // Renderer11.cpp:2696
    mDeviceContext->CopySubresourceRegion(
        destResource->get(), destSubresource, destOffset.x, destOffset.y, destOffset.z,
        sourceResource->get(), sourceSubresource, &d3dBox);
    

    The DstSubresource = 25 is 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.


  1. 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:

  • ValidateCopyTextureCHROMIUM Flow Verified: Extensively checked third_party/angle/src/libANGLE/validationES2.cpp (lines 3533-3662) and IsValidCopyTextureDestinationLevel (lines 251-274). The validator confirmed destLevel is exclusively verified against caps.maxCubeMapTextureSize >> level without factoring in actual dest dimensions, precisely aligning with the vulnerability claim.
  • Storage and ASSERT Interaction Verified: Analyzed TextureD3D.cpp (TextureD3D_Cube::copyTexture). redefineImage releases storage if level >= storageLevels, but then createCompleteStorage rebuilds 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 from destLevel=10 is ASSERT(isValidFaceLevel(...)).
  • D3D11CalcSubresource Context Verified: Traced TextureStorage11::getSubresourceIndex usage of mMipLevels. mMipLevels is set via TextureStorage11_Cube constructor as mTopLevel + levels (e.g., 3). Passing a destLevel of 10 deterministically produces an OOB destSubresource parameter calculation.
  • Architectural Bounds: Confirmed host bounds are safe because mImageArray sizes dynamically pad to IMPLEMENTATION_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.

View on issue tracker